//! This library constructs Open Badges [version 2][open-badges-v2] //! compatible badge images. These images contain metadata which //! cryptographically certifies some achievement on the part of the //! recipient. //! //! [open-badges-v2]: https://www.imsglobal.org/sites/default/files/Badges/OBv2p0/index.html // [open-badges-v3]: https://www.imsglobal.org/spec/ob/v3p0/ pub mod v2; mod private { pub trait Sealed {} } pub trait ValidityCheck { fn check_validity(&self) -> Result<(), Error>; } #[derive(Debug, thiserror::Error)] pub enum Error { #[error("PNG Decoding Error: {0}")] PngDecode(#[from] png::DecodingError), #[error("PNG Encoding Error: {0}")] PngEncode(#[from] png::EncodingError), #[error("I/O Error: {0}")] IO(#[from] std::io::Error), #[error("JSON Error: {0}")] JSON(#[from] serde_json::Error), #[error("The source is not a PNG or SVG image")] InvalidImage, #[error("The source image already contains badge metadata")] AlreadyABadge, #[error("Invalid payload: {0}")] InvalidPayload(String), } /// A convenient container for the data resulting from a successful /// call to [`Metadata::render`]. The contained data fields are public /// and meant to be accessed directly. pub struct RenderedMetadata { pub verify: String, pub metadata: String, pub signature: Option, } /// Identifies types which can be used as the payload by the [`bake`] /// function. pub trait Metadata: private::Sealed { /// Checks that the payload is valid, renders it into the format /// required for embedding into an image, and generates a /// signature for the rendered metadata if appropriate. fn render(self) -> Result; } /// Generates a badge image containing the payload, based on the image /// in the source, and writes it to the target. /// /// There are many options for the **source** and **target** /// arguments. For example, [`std::io::File`], [`std::io::Cursor`], /// and [`std::io::BufReader`] or [`std::io::BufWriter`] all work, and /// [`std::net::TcpStream`] works as a target but not as a source /// because it isn't seekable. /// /// If a PNG, the source image must not be animated. pub fn bake( payload: impl Metadata, mut source: impl std::io::Read + std::io::Seek, target: impl std::io::Write, ) -> Result<(), Error> { // First we need to figure out if the source image is a PNG, an // SVG, or neither. let mut magic = [0u8; 8]; let pos = source.stream_position()?; source.read_exact(&mut magic)?; source.seek(std::io::SeekFrom::Start(pos))?; match magic { [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] => bake_png(payload, source, target), [0x3C, 0x3F, 0x78, 0x6D, 0x6C, 0x20, _, _] => bake_svg(payload, source, target), [0x3C, 0x73, 0x76, 0x66, 0x20, _, _, _] => bake_svg(payload, source, target), _ => Err(Error::InvalidImage), } } fn bake_png( payload: impl Metadata, source: impl std::io::Read, target: impl std::io::Write, ) -> Result<(), Error> { // We need to write the JSON-serialized metadata to an // uncompressed iTXt chunk with the "openbadges" keyword, which we // insert into in the image. If there is already such a chunk, we // return an error. let decoder = png::Decoder::new(source); let mut reader = decoder.read_info()?; let mut frame_data = vec![0; reader.output_buffer_size()]; let frame_info = reader.next_frame(&mut frame_data)?; reader.finish()?; let info = reader.info(); if info.is_animated() { return Err(Error::InvalidImage); } if info .utf8_text .iter() .any(|chunk| chunk.keyword == "openbadges") { return Err(Error::AlreadyABadge); } let rendered = payload.render()?; let chunk = png::text_metadata::ITXtChunk::new("openbadges", rendered.metadata); // Write the png with the chunk added to it into the target let mut encoder = png::Encoder::new(target, info.width, info.height); encoder.set_color(info.color_type); encoder.set_depth(info.bit_depth); if let Some(gamma) = info.source_gamma { encoder.set_source_gamma(gamma); } if let Some(chroma) = info.source_chromaticities { encoder.set_source_chromaticities(chroma); } encoder.set_compression(png::Compression::Best); let mut writer = encoder.write_header()?; writer.write_text_chunk(&chunk)?; // Write the image data to the target writer.write_image_data(&frame_data[..frame_info.buffer_size()])?; writer.finish()?; Ok(()) } fn bake_svg( payload: impl Metadata, mut source: impl std::io::Read, mut target: impl std::io::Write, ) -> Result<(), Error> { // If it's an SVG, add an xmlns:openbadges attribute to the // tag with the value “http://openbadges.org”. Directly after the // tag, we add an tag containing the // JSON-serialized metadata, wrapped in a ... // block. If there is already such a tag, we return an error. The // tag must have a verify attribute // containing either the signature or the verification URL for the // assertion. let mut original = String::new(); source.read_to_string(&mut original)?; if let Some((before, after)) = original.split_once("") { target.write_all(middle.as_bytes())?; target.write_all(b">")?; let rendered = payload.render()?; target.write_all(b"")?; target.write_all(rendered.metadata.as_bytes())?; target.write_all(b"")?; target.write_all(rest.as_bytes())?; } else { return Err(Error::InvalidImage); } } else { return Err(Error::InvalidImage); }; Ok(()) } #[cfg(test)] mod tests { use std::io::Cursor; use super::*; fn fake_v2_payload() -> v2::Payload { let issuer = v2::Issuer::default() .with_id("https://example.com/issuer") .with_name("Example Issuer") .with_url("https://example.com/") .with_email("HkKzT@example.com"); let payload = v2::Payload::default() .with_id("https://example.com/assertion/42") .with_recipient_email("recipient@example.com") .with_issuer(issuer) .with_image("https://example.com/123456.png") .with_badge_class_id("https://example.com/badgeclass/1") .with_badge_class_name("Example Badge Class") .with_badge_class_description("This is totally a description") .with_badge_class_image_url("https://example.com/badgeclass/1.png") .with_badge_class_criteria( "https://example.com/badgeclass/1/earning", "You put your left foot in, you put your left foot out, you put your left foot in and you shake it all about." ) .with_issued_timestamp(jiff::Timestamp::UNIX_EPOCH); payload } #[test] fn test_bake_png() { let payload = fake_v2_payload(); let input = std::fs::File::open("test.png").unwrap(); let output = Cursor::new(Vec::new()); bake(payload, input, output).unwrap(); } #[test] fn test_bake_svg() { let payload = fake_v2_payload(); let input = std::fs::File::open("test.svg").unwrap(); let output = Cursor::new(Vec::new()); bake(payload, input, output).unwrap(); } #[test] fn test_bake_other() { let payload = fake_v2_payload(); let input = std::fs::File::open("test.junk").unwrap(); let output = Cursor::new(Vec::new()); assert!(bake(payload, input, output).is_err()); } }