diff --git a/src/error.rs b/src/error.rs index 919c77f0..21ce6ab5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,6 +14,7 @@ pub enum PngError { ChunkMissing(&'static str), InvalidDepthForType(BitDepth, ColorType), IncorrectDataLength(usize, usize), + C2PAMetadataPreventsChanges, Other(Box), } @@ -41,6 +42,9 @@ impl fmt::Display for PngError { "Data length {} does not match the expected length {}", l1, l2 ), + PngError::C2PAMetadataPreventsChanges => f.write_str( + "The image contains C2PA manifest that would be invalidated by any file changes", + ), PngError::Other(ref s) => f.write_str(s), } } diff --git a/src/headers.rs b/src/headers.rs index d869199a..92c949ac 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -75,6 +75,8 @@ pub struct Chunk { #[derive(Debug, PartialEq, Eq, Clone)] pub enum StripChunks { /// None + /// + /// ...except caBX chunk if it contains a C2PA.org signature. None, /// Remove specific chunks Strip(IndexSet<[u8; 4]>), @@ -111,6 +113,36 @@ pub struct RawChunk<'a> { pub data: &'a [u8], } +impl RawChunk<'_> { + // Is it a chunk for C2PA/CAI JUMBF metadata + pub(crate) fn is_c2pa(&self) -> bool { + if self.name == *b"caBX" { + if let Some((b"jumb", data)) = parse_jumbf_box(self.data) { + if let Some((b"jumd", data)) = parse_jumbf_box(data) { + if data.get(..4) == Some(b"c2pa") { + return true; + } + } + } + } + false + } +} + +fn parse_jumbf_box(data: &[u8]) -> Option<(&[u8], &[u8])> { + if data.len() < 8 { + return None; + } + let (len, rest) = data.split_at(4); + let len = u32::from_be_bytes(len.try_into().unwrap()) as usize; + if len < 8 || len > data.len() { + return None; + } + let (box_name, data) = rest.split_at(4); + let data = data.get(..len - 8)?; + Some((box_name, data)) +} + pub fn parse_next_chunk<'a>( byte_data: &'a [u8], byte_offset: &mut usize, diff --git a/src/main.rs b/src/main.rs index 5d73be32..1b8d4781 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,7 @@ use clap::ArgMatches; mod cli; use indexmap::IndexSet; use log::{error, warn, Level, LevelFilter}; -use oxipng::{Deflaters, InFile, Options, OutFile, RowFilter, StripChunks}; +use oxipng::{Deflaters, InFile, Options, OutFile, PngError, RowFilter, StripChunks}; use rayon::prelude::*; use crate::cli::DISPLAY_CHUNKS; @@ -88,6 +88,10 @@ fn main() -> ExitCode { // PNG files, and return an error for them. // We don't really want to return an error code for those files. Ok(_) => OptimizationResult::Ok, + Err(e @ PngError::C2PAMetadataPreventsChanges) => { + warn!("{input}: {e}"); + OptimizationResult::Skipped + } Err(e) => { error!("{input}: {e}"); OptimizationResult::Failed diff --git a/src/png/mod.rs b/src/png/mod.rs index cd2d605b..08ef264d 100644 --- a/src/png/mod.rs +++ b/src/png/mod.rs @@ -114,6 +114,15 @@ impl PngData { } _ => { if opts.strip.keep(&chunk.name) { + if chunk.is_c2pa() { + // StripChunks::None is the default value, so to keep optimizing by default, + // interpret it as stripping the C2PA metadata. + // The C2PA metadata is invalidated if the file changes, so it shouldn't be kept. + if opts.strip == StripChunks::None { + continue; + } + return Err(PngError::C2PAMetadataPreventsChanges); + } aux_chunks.push(Chunk { name: chunk.name, data: chunk.data.to_owned(), diff --git a/tests/files/c2pa-signed.png b/tests/files/c2pa-signed.png new file mode 100644 index 00000000..b70b781d Binary files /dev/null and b/tests/files/c2pa-signed.png differ diff --git a/tests/lib.rs b/tests/lib.rs index 24b4ad58..f7634f87 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -42,6 +42,19 @@ fn optimize() { assert!(result.is_ok()); } +#[test] +fn skip_c2pa() { + let result = oxipng::optimize( + &"tests/files/c2pa-signed.png".into(), + &OutFile::None, + &Options { + strip: StripChunks::Keep(indexset! {*b"caBX"}), + ..Options::default() + }, + ); + assert!(matches!(result, Err(PngError::C2PAMetadataPreventsChanges))); +} + #[test] fn optimize_corrupted() { let result = oxipng::optimize(