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

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 }