c2pa-rs

A fork of https://github.com/contentauth/c2pa-rs/
git clone git://archive.git.mtrnord.blog/mtrnords-photography-manager/c2pa-rs.git
Log | Files | Refs | README

commit c7eb0b0dbdf695f1a0b7b5341ffe9a9fc42f1b12
parent ad1075c2d1b5dce02ec234487f82326e60f56303
Author: mauricefisher64 <92736594+mauricefisher64@users.noreply.github.com>
Date:   Thu, 22 Sep 2022 19:01:53 -0400

(MINOR) Add support for manifest removal (#123)

* Support for manifest removal.
* Unit test changes
* Address PR comments
Diffstat:
Msdk/src/asset_handlers/bmff_io.rs | 190++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msdk/src/asset_handlers/c2pa_io.rs | 4++++
Msdk/src/asset_handlers/jpeg_io.rs | 60+++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msdk/src/asset_handlers/png_io.rs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msdk/src/asset_io.rs | 3+++
Msdk/src/error.rs | 3+++
Asdk/tests/fixtures/exp-test1.png | 0
7 files changed, 297 insertions(+), 46 deletions(-)

diff --git a/sdk/src/asset_handlers/bmff_io.rs b/sdk/src/asset_handlers/bmff_io.rs @@ -509,7 +509,7 @@ fn adjust_stco_and_co64<W: Write + CAIRead>( for stco_token in stco_list { let stco_box_info = &bmff_tree[*stco_token].data; if stco_box_info.box_type != BoxType::StcoBox { - return Err(Error::BadParam("Bad BMFF".to_string())); + return Err(Error::InvalidAsset("Bad BMFF".to_string())); } // read stco box and patch @@ -517,9 +517,9 @@ fn adjust_stco_and_co64<W: Write + CAIRead>( // read header let header = BoxHeaderLite::read(output) - .map_err(|_err| Error::BadParam("Bad BMFF".to_string()))?; + .map_err(|_err| Error::InvalidAsset("Bad BMFF".to_string()))?; if header.name != BoxType::StcoBox { - return Err(Error::BadParam("Bad BMFF".to_string())); + return Err(Error::InvalidAsset("Bad BMFF".to_string())); } // read extended header @@ -536,12 +536,12 @@ fn adjust_stco_and_co64<W: Write + CAIRead>( let new_offset = if adjust < 0 { offset - u32::try_from(adjust.abs()).map_err(|_| { - Error::BadParam("Bad BMFF offset adjustment".to_string()) + Error::InvalidAsset("Bad BMFF offset adjustment".to_string()) })? } else { offset + u32::try_from(adjust).map_err(|_| { - Error::BadParam("Bad BMFF offset adjustment".to_string()) + Error::InvalidAsset("Bad BMFF offset adjustment".to_string()) })? }; entries.push(new_offset); @@ -560,7 +560,7 @@ fn adjust_stco_and_co64<W: Write + CAIRead>( for co64_token in co64_list { let co64_box_info = &bmff_tree[*co64_token].data; if co64_box_info.box_type != BoxType::Co64Box { - return Err(Error::BadParam("Bad BMFF".to_string())); + return Err(Error::InvalidAsset("Bad BMFF".to_string())); } // read co64 box and patch @@ -568,9 +568,9 @@ fn adjust_stco_and_co64<W: Write + CAIRead>( // read header let header = BoxHeaderLite::read(output) - .map_err(|_err| Error::BadParam("Bad BMFF".to_string()))?; + .map_err(|_err| Error::InvalidAsset("Bad BMFF".to_string()))?; if header.name != BoxType::Co64Box { - return Err(Error::BadParam("Bad BMFF".to_string())); + return Err(Error::InvalidAsset("Bad BMFF".to_string())); } // read extended header @@ -587,12 +587,12 @@ fn adjust_stco_and_co64<W: Write + CAIRead>( let new_offset = if adjust < 0 { offset - u64::try_from(adjust.abs()).map_err(|_| { - Error::BadParam("Bad BMFF offset adjustment".to_string()) + Error::InvalidAsset("Bad BMFF offset adjustment".to_string()) })? } else { offset + u64::try_from(adjust).map_err(|_| { - Error::BadParam("Bad BMFF offset adjustment".to_string()) + Error::InvalidAsset("Bad BMFF offset adjustment".to_string()) })? }; entries.push(new_offset); @@ -625,8 +625,8 @@ pub(crate) fn build_bmff_tree( let mut current = start; while current < end { // Get box header. - let header = - BoxHeaderLite::read(reader).map_err(|_err| Error::BadParam("Bad BMFF".to_string()))?; + let header = BoxHeaderLite::read(reader) + .map_err(|_err| Error::InvalidAsset("Bad BMFF".to_string()))?; // Break if size zero BoxHeader let s = header.size; @@ -710,7 +710,7 @@ pub(crate) fn build_bmff_tree( let new_token = bmff_tree.new_node(b); current_node .append_node(bmff_tree, new_token) - .map_err(|_err| Error::BadParam("Bad BMFF Graph".to_string()))?; + .map_err(|_err| Error::InvalidAsset("Bad BMFF Graph".to_string()))?; let path = path_from_token(bmff_tree, &new_token)?; add_token_to_cache(bmff_path_map, path, new_token); @@ -965,16 +965,16 @@ impl AssetIO for BmffIO { let (start, end) = if let Some(c2pa_length) = c2pa_length { let start = usize::value_from(c2pa_start) - .map_err(|_err| Error::BadParam("value out of range".to_string()))?; // get beginning of chunk which starts 4 bytes before label + .map_err(|_err| Error::InvalidAsset("value out of range".to_string()))?; // get beginning of chunk which starts 4 bytes before label let end = usize::value_from(c2pa_start + c2pa_length) - .map_err(|_err| Error::BadParam("value out of range".to_string()))?; + .map_err(|_err| Error::InvalidAsset("value out of range".to_string()))?; (start, end) } else { // insert new c2pa let end = usize::value_from(c2pa_start) - .map_err(|_err| Error::BadParam("value out of range".to_string()))?; + .map_err(|_err| Error::InvalidAsset("value out of range".to_string()))?; (end, end) }; @@ -992,7 +992,7 @@ impl AssetIO for BmffIO { let offset_adjust: i32 = if end == 0 { new_c2pa_box_size as i32 } else { - // value could be negative is box is truncated + // value could be negative if box is truncated let existing_c2pa_box_size = end - start; let pad_size: i32 = new_c2pa_box_size as i32 - existing_c2pa_box_size as i32; pad_size @@ -1041,7 +1041,7 @@ impl AssetIO for BmffIO { &mut output_bmff_map, )?; - // adjust based on current layyout + // adjust based on current layout adjust_stco_and_co64( &mut temp_file, &output_bmff_tree, @@ -1066,6 +1066,135 @@ impl AssetIO for BmffIO { let vec: Vec<HashObjectPositions> = Vec::new(); Ok(vec) } + + fn remove_cai_store(&self, asset_path: &Path) -> Result<()> { + let mut input = File::open(asset_path)?; + let size = input.seek(SeekFrom::End(0))?; + input.seek(SeekFrom::Start(0))?; + + // create root node + let root_box = BoxInfo { + path: "".to_string(), + offset: 0, + size: size as u64, + box_type: BoxType::Empty, + parent: None, + user_type: None, + version: None, + flags: None, + }; + + let (mut bmff_tree, root_token) = Arena::with_data(root_box); + let mut bmff_map: HashMap<String, Vec<Token>> = HashMap::new(); + + // build layout of the BMFF structure + build_bmff_tree( + &mut input, + size as u64, + &mut bmff_tree, + &root_token, + &mut bmff_map, + )?; + + // get position of c2pa manifest + let (c2pa_start, c2pa_length) = + if let Some(c2pa_token) = get_manifest_token(&bmff_tree, &bmff_map) { + let uuid_info = &bmff_tree[c2pa_token].data; + + (uuid_info.offset, Some(uuid_info.size)) + } else { + return Ok(()); // no box to remove + }; + + let mut temp_file = Builder::new() + .prefix("c2pa_temp") + .rand_bytes(5) + .tempfile()?; + + let (start, end) = if let Some(c2pa_length) = c2pa_length { + let start = usize::value_from(c2pa_start) + .map_err(|_err| Error::InvalidAsset("value out of range".to_string()))?; // get beginning of chunk which starts 4 bytes before label + + let end = usize::value_from(c2pa_start + c2pa_length) + .map_err(|_err| Error::InvalidAsset("value out of range".to_string()))?; + + (start, end) + } else { + return Err(Error::InvalidAsset("value out of range".to_string())); + }; + + // write content before ContentProvenanceBox + input.seek(SeekFrom::Start(0))?; + let mut b = vec![0u8; start]; + input.read_exact(&mut b)?; + temp_file.write_all(&b)?; + + // calc offset adjustments + // value will be negative since the box is truncated + let new_c2pa_box_size: i32 = 0; + let existing_c2pa_box_size = end - start; + let offset_adjust = new_c2pa_box_size - existing_c2pa_box_size as i32; + + // write content after ContentProvenanceBox + input.seek(SeekFrom::Start(end as u64))?; + let mut chunk = vec![0u8; 1024 * 1024]; + loop { + let len = match input.read(&mut chunk) { + Ok(0) => break, + Ok(len) => len, + Err(e) => return Err(Error::IoError(e)), + }; + + temp_file.write_all(&chunk[0..len])?; + } + temp_file.flush()?; + + // Manipulating the UUID box means we may need some patch offsets if they are file absolute offsets. + match self.bmff_format.as_ref() { + "m4a" | "mp4" | "mov" => { + // create root node + let root_box = BoxInfo { + path: "".to_string(), + offset: 0, + size: size as u64, + box_type: BoxType::Empty, + parent: None, + user_type: None, + version: None, + flags: None, + }; + + // rebuild box layout for output file + let (mut output_bmff_tree, root_token) = Arena::with_data(root_box); + let mut output_bmff_map: HashMap<String, Vec<Token>> = HashMap::new(); + + let size = temp_file.seek(SeekFrom::End(0))?; + temp_file.seek(SeekFrom::Start(0))?; + build_bmff_tree( + &mut temp_file, + size as u64, + &mut output_bmff_tree, + &root_token, + &mut output_bmff_map, + )?; + + // adjust based on current layout + adjust_stco_and_co64( + &mut temp_file, + &output_bmff_tree, + &output_bmff_map, + offset_adjust, + )?; + } + _ => (), // todo: handle more patching cases as necessary + } + + // copy temp file to asset + std::fs::rename(&temp_file.path(), asset_path) + // if rename fails, try to copy in case we are on different volumes + .or_else(|_| std::fs::copy(&temp_file.path(), asset_path).and(Ok(()))) + .map_err(Error::IoError) + } } impl AssetPatch for BmffIO { @@ -1120,7 +1249,7 @@ impl AssetPatch for BmffIO { (0, None) } } else { - return Err(Error::BadParam( + return Err(Error::InvalidAsset( "patch_cai_store found no manifest store to patch.".to_string(), )); }; @@ -1136,12 +1265,12 @@ impl AssetPatch for BmffIO { asset.write_all(&new_c2pa_box)?; Ok(()) } else { - Err(Error::BadParam( + Err(Error::InvalidAsset( "patch_cai_store store size mismatch.".to_string(), )) } } else { - Err(Error::BadParam( + Err(Error::InvalidAsset( "patch_cai_store store size mismatch.".to_string(), )) } @@ -1257,4 +1386,23 @@ pub mod tests { } assert!(success) } + + #[test] + fn test_remove_c2pa() { + let source = fixture_path("video1.mp4"); + + let temp_dir = tempdir().unwrap(); + let output = temp_dir_path(&temp_dir, "mp4_test.mp4"); + + std::fs::copy(&source, &output).unwrap(); + let bmff_io = BmffIO::new("mp4"); + + bmff_io.remove_cai_store(&output).unwrap(); + + // read back in asset, JumbfNotFound is expected since it was removed + match bmff_io.read_cai_store(&output) { + Err(Error::JumbfNotFound) => (), + _ => unreachable!(), + } + } } diff --git a/sdk/src/asset_handlers/c2pa_io.rs b/sdk/src/asset_handlers/c2pa_io.rs @@ -60,6 +60,10 @@ impl AssetIO for C2paIO { Ok(vec![hop]) } + + fn remove_cai_store(&self, _asset_path: &Path) -> Result<()> { + Ok(()) + } } #[cfg(test)] diff --git a/sdk/src/asset_handlers/jpeg_io.rs b/sdk/src/asset_handlers/jpeg_io.rs @@ -70,7 +70,7 @@ fn xmp_from_bytes(asset_bytes: &[u8]) -> Option<String> { fn add_required_segs(asset_path: &std::path::Path) -> Result<()> { let buf = read(asset_path)?; let dimg_opt = DynImage::from_bytes(buf.into()) - .map_err(|_err| Error::BadParam("Could not parse input image".to_owned()))?; + .map_err(|_err| Error::InvalidAsset("Could not parse input JPEG".to_owned()))?; if let Some(DynImage::Jpeg(jpeg)) = dimg_opt { // check for JUMBF Seg @@ -83,9 +83,7 @@ fn add_required_segs(asset_path: &std::path::Path) -> Result<()> { aio.save_cai_store(asset_path, &no_bytes)?; } } else { - return Err(Error::BadParam( - "Image type not supported by handler".to_owned(), - )); + return Err(Error::UnsupportedType); } Ok(()) @@ -154,7 +152,7 @@ impl CAILoader for JpegIO { asset_reader.read_to_end(&mut buf).map_err(Error::IoError)?; let dimg_opt = DynImage::from_bytes(buf.into()) - .map_err(|_err| Error::BadParam("Could not parse input image".to_owned()))?; + .map_err(|_err| Error::InvalidAsset("Could not parse input JPEG".to_owned()))?; if let Some(dimg) = dimg_opt { match dimg { @@ -206,12 +204,10 @@ impl CAILoader for JpegIO { } } } - _ => return Err(Error::BadParam("Unknown image format".to_owned())), + _ => return Err(Error::InvalidAsset("Unknown image format".to_owned())), }; } else { - return Err(Error::BadParam( - "Image type not supported by handler".to_owned(), - )); + return Err(Error::UnsupportedType); } if buffer.is_empty() { @@ -300,7 +296,7 @@ impl AssetIO for JpegIO { jpeg.encoder() .write_to(output) - .map_err(|_err| Error::BadParam("JPEG write error".to_owned()))?; + .map_err(|_err| Error::InvalidAsset("JPEG write error".to_owned()))?; Ok(()) } @@ -399,11 +395,34 @@ impl AssetIO for JpegIO { curr_offset += seg.len_with_entropy(); } } - _ => return Err(Error::BadParam("Unknown image format".to_owned())), + _ => return Err(Error::InvalidAsset("Unknown image format".to_owned())), } Ok(positions) } + + fn remove_cai_store(&self, asset_path: &std::path::Path) -> Result<()> { + let input = read(asset_path).map_err(wrap_io_err)?; + + let mut jpeg = Jpeg::from_bytes(input.into()).map_err(|_err| Error::EmbeddingError)?; + + // remove existing CAI segments + delete_cai_segments(&mut jpeg)?; + + // save updated file + let output = std::fs::OpenOptions::new() + .read(true) + .write(true) + .truncate(true) + .open(asset_path) + .map_err(Error::IoError)?; + + jpeg.encoder() + .write_to(output) + .map_err(|_err| Error::InvalidAsset("JPEG write error".to_owned()))?; + + Ok(()) + } } #[cfg(test)] @@ -431,4 +450,23 @@ pub mod tests { let result = extract_xmp(&seg); assert_eq!(result, None); } + + #[test] + fn test_remove_c2pa() { + let source = crate::utils::test::fixture_path("CA.jpg"); + + let temp_dir = tempfile::tempdir().unwrap(); + let output = crate::utils::test::temp_dir_path(&temp_dir, "CA_test.jpg"); + + std::fs::copy(&source, &output).unwrap(); + let jpeg_io = JpegIO {}; + + jpeg_io.remove_cai_store(&output).unwrap(); + + // read back in asset, JumbfNotFound is expected since it was removed + match jpeg_io.read_cai_store(&output) { + Err(Error::JumbfNotFound) => (), + _ => unreachable!(), + } + } } diff --git a/sdk/src/asset_handlers/png_io.rs b/sdk/src/asset_handlers/png_io.rs @@ -59,9 +59,9 @@ fn get_png_chunk_positions(f: &mut dyn CAIRead) -> Result<Vec<PngChunkPos>> { // check PNG signature f.read_exact(&mut hdr) - .map_err(|_err| Error::BadParam("PNG invalid".to_string()))?; + .map_err(|_err| Error::InvalidAsset("PNG invalid".to_string()))?; if hdr != PNG_ID { - return Err(Error::BadParam("PNG invalid".to_string())); + return Err(Error::InvalidAsset("PNG invalid".to_string())); } loop { @@ -70,23 +70,23 @@ fn get_png_chunk_positions(f: &mut dyn CAIRead) -> Result<Vec<PngChunkPos>> { // read the chunk length let length = f .read_u32::<BigEndian>() - .map_err(|_err| Error::BadParam("PNG out of range".to_string()))?; + .map_err(|_err| Error::InvalidAsset("PNG out of range".to_string()))?; // read the chunk type f.read_exact(&mut buf4) - .map_err(|_err| Error::BadParam("PNG out of range".to_string()))?; + .map_err(|_err| Error::InvalidAsset("PNG out of range".to_string()))?; let name = buf4; // seek past data f.seek(SeekFrom::Current(length as i64)) - .map_err(|_err| Error::BadParam("PNG out of range".to_string()))?; + .map_err(|_err| Error::InvalidAsset("PNG out of range".to_string()))?; // read crc f.read_exact(&mut buf4) - .map_err(|_err| Error::BadParam("PNG out of range".to_string()))?; + .map_err(|_err| Error::InvalidAsset("PNG out of range".to_string()))?; let chunk_name = String::from_utf8(name.to_vec()) - .map_err(|_err| Error::BadParam("PNG bad chunk name".to_string()))?; + .map_err(|_err| Error::InvalidAsset("PNG bad chunk name".to_string()))?; let pcp = PngChunkPos { start: current_pos, @@ -131,7 +131,7 @@ fn get_cai_data(f: &mut dyn CAIRead) -> Result<Vec<u8>> { let mut data: Vec<u8> = vec![0; length]; f.read_exact(&mut data[..]) - .map_err(|_err| Error::BadParam("PNG out of range".to_string()))?; + .map_err(|_err| Error::InvalidAsset("PNG out of range".to_string()))?; Ok(data) } @@ -303,7 +303,7 @@ impl AssetIO for PngIO { /* splice in new chunk. Each PNG chunk has the following format: chunk data length (4 bytes big endian) chunk identifier (4 byte character sequence) - chunk data (0 - n bytes of chunck data) + chunk data (0 - n bytes of chunk data) chunk crc (4 bytes in crc in format defined in PNG spec) */ @@ -313,10 +313,10 @@ impl AssetIO for PngIO { if let Some(existing_cai) = iter.find(|pcp| pcp.name == CAI_CHUNK) { // replace existing CAI let start = usize::value_from(existing_cai.start) - .map_err(|_err| Error::BadParam("value out of range".to_string()))?; // get beginning of chunk which starts 4 bytes before label + .map_err(|_err| Error::InvalidAsset("value out of range".to_string()))?; // get beginning of chunk which starts 4 bytes before label let end = usize::value_from(existing_cai.end()) - .map_err(|_err| Error::BadParam("value out of range".to_string()))?; + .map_err(|_err| Error::InvalidAsset("value out of range".to_string()))?; png_buf.splice(start..end, empty_buf.iter().cloned()); } @@ -330,7 +330,7 @@ impl AssetIO for PngIO { // add new cai data after image header chunk if let Some(img_hdr) = iter.find(|pcp| pcp.name == IMG_HDR) { let end = usize::value_from(img_hdr.end()) - .map_err(|_err| Error::BadParam("value out of range".to_string()))?; + .map_err(|_err| Error::InvalidAsset("value out of range".to_string()))?; png_buf.splice(end..end, cai_data.iter().cloned()); } else { @@ -338,8 +338,7 @@ impl AssetIO for PngIO { } // save png data - std::fs::write(asset_path, png_buf) - .map_err(|_err| Error::BadParam("PNG write error".to_owned()))?; + std::fs::write(asset_path, png_buf)?; Ok(()) } @@ -384,6 +383,43 @@ impl AssetIO for PngIO { Ok(positions) } + + fn remove_cai_store(&self, asset_path: &Path) -> Result<()> { + // get png byte + let mut png_buf = std::fs::read(asset_path).map_err(|_err| Error::EmbeddingError)?; + + let mut cursor = Cursor::new(png_buf); + let ps = get_png_chunk_positions(&mut cursor)?; + + // get back buffer + png_buf = cursor.into_inner(); + + /* splice in new chunk. Each PNG chunk has the following format: + chunk data length (4 bytes big endian) + chunk identifier (4 byte character sequence) + chunk data (0 - n bytes of chunk data) + chunk crc (4 bytes in crc in format defined in PNG spec) + */ + + // erase existing + let empty_buf = Vec::new(); + let mut iter = ps.into_iter(); + if let Some(existing_cai) = iter.find(|pcp| pcp.name == CAI_CHUNK) { + // replace existing CAI + let start = usize::value_from(existing_cai.start) + .map_err(|_err| Error::InvalidAsset("value out of range".to_string()))?; // get beginning of chunk which starts 4 bytes before label + + let end = usize::value_from(existing_cai.end()) + .map_err(|_err| Error::InvalidAsset("value out of range".to_string()))?; + + png_buf.splice(start..end, empty_buf.iter().cloned()); + } + + // save png data + std::fs::write(asset_path, png_buf)?; + + Ok(()) + } } #[cfg(test)] @@ -433,4 +469,23 @@ pub mod tests { } } } + + #[test] + fn test_remove_c2pa() { + let source = crate::utils::test::fixture_path("exp-test1.png"); + + let temp_dir = tempfile::tempdir().unwrap(); + let output = crate::utils::test::temp_dir_path(&temp_dir, "exp-test1_tmp.png"); + + std::fs::copy(&source, &output).unwrap(); + let png_io = PngIO {}; + + png_io.remove_cai_store(&output).unwrap(); + + // read back in asset, JumbfNotFound is expected since it was removed + match png_io.read_cai_store(&output) { + Err(Error::JumbfNotFound) => (), + _ => unreachable!(), + } + } } diff --git a/sdk/src/asset_io.rs b/sdk/src/asset_io.rs @@ -70,6 +70,9 @@ pub trait AssetIO { fn asset_patch_ref(&self) -> Option<&dyn AssetPatch> { None } + + // Remove entire CAI block from asset + fn remove_cai_store(&self, asset_path: &Path) -> Result<()>; } // `AssetPatch` optimizes output generation for asset_io handlers that diff --git a/sdk/src/error.rs b/sdk/src/error.rs @@ -216,6 +216,9 @@ pub enum Error { #[error(transparent)] InvalidClaim(#[from] crate::store::InvalidClaimError), + #[error("asset could not be parsed: {0}")] + InvalidAsset(String), + #[error(transparent)] JumbfParseError(#[from] crate::jumbf::boxes::JumbfParseError), diff --git a/sdk/tests/fixtures/exp-test1.png b/sdk/tests/fixtures/exp-test1.png Binary files differ.