#![forbid(unsafe_code)]
use thiserror::Error;
mod bmff;
mod flac;
mod font;
mod gif;
mod id3;
mod jpeg;
mod jxl;
mod ogg;
mod pdf;
mod png;
mod riff;
mod svg;
mod text;
pub(crate) mod text_standard;
mod tiff;
mod util;
mod zip;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AssetFormat {
Jpeg,
Png,
Bmff,
Riff,
Tiff,
Gif,
Svg,
Pdf,
Zip,
Id3,
Flac,
Ogg,
Font,
Jxl,
TextUnstructured,
TextStructured {
comment_prefix: &'static str,
comment_suffix: &'static str,
},
TextHtml,
C2paStore,
}
pub const C2PA_BMFF_UUID: [u8; 16] = [
0xd8, 0xfe, 0xc3, 0xd6, 0x1b, 0x0e, 0x48, 0x3c, 0x92, 0x97, 0x58, 0x28, 0x87, 0x7e, 0xc4, 0x81,
];
pub const BMFF_HASH_EXCLUSION_PATHS: &[&str] = &["/uuid", "/ftyp", "/mfra", "/free", "/skip"];
pub const HASH_MODE_MIMES: &[&str] = &[
"application/mp4",
"audio/mp4",
"audio/wav",
"image/avif",
"image/heic",
"image/heic-sequence",
"image/heif",
"image/heif-sequence",
"image/jpeg",
"image/png",
"image/webp",
"video/mp4",
"video/quicktime",
"video/x-m4v",
"video/x-msvideo",
];
pub fn supports_hash_mode(mime: &str) -> bool {
let canonical = crate::c2pa_core::spec::canonicalize_mime(mime);
HASH_MODE_MIMES.contains(&canonical.as_str())
}
impl AssetFormat {
pub fn from_mime(mime: &str) -> Option<Self> {
let canon = crate::c2pa_core::spec::canonicalize_mime(mime);
Some(match canon.as_str() {
"image/jpeg" => Self::Jpeg,
"image/png" => Self::Png,
"image/webp" => Self::Riff,
"image/tiff" | "image/x-adobe-dng" => Self::Tiff,
"image/x-sony-arw" | "image/x-nikon-nef" => Self::Tiff,
"image/avif"
| "image/heic"
| "image/heic-sequence"
| "image/heif"
| "image/heif-sequence"
| "video/mp4"
| "video/quicktime"
| "audio/mp4"
| "video/x-m4v"
| "application/mp4" => Self::Bmff,
"image/gif" => Self::Gif,
"image/svg+xml" => Self::Svg,
"image/jxl" => Self::Jxl,
"video/x-msvideo" | "audio/wav" => Self::Riff,
"audio/mpeg" => Self::Id3,
"audio/flac" => Self::Flac,
"audio/ogg" => Self::Ogg,
"application/pdf" => Self::Pdf,
"application/epub+zip"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.template"
| "application/vnd.ms-word.document.macroenabled.12"
| "application/vnd.ms-word.template.macroenabled.12"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.template"
| "application/vnd.ms-excel.sheet.macroenabled.12"
| "application/vnd.ms-excel.template.macroenabled.12"
| "application/vnd.ms-excel.sheet.binary.macroenabled.12"
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
| "application/vnd.openxmlformats-officedocument.presentationml.template"
| "application/vnd.openxmlformats-officedocument.presentationml.slideshow"
| "application/vnd.ms-powerpoint.presentation.macroenabled.12"
| "application/vnd.ms-powerpoint.template.macroenabled.12"
| "application/vnd.ms-powerpoint.slideshow.macroenabled.12"
| "application/vnd.ms-visio.drawing"
| "application/vnd.ms-visio.drawing.macroenabled.12"
| "application/vnd.ms-visio.stencil"
| "application/vnd.ms-visio.stencil.macroenabled.12"
| "application/vnd.ms-visio.template"
| "application/vnd.ms-visio.template.macroenabled.12"
| "application/vnd.oasis.opendocument.text"
| "application/vnd.oasis.opendocument.spreadsheet"
| "application/vnd.oasis.opendocument.presentation"
| "application/oxps"
| "application/vnd.ms-xpsdocument" => Self::Zip,
"font/otf"
| "font/ttf"
| "font/sfnt"
| "application/font-sfnt"
| "application/x-font-ttf" => Self::Font,
"text/plain" | "text/csv" | "application/json" => Self::TextUnstructured,
"text/html" => Self::TextHtml,
"text/x-python" => Self::TextStructured {
comment_prefix: "#",
comment_suffix: "",
},
"application/c2pa" => Self::C2paStore,
other => match c2pa_text::structured::comment_syntax(other) {
Some((comment_prefix, comment_suffix)) => Self::TextStructured {
comment_prefix,
comment_suffix,
},
None => return None,
},
})
}
pub fn from_mime_for_profile(
mime: &str,
profile: crate::c2pa_core::EngineProfile,
) -> Option<Self> {
if !profile.permits_mime(mime) {
return None;
}
Self::from_mime(mime)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DataHashExclusion {
pub start: usize,
pub length: usize,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum FormatError {
#[error("operation not implemented for format {0:?}")]
NotImplemented(AssetFormat),
#[error("not a valid {format:?} asset: {detail}")]
InvalidStructure {
format: AssetFormat,
detail: &'static str,
},
#[error("unexpected end of data while parsing {0:?}")]
Truncated(AssetFormat),
#[error("manifest of {got} bytes exceeds the {max}-byte limit for {format:?}")]
ManifestTooLarge {
format: AssetFormat,
max: usize,
got: usize,
},
#[error("unsupported {format:?} variant: {detail}")]
UnsupportedVariant {
format: AssetFormat,
detail: &'static str,
},
}
pub(crate) use bmff::{bmff_box_ranges, bmff_exclusion_ranges};
pub(crate) use bmff::{
bmff_hash, bmff_hash_with_exclusions, BmffDataMap, BmffExclusionMap, BmffSubsetMap,
};
pub(crate) use bmff::bmff_hash_reader;
pub(crate) use bmff::{bmff_fragment_leaf_hash, bmff_merkle_boxes, BmffMerkleBox};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoxSpan {
pub name: String,
pub start: usize,
pub end: usize,
}
pub fn box_spans(format: AssetFormat, data: &[u8]) -> Result<Option<Vec<BoxSpan>>, FormatError> {
match format {
AssetFormat::Jpeg => jpeg::box_spans(data).map(Some),
AssetFormat::Png => png::box_spans(data).map(Some),
_ => Ok(None),
}
}
pub(crate) use zip::{
zip_central_directory_hash_parts, zip_entry_data, zip_entry_hash_span, zip_entry_local_span,
zip_entry_names,
};
pub(crate) use bmff::bmff_mdat_payloads;
pub fn extract_manifest(format: AssetFormat, data: &[u8]) -> Result<Option<Vec<u8>>, FormatError> {
let manifest = match format {
AssetFormat::Jpeg => jpeg::extract(data),
AssetFormat::Png => png::extract(data),
AssetFormat::Bmff => bmff::extract(data),
AssetFormat::Riff => riff::extract(data),
AssetFormat::Tiff => tiff::extract(data),
AssetFormat::Gif => gif::extract(data),
AssetFormat::Svg => svg::extract(data),
AssetFormat::Pdf => pdf::extract(data),
AssetFormat::Zip => zip::extract(data),
AssetFormat::Id3 => id3::extract(data),
AssetFormat::Flac => flac::extract(data),
AssetFormat::Ogg => ogg::extract(data),
AssetFormat::Font => font::extract(data),
AssetFormat::Jxl => jxl::extract(data),
AssetFormat::TextUnstructured => text::extract(text::TextMethod::Unstructured, data),
AssetFormat::TextStructured { .. } => text::extract(text::TextMethod::Structured, data),
AssetFormat::TextHtml => text::extract(text::TextMethod::Html, data),
AssetFormat::C2paStore => {
if data.is_empty() {
Ok(None)
} else {
ensure_manifest_store_size(format, data.len())?;
Ok(Some(data.to_vec()))
}
}
}?;
if let Some(store) = &manifest {
ensure_manifest_store_size(format, store.len())?;
}
Ok(manifest)
}
fn ensure_manifest_store_size(format: AssetFormat, got: usize) -> Result<(), FormatError> {
if got > crate::MAX_MANIFEST_STORE_BYTES {
return Err(FormatError::ManifestTooLarge {
format,
max: crate::MAX_MANIFEST_STORE_BYTES,
got,
});
}
Ok(())
}
#[cfg(test)]
pub fn strip_manifest(format: AssetFormat, asset: &[u8]) -> Result<Vec<u8>, FormatError> {
match format {
AssetFormat::Jpeg => jpeg::strip(asset),
AssetFormat::Png => png::strip(asset),
AssetFormat::Bmff => bmff::strip(asset),
AssetFormat::Riff => riff::strip(asset),
AssetFormat::Gif => gif::strip(asset),
AssetFormat::Svg => svg::strip(asset),
AssetFormat::Zip => zip::strip(asset),
AssetFormat::Flac => flac::strip(asset),
AssetFormat::Ogg => ogg::strip(asset),
AssetFormat::Jxl => jxl::strip(asset),
AssetFormat::Tiff
| AssetFormat::Font
| AssetFormat::Id3
| AssetFormat::Pdf
| AssetFormat::TextUnstructured
| AssetFormat::TextStructured { .. }
| AssetFormat::TextHtml
| AssetFormat::C2paStore => Ok(asset.to_vec()),
}
}
#[cfg(test)]
pub fn build_manifest_carrier(
format: AssetFormat,
manifest_store: &[u8],
) -> Result<Vec<u8>, FormatError> {
match format {
AssetFormat::Jpeg => jpeg::build_app11_segments(manifest_store),
AssetFormat::Png => Ok(png::build_cabx_chunk(manifest_store)),
AssetFormat::Riff => riff::build_c2pa_chunk(manifest_store),
AssetFormat::Bmff => Ok(bmff::build_c2pa_uuid_box(manifest_store)),
other => Err(FormatError::NotImplemented(other)),
}
}
#[cfg(test)]
pub fn embed_manifest(
format: AssetFormat,
asset: &[u8],
manifest_store: &[u8],
) -> Result<Vec<u8>, FormatError> {
match format {
AssetFormat::Jpeg => jpeg::embed(asset, manifest_store),
AssetFormat::Png => png::embed(asset, manifest_store),
AssetFormat::Bmff => bmff::embed(asset, manifest_store),
AssetFormat::Riff => riff::embed(asset, manifest_store),
AssetFormat::Tiff => tiff::embed(asset, manifest_store),
AssetFormat::Gif => gif::embed(asset, manifest_store),
AssetFormat::Svg => svg::embed(asset, manifest_store),
AssetFormat::Pdf => pdf::embed(asset, manifest_store),
AssetFormat::Zip => zip::embed(asset, manifest_store),
AssetFormat::Id3 => id3::embed(asset, manifest_store),
AssetFormat::Flac => flac::embed(asset, manifest_store),
AssetFormat::Ogg => ogg::embed(asset, manifest_store),
AssetFormat::Font => font::embed(asset, manifest_store),
AssetFormat::Jxl => jxl::embed(asset, manifest_store),
AssetFormat::TextUnstructured => {
text::embed(text::TextMethod::Unstructured, asset, manifest_store)
}
AssetFormat::TextStructured {
comment_prefix,
comment_suffix,
} => text::embed_structured(asset, manifest_store, comment_prefix, comment_suffix),
AssetFormat::TextHtml => text::embed(text::TextMethod::Html, asset, manifest_store),
AssetFormat::C2paStore => Err(FormatError::NotImplemented(AssetFormat::C2paStore)),
}
}
pub fn compute_data_hash_exclusions(
format: AssetFormat,
asset_with_placeholder: &[u8],
) -> Result<Vec<DataHashExclusion>, FormatError> {
match format {
AssetFormat::Jpeg => jpeg::exclusions(asset_with_placeholder),
AssetFormat::Png => png::exclusions(asset_with_placeholder),
AssetFormat::Bmff => bmff::exclusions(asset_with_placeholder),
AssetFormat::Riff => riff::exclusions(asset_with_placeholder),
AssetFormat::Flac => flac::exclusions(asset_with_placeholder),
AssetFormat::Ogg => ogg::exclusions(asset_with_placeholder),
AssetFormat::TextUnstructured => {
text::exclusions(text::TextMethod::Unstructured, asset_with_placeholder)
}
AssetFormat::TextStructured { .. } => {
text::exclusions(text::TextMethod::Structured, asset_with_placeholder)
}
AssetFormat::TextHtml => text::exclusions(text::TextMethod::Html, asset_with_placeholder),
AssetFormat::Tiff => tiff::exclusions(asset_with_placeholder),
AssetFormat::Gif => gif::exclusions(asset_with_placeholder),
AssetFormat::Svg => svg::exclusions(asset_with_placeholder),
AssetFormat::Pdf => pdf::exclusions(asset_with_placeholder),
AssetFormat::Zip => zip::exclusions(asset_with_placeholder),
AssetFormat::Id3 => id3::exclusions(asset_with_placeholder),
AssetFormat::Font => font::exclusions(asset_with_placeholder),
AssetFormat::Jxl => jxl::exclusions(asset_with_placeholder),
AssetFormat::C2paStore => Ok(Vec::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) fn dummy_manifest_store() -> Vec<u8> {
use crate::c2pa_core::jumbf::{assertion_box, build_manifest, build_manifest_store};
let assertion = assertion_box("c2pa.actions.v2", &[0xa0], None);
let manifest = build_manifest("urn:c2pa:test:0001", &[assertion], &[0xa0], &[0xd2, 0x84]);
build_manifest_store(&[manifest])
}
#[test]
fn from_mime_covers_conformance_matrix() {
let cases: &[(&str, AssetFormat)] = &[
("image/jpeg", AssetFormat::Jpeg),
("image/png", AssetFormat::Png),
("image/webp", AssetFormat::Riff),
("image/tiff", AssetFormat::Tiff),
("image/x-adobe-dng", AssetFormat::Tiff),
("image/avif", AssetFormat::Bmff),
("image/heic", AssetFormat::Bmff),
("image/heif", AssetFormat::Bmff),
("image/heic-sequence", AssetFormat::Bmff),
("image/heif-sequence", AssetFormat::Bmff),
("video/mp4", AssetFormat::Bmff),
("video/quicktime", AssetFormat::Bmff),
("audio/mp4", AssetFormat::Bmff),
("video/x-m4v", AssetFormat::Bmff),
("application/mp4", AssetFormat::Bmff),
("image/gif", AssetFormat::Gif),
("image/svg+xml", AssetFormat::Svg),
("image/jxl", AssetFormat::Jxl),
("video/x-msvideo", AssetFormat::Riff),
("audio/wav", AssetFormat::Riff),
("audio/mpeg", AssetFormat::Id3),
("audio/flac", AssetFormat::Flac),
("audio/ogg", AssetFormat::Ogg),
("application/pdf", AssetFormat::Pdf),
("application/epub+zip", AssetFormat::Zip),
(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
AssetFormat::Zip,
),
(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
AssetFormat::Zip,
),
(
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
AssetFormat::Zip,
),
("application/vnd.oasis.opendocument.text", AssetFormat::Zip),
(
"application/vnd.oasis.opendocument.spreadsheet",
AssetFormat::Zip,
),
(
"application/vnd.oasis.opendocument.presentation",
AssetFormat::Zip,
),
("application/oxps", AssetFormat::Zip),
("application/vnd.ms-xpsdocument", AssetFormat::Zip),
("font/otf", AssetFormat::Font),
("font/ttf", AssetFormat::Font),
];
for (mime, want) in cases {
assert_eq!(AssetFormat::from_mime(mime), Some(*want), "mime {mime}");
}
assert_eq!(AssetFormat::from_mime("application/octet-stream"), None);
assert_eq!(
AssetFormat::from_mime("image/jpeg; charset=binary"),
Some(AssetFormat::Jpeg)
);
}
#[test]
fn v2_4_registry_routes_every_mime() {
use crate::c2pa_core::{spec::mimes_for_version, SpecVersion};
for mime in mimes_for_version(SpecVersion::V2_4) {
assert!(
AssetFormat::from_mime(mime).is_some(),
"v2.4 registry MIME has no format implementation: {mime}"
);
}
}
#[test]
fn every_c2pa_text_type_is_registry_permitted() {
use crate::c2pa_core::{EngineProfile, OperatingMode, SpecVersion};
let text_types = [
"text/plain",
"text/csv",
"text/html",
"text/markdown",
"text/xml",
"text/css",
"text/x-python",
"text/javascript",
"text/yaml",
"application/javascript",
"application/json",
"application/xml",
"application/xhtml+xml",
"application/yaml",
"application/x-yaml",
"application/toml",
];
let regular = EngineProfile::new(SpecVersion::V2_4, OperatingMode::Regular);
for mime in text_types {
assert!(
regular.permits_mime(mime),
"c2pa-text supports {mime} but the v2.4 registry gate rejects it"
);
assert!(
AssetFormat::from_mime(mime).is_some(),
"c2pa-text supports {mime} but no AssetFormat routes it"
);
}
}
#[test]
fn container_aliases_resolve_to_canonical_format() {
let cases: &[(&str, AssetFormat)] = &[
("video/avi", AssetFormat::Riff),
("video/msvideo", AssetFormat::Riff),
("application/x-troff-msvideo", AssetFormat::Riff),
("audio/wave", AssetFormat::Riff),
("audio/vnd.wave", AssetFormat::Riff),
("audio/x-wav", AssetFormat::Riff),
("audio/x-flac", AssetFormat::Flac),
("application/ogg", AssetFormat::Ogg),
("image/dng", AssetFormat::Tiff),
("application/svg+xml", AssetFormat::Svg),
];
for (mime, want) in cases {
assert_eq!(AssetFormat::from_mime(mime), Some(*want), "alias {mime}");
}
}
#[test]
fn raw_camera_is_read_only() {
use crate::c2pa_core::{EngineProfile, OperatingMode, SpecVersion};
let regular = EngineProfile::new(SpecVersion::V2_4, OperatingMode::Regular);
for mime in ["image/x-sony-arw", "image/x-nikon-nef"] {
assert_eq!(
AssetFormat::from_mime(mime),
Some(AssetFormat::Tiff),
"{mime}"
);
assert_eq!(
AssetFormat::from_mime_for_profile(mime, regular),
None,
"{mime} must be read-only (not signable)"
);
}
}
#[test]
fn structured_text_routes_by_comment_syntax() {
let store = dummy_manifest_store();
let body = b"k: v\n";
let cases: &[(&str, &str, &str)] = &[
("text/css", "/* -----BEGIN C2PA MANIFEST-----", "*/"),
(
"application/javascript",
"// -----BEGIN C2PA MANIFEST-----",
"-----END C2PA MANIFEST-----",
),
(
"application/xml",
"<!-- -----BEGIN C2PA MANIFEST-----",
"-->",
),
(
"application/yaml",
"# -----BEGIN C2PA MANIFEST-----",
"-----END C2PA MANIFEST-----",
),
];
for &(mime, open_marker, end_marker) in cases {
let fmt = AssetFormat::from_mime(mime).unwrap();
let signed = embed_manifest(fmt, body, &store).unwrap();
let text = String::from_utf8(signed.clone()).unwrap();
assert!(
text.contains(open_marker),
"{mime}: missing `{open_marker}`"
);
assert!(
text.trim_end().ends_with(end_marker),
"{mime}: bad block tail"
);
assert_eq!(
extract_manifest(fmt, &signed).unwrap(),
Some(store.clone()),
"{mime}: extract"
);
let ex = compute_data_hash_exclusions(fmt, &signed).unwrap();
assert_eq!(ex.len(), 1, "{mime}: exclusion count");
assert!(
ex[0].start >= body.len(),
"{mime}: source must be outside exclusion"
);
}
}
#[test]
fn manifest_store_size_is_bounded_before_direct_copy() {
assert!(matches!(
ensure_manifest_store_size(
AssetFormat::C2paStore,
crate::MAX_MANIFEST_STORE_BYTES + 1
),
Err(FormatError::ManifestTooLarge {
format: AssetFormat::C2paStore,
max: crate::MAX_MANIFEST_STORE_BYTES,
got
}) if got == crate::MAX_MANIFEST_STORE_BYTES + 1
));
}
}
#[cfg(test)]
mod fuzz_robustness;