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