lib.rs raw

//! 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<String>,
}

/// 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<RenderedMetadata, Error>;
}

/// 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 <svg>
    // tag with the value “http://openbadges.org”. Directly after the
    // <svg> tag, we add an <openbadges:assertion> tag containing the
    // JSON-serialized metadata, wrapped in a <![CDATA[...]]>
    // block. If there is already such a tag, we return an error. The
    // <openbadges:assertion> 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("<svg") {
        target.write_all(before.as_bytes())?;
        target.write_all(b"<svg xmlns:openbadges=\"http://openbadges.org\"")?;
        if let Some((middle, rest)) = after.split_once(">") {
            target.write_all(middle.as_bytes())?;
            target.write_all(b">")?;

            let rendered = payload.render()?;

            target.write_all(b"<openbadges:assertion verify=\"")?;
            target.write_all(rendered.verify.as_bytes())?;
            target.write_all(b"\"><![CDATA[")?;
            target.write_all(rendered.metadata.as_bytes())?;
            target.write_all(b"]]></openbadges:assertion>")?;

            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());
    }
}