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 29bb633d12d6a100f478216a1c279474e171535d
parent e64fe9607d9834e24a5f1c14b6a2965d40f3db6e
Author: mauricefisher64 <92736594+mauricefisher64@users.noreply.github.com>
Date:   Thu, 21 Jul 2022 12:18:48 -0400

Optimize performance of large assets (#84)

* Optimize file writing

* test load without verify

* more perf test

* Test hardware accelerated Sha2

* Test interleaved hashing

* Remove print statement

* chunked Blake3

* Add Blake3 support to hash_utils

* Fix build issue

* Remove unneeded const value

* optimize cleanup

* Remove asm until Windows builds are fixed.

* fix bad toml

* Document feature to disable interleaved IO

* ThreadSendError is not used

* Tweak wording

* Tweak wording

* Fix bad wording

* adjust logic on when to let save_to_asset perform the copy

Co-authored-by: mauricefisher64 <mfisher@Octopus>
Co-authored-by: Eric Scouten <scouten@adobe.com>
Diffstat:
MREADME.md | 1+
Msdk/Cargo.toml | 2++
Msdk/src/asset_handlers/bmff_io.rs | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msdk/src/asset_io.rs | 18+++++++++++++++++-
Msdk/src/error.rs | 3+++
Msdk/src/ingredient.rs | 8++------
Msdk/src/jumbf_io.rs | 18++++++++++++++----
Msdk/src/manifest.rs | 10+++++++++-
Msdk/src/utils/hash_utils.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
9 files changed, 297 insertions(+), 30 deletions(-)

diff --git a/README.md b/README.md @@ -58,6 +58,7 @@ The Rust SDK crate provides: * `file_io` enables manifest generation, signing via OpenSSL, and embedding manifests in various file formats. * `serialize_thumbnails` includes binary thumbnail data in the [Serde](https://serde.rs/) serialization output. * `xmp_write` enables updating XMP on embed with the `dcterms:provenance` field. (Requires [xmp_toolkit](https://crates.io/crates/xmp_toolkit).) +* `no_interleaved_io` the SDK uses threaded I/O for some operations to improve performance. Using this feature will force fully synchronous I/O. ## License diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml @@ -24,6 +24,7 @@ bmff = [] # Work in progress support for BMFF-based containers file_io = ["openssl"] serialize_thumbnails = [] xmp_write = ["xmp_toolkit"] +no_interleaved_io = ["file_io"] # The diagnostics feature is unsupported and might be removed. # It enables some low-overhead timing features used in our development cycle. @@ -50,6 +51,7 @@ async-trait = { version = "0.1.48", optional = true } atree = "0.5.2" base64 = "0.13.0" bcder = "0.6.0" +blake3 = "1.0.0" bytes = "1.1.0" byteorder = "1.3.4" chrono = { version = "0.4.19", features = ["wasmbind"] } diff --git a/sdk/src/asset_handlers/bmff_io.rs b/sdk/src/asset_handlers/bmff_io.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use std::convert::{From, TryFrom}; -use std::fs::File; +use std::fs::{File, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::Path; @@ -26,7 +26,7 @@ use atree::{Arena, Token}; use tempfile::{Builder, NamedTempFile}; use crate::assertions::ExclusionsMap; -use crate::asset_io::{AssetIO, CAILoader, CAIRead, HashObjectPositions}; +use crate::asset_io::{AssetIO, AssetPatch, CAILoader, CAIRead, HashObjectPositions}; use crate::error::{Error, Result}; use crate::utils::hash_utils::{vec_compare, Exclusion}; @@ -904,6 +904,10 @@ impl CAILoader for BmffIO { } impl AssetIO for BmffIO { + fn asset_patch_ref(&self) -> Option<&dyn AssetPatch> { + Some(self) + } + fn read_cai_store(&self, asset_path: &Path) -> Result<Vec<u8>> { let mut f = File::open(asset_path)?; self.read_cai(&mut f) @@ -1060,9 +1064,11 @@ impl AssetIO for BmffIO { _ => (), // todo: handle more patching cases as necessary } - std::fs::copy(temp_file.path(), asset_path)?; - - Ok(()) + // 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) } fn get_object_locations( @@ -1074,9 +1080,93 @@ impl AssetIO for BmffIO { } } +impl AssetPatch for BmffIO { + fn patch_cai_store(&self, asset_path: &std::path::Path, store_bytes: &[u8]) -> Result<()> { + let mut asset = OpenOptions::new() + .write(true) + .read(true) + .create(false) + .open(asset_path)?; + let size = asset.seek(SeekFrom::End(0))?; + asset.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 asset, + size as u64, + &mut bmff_tree, + &root_token, + &mut bmff_map, + )?; + + // get position to insert c2pa + let (c2pa_start, c2pa_length) = if let Some(uuid_tokens) = bmff_map.get("/uuid") { + let uuid_info = &bmff_tree[uuid_tokens[0]].data; + + // is this a C2PA manifest + let is_c2pa = if let Some(uuid) = &uuid_info.user_type { + // make sure it is a C2PA box + vec_compare(&C2PA_UUID, uuid) + } else { + false + }; + + if is_c2pa { + (uuid_info.offset, Some(uuid_info.size)) + } else { + (0, None) + } + } else { + return Err(Error::BadParam( + "patch_cai_store found no manifest store to patch.".to_string(), + )); + }; + + if let Some(manifest_length) = c2pa_length { + let mut new_c2pa_box: Vec<u8> = Vec::with_capacity(store_bytes.len() * 2); + let merkle_data: &[u8] = &[]; // not yet supported + write_c2pa_box(&mut new_c2pa_box, store_bytes, true, merkle_data)?; + let new_c2pa_box_size = new_c2pa_box.len(); + + if new_c2pa_box_size as u64 == manifest_length { + asset.seek(SeekFrom::Start(c2pa_start as u64))?; + asset.write_all(&new_c2pa_box)?; + Ok(()) + } else { + Err(Error::BadParam( + "patch_cai_store store size mismatch.".to_string(), + )) + } + } else { + Err(Error::BadParam( + "patch_cai_store store size mismatch.".to_string(), + )) + } + } +} + #[cfg(feature = "bmff")] #[cfg(test)] pub mod tests { + #![allow(clippy::expect_used)] + #![allow(clippy::panic)] + #![allow(clippy::unwrap_used)] + use tempfile::tempdir; use super::*; @@ -1150,4 +1240,33 @@ pub mod tests { } assert!(success) } + + #[test] + fn test_patch_c2pa_write_mp4() { + let test_data = "some test data".as_bytes(); + let source = fixture_path("video1.mp4"); + + let mut success = false; + if let Ok(temp_dir) = tempdir() { + let output = temp_dir_path(&temp_dir, "mp4_test.mp4"); + + if let Ok(_size) = std::fs::copy(&source, &output) { + let bmff = BmffIO::new("mp4"); + + if let Ok(source_data) = bmff.read_cai_store(&output) { + // create replacement data of same size + let mut new_data = vec![0u8; source_data.len()]; + new_data[..test_data.len()].copy_from_slice(test_data); + bmff.patch_cai_store(&output, &new_data).unwrap(); + + let replaced = bmff.read_cai_store(&output).unwrap(); + + assert_eq!(new_data, replaced); + + success = true; + } + } + } + assert!(success) + } } diff --git a/sdk/src/asset_io.rs b/sdk/src/asset_io.rs @@ -56,9 +56,25 @@ pub trait AssetIO { // Write the CAI block to an asset fn save_cai_store(&self, asset_path: &Path, store_bytes: &[u8]) -> Result<()>; - /// List of standard object offests + /// List of standard object offsets /// If the offsets exist return the start of those locations other it should /// return the calculated location of when it should start. There may still be a /// length if the format contains extra header information for example. fn get_object_locations(&self, asset_path: &Path) -> Result<Vec<HashObjectPositions>>; + + // Returns [`AssetPatch`] trait if this I/O handler supports patching. + fn asset_patch_ref(&self) -> Option<&dyn AssetPatch> { + None + } +} + +// `AssetPatch` optimizes output generation for asset_io handlers that +// are able to patch blocks of data without changing any other data. The +// resultant file must still be a valid asset. This saves having to rewrite +// assets since only the patched bytes are modified. +pub trait AssetPatch { + // Patches an existing manifest store with new manifest store. + // Only existing manifest stores of the same size may be patched + // since any other changes will invalidate asset hashes. + fn patch_cai_store(&self, asset_path: &Path, store_bytes: &[u8]) -> Result<()>; } diff --git a/sdk/src/error.rs b/sdk/src/error.rs @@ -155,6 +155,9 @@ pub enum Error { #[error("could not create valid JUMBF for claim")] JumbfCreationError, + #[error("thread receive error")] + ThreadReceiveError, + /// No JUMBF data found. /// TODO before merging PR: Does this error case need to be part of the public API? #[error("no JUMBF data found")] diff --git a/sdk/src/ingredient.rs b/sdk/src/ingredient.rs @@ -382,7 +382,6 @@ impl Ingredient { ) -> Result<Self> { Self::from_file_impl(path.as_ref(), options) } - // Internal implementation to avoid code bloat. #[cfg(feature = "file_io")] fn from_file_impl(path: &Path, options: &dyn IngredientOptions) -> Result<Self> { @@ -407,8 +406,6 @@ impl Ingredient { if let Some(opt_title) = options.title(path) { ingredient.title = opt_title; } - // read the file into a buffer for processing - let buf = std::fs::read(path).map_err(wrap_io_err)?; // optionally generate a hash so we know if the file has changed ingredient.hash = options.hash(path); @@ -417,7 +414,7 @@ impl Ingredient { // generate a store from the buffer and then validate from the asset path // load and verify store in single call - no need to call low level jumbf_io functions - match Store::load_from_memory(&ingredient.format, &buf, true, &mut report) { + match Store::load_from_asset(path, true, &mut report) { Ok(store) => { // generate ValidationStatus from ValidationItems filtering for only errors let statuses = status_for_store(&store, &mut report); @@ -436,8 +433,7 @@ impl Ingredient { } ingredient.active_manifest = Some(claim.label().to_string()); } - ingredient.manifest_data = - jumbf_io::load_jumbf_from_memory(&ingredient.format, &buf).ok(); + ingredient.manifest_data = jumbf_io::load_jumbf_from_file(path).ok(); ingredient.validation_status = if statuses.is_empty() { None } else { diff --git a/sdk/src/jumbf_io.rs b/sdk/src/jumbf_io.rs @@ -133,7 +133,7 @@ pub fn save_jumbf_to_file(data: &[u8], in_path: &Path, out_path: Option<&Path>) let ext = get_file_extension(in_path).ok_or(Error::UnsupportedType)?; // if no output path make a new file based off of source file name - let img_out_path: PathBuf = match out_path { + let asset_out_path: PathBuf = match out_path { Some(p) => p.to_owned(), None => { let filename_osstr = in_path.file_stem().ok_or(Error::UnsupportedType)?; @@ -145,12 +145,22 @@ pub fn save_jumbf_to_file(data: &[u8], in_path: &Path, out_path: Option<&Path>) }; // clone output to be overwritten - if in_path != img_out_path { - fs::copy(&in_path, &img_out_path).map_err(Error::IoError)?; + if in_path != asset_out_path { + fs::copy(&in_path, &asset_out_path).map_err(Error::IoError)?; } match get_assetio_handler(&ext) { - Some(asset_handler) => asset_handler.save_cai_store(&img_out_path, data), + Some(asset_handler) => { + // patch if possible to save time and resources + if let Some(patch_handler) = asset_handler.asset_patch_ref() { + if patch_handler.patch_cai_store(&asset_out_path, data).is_ok() { + return Ok(()); + } + } + + // couldn't patch so just save + asset_handler.save_cai_store(&asset_out_path, data) + } _ => Err(Error::UnsupportedType), } } diff --git a/sdk/src/manifest.rs b/sdk/src/manifest.rs @@ -636,15 +636,23 @@ impl Manifest { return Err(Error::FileNotFound(path)); } // we need to copy the source to target before setting the asset info + let mut copied = false; if !dest_path.as_ref().exists() { std::fs::copy(&source_path, &dest_path)?; + copied = true; } // first add the information about the target file self.set_asset_from_path(dest_path.as_ref()); // convert the manifest to a store let mut store = self.to_store()?; // sign and write our store to to the output image file - store.save_to_asset(source_path.as_ref(), signer, dest_path.as_ref())?; + if copied { + // set source and dest to same path to avoid an unnecessary copy since we already copied above + store.save_to_asset(dest_path.as_ref(), signer, dest_path.as_ref())?; + } else { + // save to asset will do the copy + store.save_to_asset(source_path.as_ref(), signer, dest_path.as_ref())?; + } // todo: update xmp Ok(store) diff --git a/sdk/src/utils/hash_utils.rs b/sdk/src/utils/hash_utils.rs @@ -85,6 +85,7 @@ pub fn hash_by_type(hash_type: u8, data: &[u8]) -> Option<Multihash> { } } +#[derive(Clone)] enum Hasher { SHA256(Sha256), SHA384(Sha384), @@ -232,25 +233,71 @@ pub fn hash_asset_by_alg( } }; - // hash the data for ranges - for r in ranges.into_smallvec() { - let start = r.start(); - let end = r.end(); - let mut chunk_left = end - start + 1; + if cfg!(feature = "no_interleaved_io") { + // hash the data for ranges + for r in ranges.into_smallvec() { + let start = r.start(); + let end = r.end(); + let mut chunk_left = end - start + 1; - // move to start of range - data.seek(SeekFrom::Start(*start))?; + // move to start of range + data.seek(SeekFrom::Start(*start))?; - loop { - let mut chunk = vec![0u8; std::cmp::min(chunk_left as usize, MAX_HASH_BUF)]; + loop { + let mut chunk = vec![0u8; std::cmp::min(chunk_left as usize, MAX_HASH_BUF)]; + + data.read_exact(&mut chunk)?; + + hasher_enum.update(&chunk); + chunk_left -= chunk.len() as u64; + if chunk_left == 0 { + break; + } + } + } + } else { + // hash the data for ranges + for r in ranges.into_smallvec() { + let start = r.start(); + let end = r.end(); + let mut chunk_left = end - start + 1; + + // move to start of range + data.seek(SeekFrom::Start(*start))?; + + let mut chunk = vec![0u8; std::cmp::min(chunk_left as usize, MAX_HASH_BUF)]; data.read_exact(&mut chunk)?; - hasher_enum.update(&chunk); + loop { + let (tx, rx) = std::sync::mpsc::channel(); - chunk_left -= chunk.len() as u64; - if chunk_left == 0 { - break; + chunk_left -= chunk.len() as u64; + + std::thread::spawn(move || { + hasher_enum.update(&chunk); + tx.send(hasher_enum).unwrap_or_default(); + }); + + // are we done + if chunk_left == 0 { + hasher_enum = match rx.recv() { + Ok(hasher) => hasher, + Err(_) => return Err(Error::ThreadReceiveError), + }; + break; + } + + // read next chunk while we wait for hash + let mut next_chunk = vec![0u8; std::cmp::min(chunk_left as usize, MAX_HASH_BUF)]; + data.read_exact(&mut next_chunk)?; + + hasher_enum = match rx.recv() { + Ok(hasher) => hasher, + Err(_) => return Err(Error::ThreadReceiveError), + }; + + chunk = next_chunk; } } } @@ -321,6 +368,71 @@ pub fn verify_hash(hash: &str, data: &[u8]) -> bool { } } +// Fast implementation for Blake3 hashing that can handle large assets +pub fn blake3_from_asset(path: &Path) -> Result<String> { + let mut data = File::open(path)?; + data.seek(SeekFrom::Start(0))?; + let data_len = data.seek(SeekFrom::End(0))?; + data.seek(SeekFrom::Start(0))?; + + let mut hasher = blake3::Hasher::new(); + + let mut chunk_left = data_len; + + if cfg!(feature = "no_interleaved_io") { + loop { + let mut chunk = vec![0u8; std::cmp::min(chunk_left as usize, MAX_HASH_BUF)]; + + data.read_exact(&mut chunk)?; + + hasher.update(&chunk); + + chunk_left -= chunk.len() as u64; + if chunk_left == 0 { + break; + } + } + } else { + let mut chunk = vec![0u8; std::cmp::min(chunk_left as usize, MAX_HASH_BUF)]; + data.read_exact(&mut chunk)?; + + loop { + let (tx, rx) = std::sync::mpsc::channel(); + + chunk_left -= chunk.len() as u64; + + std::thread::spawn(move || { + hasher.update(&chunk); + tx.send(hasher).unwrap_or_default(); + }); + + // are we done + if chunk_left == 0 { + hasher = match rx.recv() { + Ok(hasher) => hasher, + Err(_) => return Err(Error::ThreadReceiveError), + }; + break; + } + + // read next chunk while we wait for hash + let mut next_chunk = vec![0u8; std::cmp::min(chunk_left as usize, MAX_HASH_BUF)]; + data.read_exact(&mut next_chunk)?; + + hasher = match rx.recv() { + Ok(hasher) => hasher, + Err(_) => return Err(Error::ThreadReceiveError), + }; + + chunk = next_chunk; + } + } + + let hash = hasher.finalize(); + + Ok(hash.to_hex().as_str().to_owned()) +} + /// Return the hash of data in the same hash format in_hash pub fn hash_as_source(in_hash: &str, data: &[u8]) -> Option<String> { match decode(in_hash) {