make_test_images.rs (24791B)
1 // Copyright 2022 Adobe. All rights reserved. 2 // This file is licensed to you under the Apache License, 3 // Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) 4 // or the MIT license (http://opensource.org/licenses/MIT), 5 // at your option. 6 7 // Unless required by applicable law or agreed to in writing, 8 // this software is distributed on an "AS IS" BASIS, WITHOUT 9 // WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or 10 // implied. See the LICENSE-MIT and LICENSE-APACHE files for the 11 // specific language governing permissions and limitations under 12 // each license. 13 14 //! Constructs a set of test images using a configuration script 15 use std::{ 16 collections::HashMap, 17 fs, 18 io::{Cursor, Seek}, 19 path::{Path, PathBuf}, 20 }; 21 22 use anyhow::{Context, Result}; 23 use c2pa::{ 24 create_signer, 25 jumbf_io::{get_supported_types, load_jumbf_from_stream, save_jumbf_to_stream}, 26 Builder, Error, Reader, Signer, SigningAlg, 27 }; 28 use memchr::memmem; 29 use nom::AsBytes; 30 use serde::Deserialize; 31 use serde_json::json; 32 33 use crate::{compare_manifests::compare_folders, make_thumbnail::make_thumbnail_from_stream}; 34 35 const IMAGE_WIDTH: u32 = 2048; 36 const IMAGE_HEIGHT: u32 = 1365; 37 38 /// Defines an operation for creating a test image 39 #[derive(Debug, Deserialize)] 40 #[serde(deny_unknown_fields)] 41 pub struct Recipe { 42 /// The operation to perform: 43 /// 44 /// One of: "copy", "make", "ogp", "dat", "sig", "uri", "clm", "prv" 45 pub op: String, 46 /// Path or filename of parent 47 /// 48 /// Assumes output folder if no path 49 /// Will add default extension if non specified 50 pub parent: Option<String>, 51 /// A list of Ingredient paths 52 /// 53 /// Assumes output folder if no path 54 /// Will add default extension if non specified 55 pub ingredients: Option<Vec<String>>, 56 /// The folder to write files to, will create if it does not exist 57 pub output: String, 58 } 59 60 /// Configuration 61 #[derive(Debug, Deserialize)] 62 #[serde(default, deny_unknown_fields)] 63 pub struct Config { 64 /// The signing algorithm to use 65 pub alg: String, 66 /// A url to a time stamp authority if desired 67 pub tsa_url: Option<String>, 68 /// The output folder for the generated files 69 pub output_path: String, 70 /// Extension to add to filenames if none was given 71 pub default_ext: String, 72 /// A name for a Creative Work Author assertion 73 pub author: Option<String>, 74 /// A list of recipes for test files 75 pub recipes: Vec<Recipe>, 76 /// A folder to compare the output to 77 pub compare_folders: Option<[String; 2]>, 78 } 79 80 impl Config { 81 pub fn get_signer(&self) -> c2pa::Result<Box<dyn Signer>> { 82 // sign and embed into the target file 83 let alg: SigningAlg = self.alg.parse().map_err(|_| c2pa::Error::UnsupportedType)?; 84 let tsa_url = self.tsa_url.as_ref().map(|s| s.to_owned()); 85 let mut signcert_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 86 signcert_path.push(format!("../sdk/tests/fixtures/certs/{}.pub", self.alg)); 87 let mut pkey_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 88 pkey_path.push(format!("../sdk/tests/fixtures/certs/{alg}.pem")); 89 create_signer::from_files(signcert_path, pkey_path, alg, tsa_url) 90 } 91 } 92 93 // Defaults for Config 94 impl Default for Config { 95 fn default() -> Self { 96 Self { 97 alg: "ps256".to_owned(), 98 tsa_url: None, 99 output_path: "target/images".to_owned(), 100 default_ext: "jpg".to_owned(), 101 author: None, 102 recipes: Vec::new(), 103 compare_folders: None, 104 } 105 } 106 } 107 108 /// Converts a file extension to a MIME type 109 fn extension_to_mime(extension: &str) -> Option<&'static str> { 110 Some(match extension.to_lowercase().as_str() { 111 "jpg" | "jpeg" => "image/jpeg", 112 "png" => "image/png", 113 "gif" => "image/gif", 114 "psd" => "image/vnd.adobe.photoshop", 115 "tiff" | "tif" => "image/tiff", 116 "svg" => "image/svg+xml", 117 "ico" => "image/x-icon", 118 "bmp" => "image/bmp", 119 "webp" => "image/webp", 120 "dng" => "image/dng", 121 "heic" => "image/heic", 122 "heif" => "image/heif", 123 "mp2" | "mpa" | "mpe" | "mpeg" | "mpg" | "mpv2" => "video/mpeg", 124 "mp4" => "video/mp4", 125 "avif" => "image/avif", 126 "mov" | "qt" => "video/quicktime", 127 "m4a" => "audio/mp4", 128 "mid" | "rmi" => "audio/mid", 129 "mp3" => "audio/mpeg", 130 "wav" => "audio/vnd.wav", 131 "aif" | "aifc" | "aiff" => "audio/aiff", 132 "ogg" => "audio/ogg", 133 "pdf" => "application/pdf", 134 "ai" => "application/postscript", 135 _ => return None, 136 }) 137 } 138 139 fn extension(path: &Path) -> Option<&str> { 140 path.extension().and_then(std::ffi::OsStr::to_str) 141 } 142 143 fn file_name(path: &Path) -> Option<&str> { 144 path.file_name().and_then(std::ffi::OsStr::to_str) 145 } 146 147 /// Tool for building test case images for C2PA 148 pub struct MakeTestImages { 149 config: Config, 150 output_dir: PathBuf, 151 } 152 153 impl MakeTestImages { 154 pub fn new(config: Config) -> Self { 155 let output = config.output_path.to_owned(); 156 Self { 157 config, 158 output_dir: PathBuf::from(output), 159 } 160 } 161 162 /// Makes a full path from a filename or path 163 /// 164 /// If there is no parent, prepend the output path 165 /// If there is no extension, use the default 166 fn make_path(&self, s: &str) -> PathBuf { 167 let mut path_buf = PathBuf::from(s); 168 // parent() tends to return an empty string instead of None 169 let has_path = match path_buf.parent() { 170 Some(p) => p.to_string_lossy().len() > 0, 171 None => false, 172 }; 173 // if we just have a filename, then assume it is in the output folder 174 if !has_path { 175 path_buf = PathBuf::from(&self.output_dir); 176 path_buf.push(s); 177 } 178 // add the default extension is none is supplied 179 if path_buf.extension().is_none() { 180 path_buf.set_extension(&self.config.default_ext); 181 } 182 path_buf 183 } 184 185 /// Patches new content into a file 186 /// 187 /// # Parameters 188 /// path - path to file to be patched 189 /// search_bytes - bytes to be replaced 190 /// replace_bytes - replacement bytes 191 fn patch_file(path: &std::path::Path, search_bytes: &[u8], replace_bytes: &[u8]) -> Result<()> { 192 let mut buf = fs::read(path)?; 193 194 if let Some(splice_start) = memmem::find(&buf, search_bytes) { 195 buf.splice( 196 splice_start..splice_start + search_bytes.len(), 197 replace_bytes.iter().cloned(), 198 ); 199 } else { 200 return Err(Error::NotFound.into()); 201 } 202 203 fs::write(path, &buf)?; 204 205 Ok(()) 206 } 207 208 fn add_ingredient_from_file( 209 builder: &mut Builder, 210 path: &Path, 211 relationship: &str, 212 ) -> Result<String> { 213 let mut source = fs::File::open(path).context("opening ingredient")?; 214 let name = path 215 .file_name() 216 .ok_or(Error::BadParam("no filename".to_string()))? 217 .to_string_lossy(); 218 let extension = path 219 .extension() 220 .ok_or(Error::BadParam("no extension".to_owned()))? 221 .to_string_lossy() 222 .into_owned(); 223 let format = extension_to_mime(&extension).unwrap_or("image/jpeg"); 224 225 let json = json!({ 226 "title": name, 227 "relationship": relationship, 228 }) 229 .to_string(); 230 231 let ingredient = builder.add_ingredient(&json, format, &mut source)?; 232 if ingredient.thumbnail_ref().is_none() { 233 source.rewind()?; 234 let (format, thumbnail) = 235 make_thumbnail_from_stream(format, &mut source).context("making thumbnail")?; 236 ingredient.set_thumbnail(format, thumbnail)?; 237 } 238 239 Ok( 240 builder.definition.ingredients[builder.definition.ingredients.len() - 1] 241 .instance_id() 242 .to_string(), 243 ) 244 } 245 246 fn make_image(&self, recipe: &Recipe) -> Result<PathBuf> { 247 let src = recipe.parent.as_deref(); 248 let dst = recipe.output.as_str(); 249 let dst_path = self.make_path(dst); 250 println!("Creating {dst_path:?}"); 251 252 let software_agent = format!("{} {}", "Make Test Images", env!("CARGO_PKG_VERSION")); 253 // let software_agent = json!({ 254 // "name": "Make Test Images", 255 // "version": env!("CARGO_PKG_VERSION") 256 // }); 257 let name = file_name(&dst_path).ok_or(Error::BadParam("no filename".to_string()))?; 258 let extension = extension(&dst_path).unwrap_or("jpg"); 259 260 let format = extension_to_mime(extension).unwrap_or("image/jpeg"); 261 262 let manifest_def = json!({ 263 "vendor": "contentauth", 264 "title": name, 265 "format": &format, 266 "claim_generator_info": [ 267 { 268 "name": env!("CARGO_PKG_NAME"), 269 "version": env!("CARGO_PKG_VERSION") 270 } 271 ] 272 }) 273 .to_string(); 274 275 let mut builder = Builder::from_json(&manifest_def)?; 276 277 // keep track of ingredient instances so we don't duplicate them 278 let mut ingredient_table = HashMap::new(); 279 280 let mut actions = Vec::new(); 281 if let Some(author) = &self.config.author { 282 builder.add_assertion( 283 "stds.schema-org.CreativeWork", 284 &json!({ 285 "@context": "http://schema.org/", 286 "@type": "CreativeWork", 287 "author": [ 288 { 289 "@type": "Person", 290 "name": author 291 } 292 ] 293 }), 294 )?; 295 }; 296 297 // process parent first 298 let mut img = match src { 299 Some(src) => { 300 let src_path = &self.make_path(src); 301 302 let instance_id = 303 Self::add_ingredient_from_file(&mut builder, src_path, "parentOf")?; 304 305 actions.push(json!( 306 { 307 "action": "c2pa.opened", 308 "instanceId": &instance_id, 309 } 310 )); 311 312 // keep track of all ingredients we add via the instance Id 313 ingredient_table.insert(src, instance_id.to_owned()); 314 315 // load the image for editing 316 let mut img = 317 image::open(src_path).context(format!("opening parent {src_path:?}"))?; 318 319 // adjust brightness to show we made an edit 320 img = img.brighten(30); 321 actions.push(json!( 322 { 323 "action": "c2pa.color_adjustments", 324 "parameters": { 325 "name": "brightnesscontrast" 326 } 327 } 328 )); 329 img 330 } 331 None => { 332 // create a default image with a gradient 333 let mut img = image::DynamicImage::new_rgb8(IMAGE_WIDTH, IMAGE_HEIGHT); 334 if let Some(img_ref) = img.as_mut_rgb8() { 335 // fill image with a gradient 336 for (x, y, pixel) in img_ref.enumerate_pixels_mut() { 337 let r = (0.3 * x as f32) as u8; 338 let b = (0.3 * y as f32) as u8; 339 *pixel = image::Rgb([r, 100, b]); 340 } 341 } 342 actions.push(json!( 343 { 344 "action": "c2pa.created", 345 "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia", 346 "softwareAgent": software_agent, 347 "parameters": { 348 "name": "gradient" 349 } 350 } 351 )); 352 img 353 } 354 }; 355 356 // then add all ingredients 357 if let Some(ing_vec) = &recipe.ingredients { 358 // scale ingredients to paste in top row of the image 359 let width = match ing_vec.len() as u32 { 360 0 | 1 => img.width() / 2, 361 _ => img.width() / ing_vec.len() as u32, 362 }; 363 let height = img.height() / 2; 364 365 let mut x = 0; 366 for ing in ing_vec { 367 let ing_path = &self.make_path(ing); 368 369 // get the bits of the ingredient, resize it and overlay it on the base image 370 let img_ingredient = 371 image::open(ing_path).context(format!("opening ingredient {ing_path:?}"))?; 372 let img_small = img_ingredient.thumbnail(width, height); 373 image::imageops::overlay(&mut img, &img_small, x, 0); 374 375 // if we have already created an ingredient, get the instanceId, otherwise create a new one 376 let instance_id = match ingredient_table.get(ing.as_str()) { 377 Some(id) => id.to_string(), 378 None => { 379 let instance_id = 380 Self::add_ingredient_from_file(&mut builder, ing_path, "componentOf")?; 381 ingredient_table.insert(ing, instance_id.clone()); 382 instance_id 383 } 384 }; 385 actions.push(json!( 386 { 387 "action": "c2pa.placed", 388 "instanceId": instance_id, 389 } 390 )); 391 x += width as i64; 392 } 393 // record what we did as an action (only need to record this once) 394 actions.push(json!( 395 { 396 "action": "c2pa.resized", 397 } 398 )); 399 } 400 401 let mut temp = tempfile::tempfile()?; 402 403 use image::ImageFormat; 404 let image_format = ImageFormat::from_extension(extension) 405 .ok_or(Error::BadParam("extension not supported".to_owned()))?; 406 // save the changes to the image as our target file 407 img.write_to(&mut temp, image_format)?; 408 temp.rewind()?; 409 410 // add all our actions as an assertion now. 411 builder.add_assertion( 412 "c2pa.actions", 413 &json!( 414 { 415 "actions": actions 416 } 417 ), 418 )?; 419 420 // generate a thumbnail and set it in the image 421 // make sure do do this last,on the generated image so that it reflects the output 422 let (thumb_format, image) = 423 make_thumbnail_from_stream(format, &mut temp).context("making thumbnail")?; 424 builder.set_thumbnail(&thumb_format, &mut Cursor::new(image))?; 425 426 temp.rewind()?; 427 428 // now sign manifest and embed in target 429 let signer = self.config.get_signer()?; 430 431 let mut dest = fs::File::create(&dst_path)?; 432 builder 433 .sign(signer.as_ref(), format, &mut temp, &mut dest) 434 .context("signing")?; 435 436 Ok(dst_path) 437 } 438 439 fn manifest_def(title: &str, format: &str) -> String { 440 json!({ 441 "title": title, 442 "format": format, 443 "claim_generator_info": [ 444 { 445 "name": "Make Test Images", 446 "version": env!("CARGO_PKG_VERSION") 447 } 448 ], 449 "assertions": [ 450 { 451 "label": "c2pa.actions", 452 "data": { 453 "actions": [ 454 { 455 "action": "c2pa.edited", 456 "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", 457 "softwareAgent": { 458 "name": "My AI Tool", 459 "version": "0.1.0" 460 } 461 } 462 ] 463 } 464 } 465 ] 466 }).to_string() 467 } 468 469 fn sign_image(&self, recipe: &Recipe) -> Result<PathBuf> { 470 let src = recipe.parent.as_deref(); 471 let dst = recipe.output.as_str(); 472 let dst_path = self.make_path(dst); 473 println!("Signing {dst_path:?}"); 474 475 let src = match src { 476 Some(src) => src, 477 None => return Err(Error::BadParam("no parent".to_string()).into()), 478 }; 479 480 let name = file_name(&dst_path).ok_or(Error::BadParam("no filename".to_string()))?; 481 let extension = extension(&dst_path).unwrap_or("jpg"); 482 483 let format = extension_to_mime(extension).unwrap_or("image/jpeg"); 484 485 let json = Self::manifest_def(name, format); 486 487 let src_path = &self.make_path(src); 488 let mut source = fs::File::open(src_path).context("opening ingredient")?; 489 490 let mut builder = Builder::from_json(&json)?; 491 492 let parent_name = file_name(&dst_path).ok_or(Error::BadParam("no filename".to_string()))?; 493 builder.add_ingredient( 494 json!({ 495 "title": parent_name, 496 "relationship": "parentOf" 497 }) 498 .to_string(), 499 extension, 500 &mut source, 501 )?; 502 503 let mut dest = fs::File::create(&dst_path)?; 504 let signer = self.config.get_signer()?; 505 builder 506 .sign(signer.as_ref(), format, &mut source, &mut dest) 507 .context("signing")?; 508 509 Ok(dst_path) 510 } 511 512 /// makes an off the golden path image from an existing image with a claim 513 fn make_ogp(&self, recipe: &Recipe) -> Result<PathBuf> { 514 let src = recipe.parent.as_deref().unwrap_or_default(); 515 let src_path = &self.make_path(src); 516 let dst_path = self.make_path(recipe.output.as_str()); 517 println!("Creating {dst_path:?}"); 518 let format = src_path 519 .extension() 520 .ok_or(Error::BadParam("no extension".to_owned()))? 521 .to_string_lossy() 522 .into_owned(); 523 524 let mut source = std::fs::File::open(src_path).context("opening OGP source")?; 525 let jumbf = load_jumbf_from_stream(&format, &mut source) 526 .context("loading OGP") 527 .context(format!("loading OGP {src_path:?}"))?; 528 // save the edited image to our destination file 529 let mut img = 530 image::open(Path::new(src_path)).context(format!("loading OGP image{src_path:?}"))?; 531 img = img.grayscale(); 532 img.save(&dst_path) 533 .context(format!("saving OGP image{:?}", &dst_path))?; 534 let image = std::fs::read(&dst_path).context("reading OGP image")?; 535 let mut dest = std::fs::File::create(&dst_path).context("creating OGP image")?; 536 // write the original claim data to the edited image 537 save_jumbf_to_stream(&format, &mut Cursor::new(image), &mut dest, &jumbf) 538 .context(format!("OGP save_jumbf_to_file {:?}", &dst_path))?; 539 // The image library does not preserve any metadata so we have to write it ourselves. 540 // todo: should preserve all metadata and update instanceId. 541 Ok(dst_path) 542 } 543 544 /// Generates various error conditions 545 fn make_err(&self, recipe: &Recipe) -> Result<PathBuf> { 546 let op = recipe.op.as_str(); 547 let src = recipe.parent.as_deref().unwrap_or_default(); 548 let dst_path = self.make_path(recipe.output.as_str()); 549 println!("Creating {dst_path:?}"); 550 551 let (search_bytes, replace_bytes) = match op { 552 // modify the XMP (change xmp magic id value) - this should cause a data hash mismatch (OTGP) 553 "dat" => ( 554 b"W5M0MpCehiHzreSzNTczkc9d".as_bytes(), 555 b"W5M0MpCehiHzreSzdeadbeef".as_bytes(), 556 ), 557 // modify the claim_generator value inside the claim, the claim hash will no longer match the signature 558 "sig" => ( 559 b"make_test_images".as_bytes(), 560 b"make_test_xxxxxx".as_bytes(), 561 ), 562 // modify a value inside an actions assertion, the assertion hash will fail 563 "uri" => ( 564 b"brightnesscontrast".as_bytes(), 565 b"brightnessdeadbeef".as_bytes(), 566 ), 567 // modify a uri to a manifest so the manifest cannot be found (missing manifest) 568 "clm" => ( 569 b"c2pa_manifest\xA3\x63url\x78\x4aself#jumbf=/c2pa/contentauth".as_bytes(), 570 b"c2pa_manifest\xA3\x63url\x78\x4aself#jumbf=/c2pa/contentbeef".as_bytes(), 571 ), 572 // modify the provenance uri so that is references a non-existing manifest 573 "prv" => ( 574 b"dcterms:provenance=\"self#jumbf=/c2pa/contentauth".as_bytes(), 575 b"dcterms:provenance=\"self#jumbf=/c2pa/contentbeef".as_bytes(), 576 ), 577 _ => panic!("bad parameter"), 578 }; 579 580 std::fs::copy(self.make_path(src), &dst_path).context("copying for make_err")?; 581 582 Self::patch_file(&dst_path, search_bytes, replace_bytes) 583 .context(format!("patching {op}"))?; 584 585 Ok(dst_path) 586 } 587 588 /// copies a file from the parent to the output 589 fn make_copy(&self, recipe: &Recipe) -> Result<PathBuf> { 590 let src = recipe.parent.as_deref().unwrap_or_default(); 591 let dst = recipe.output.as_str(); 592 let src_path = &self.make_path(src); 593 let dst_path = self.make_path(dst); 594 println!("Copying {dst_path:?}"); 595 let src = recipe.parent.as_deref().unwrap_or_default(); 596 let dst = recipe.output.as_str(); 597 if extension(&PathBuf::from(src)) != extension(&PathBuf::from(dst)) { 598 let img = image::open(src_path).context(format!("copying {src} to {dst}"))?; 599 img.save(&dst_path) 600 .context(format!("copying {src} to {dst}"))?; 601 } else { 602 std::fs::copy(src, &dst_path).context(format!("copying {src} to {dst}"))?; 603 } 604 Ok(dst_path) 605 } 606 607 /// Runs a list of recipes 608 pub fn run(&self) -> Result<()> { 609 let supported = get_supported_types(); 610 println!("Supported types: {:#?}", supported); 611 if !self.output_dir.exists() { 612 std::fs::create_dir_all(&self.output_dir).context("Can't create output folder")?; 613 }; 614 let json_dir = self.output_dir.join("json"); 615 if !json_dir.exists() { 616 std::fs::create_dir_all(&json_dir)?; 617 } 618 619 let recipes = &self.config.recipes; 620 for recipe in recipes { 621 let dst_path = match recipe.op.as_str() { 622 "make" => self.make_image(recipe)?, 623 "sign" => self.sign_image(recipe)?, 624 "ogp" => self.make_ogp(recipe)?, 625 "dat" | "sig" | "uri" | "clm" | "prv" => self.make_err(recipe)?, 626 "copy" => self.make_copy(recipe)?, 627 _ => return Err(Error::BadParam(recipe.op.to_string()).into()), 628 }; 629 630 if recipe.op.as_str() != "copy" { 631 let mut file = std::fs::File::open(&dst_path)?; 632 let format = dst_path 633 .extension() 634 .and_then(|s| s.to_str()) 635 .unwrap_or("jpg"); 636 let reader = Reader::from_stream(format, &mut file)?; 637 let json = reader.json(); 638 639 let json_path = json_dir 640 .join(dst_path.file_name().unwrap()) 641 .with_extension("json"); 642 std::fs::write(&json_path, json)?; 643 } 644 } 645 //println!("Comparing to {:#?}", self.config.compare_folder); 646 if let Some(folders) = &self.config.compare_folders { 647 compare_folders(&folders[0], &folders[1])?; 648 } 649 Ok(()) 650 } 651 } 652 653 #[cfg(test)] 654 pub mod tests { 655 #![allow(clippy::expect_used)] 656 657 use super::*; 658 const TESTS: &str = r#"{ 659 "alg": "ps256", 660 "tsa_url": "http://timestamp.digicert.com", 661 "output_path": "../target/tmp", 662 "default_ext": "jpg", 663 "author": "Gavin Peacock", 664 "recipes": [ 665 { "op": "copy", "parent": "../sdk/tests/fixtures/IMG_0003.jpg", "output": "A.jpg" }, 666 { "op": "make", "output": "C" }, 667 { "op": "ogp", "parent": "C", "output": "XC" }, 668 { "op": "sig", "parent": "C", "output": "E-sig-C" } 669 ] 670 }"#; 671 672 #[test] 673 fn test_make_images() { 674 let config: Config = serde_json::from_str(TESTS) 675 .context("Config file format") 676 .expect("serde_json"); 677 MakeTestImages::new(config).run().expect("running"); 678 } 679 }