From 24164467b0e1ea6122c5288748a25eb236f08ffe Mon Sep 17 00:00:00 2001 From: nick Date: Wed, 17 Jul 2024 10:27:43 -0400 Subject: [PATCH 01/72] Rewind mp3 streams when reading/writing (#509) --- sdk/src/asset_handlers/mp3_io.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sdk/src/asset_handlers/mp3_io.rs b/sdk/src/asset_handlers/mp3_io.rs index 850c7cede..80b88e551 100644 --- a/sdk/src/asset_handlers/mp3_io.rs +++ b/sdk/src/asset_handlers/mp3_io.rs @@ -126,6 +126,8 @@ pub struct Mp3IO { impl CAIReader for Mp3IO { fn read_cai(&self, input_stream: &mut dyn CAIRead) -> Result> { + input_stream.rewind()?; + let mut manifest: Option> = None; if let Ok(tag) = Tag::read_from(input_stream) { @@ -145,6 +147,8 @@ impl CAIReader for Mp3IO { } fn read_xmp(&self, input_stream: &mut dyn CAIRead) -> Option { + input_stream.rewind().ok()?; + if let Ok(tag) = Tag::read_from(input_stream) { for frame in tag.frames() { if let Content::Private(private) = frame.content() { @@ -181,6 +185,8 @@ impl RemoteRefEmbed for Mp3IO { ) -> Result<()> { match embed_ref { RemoteRefEmbedType::Xmp(url) => { + source_stream.rewind()?; + let header = ID3V2Header::read_header(source_stream)?; source_stream.rewind()?; @@ -337,6 +343,8 @@ impl CAIWriter for Mp3IO { output_stream: &mut dyn CAIReadWrite, store_bytes: &[u8], ) -> Result<()> { + input_stream.rewind()?; + let header = ID3V2Header::read_header(input_stream)?; input_stream.rewind()?; From ebf02f7de0940890044c815494b83f44f62f9220 Mon Sep 17 00:00:00 2001 From: nick Date: Wed, 17 Jul 2024 11:37:33 -0400 Subject: [PATCH 02/72] Set data box placeholder len to at least 1 for GIF (#510) * Set data box placeholder len to at least 1 for GIF * Fix unit tests * Add placeholder offset for box hash --- sdk/src/asset_handlers/gif_io.rs | 36 +++++++++++++++++--------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/sdk/src/asset_handlers/gif_io.rs b/sdk/src/asset_handlers/gif_io.rs index 34960040a..330d47d66 100644 --- a/sdk/src/asset_handlers/gif_io.rs +++ b/sdk/src/asset_handlers/gif_io.rs @@ -125,12 +125,11 @@ impl CAIWriter for GifIO { }, HashObjectPositions { offset: end_preamble_pos, - // Size doesn't matter for placeholder block. - length: 0, + length: 1, // Need at least size 1. htype: HashBlockObjectType::Cai, }, HashObjectPositions { - offset: end_preamble_pos, + offset: end_preamble_pos + 1, length: usize::try_from(input_stream.seek(SeekFrom::End(0))?)? - end_preamble_pos, htype: HashBlockObjectType::Other, @@ -232,16 +231,16 @@ impl AssetBoxHash for GifIO { Blocks::new(input_stream)? .try_fold( - (Vec::new(), None), - |(mut box_maps, last_marker), + (Vec::new(), None, 0), + |(mut box_maps, last_marker, mut offset), marker| - -> Result<(Vec<_>, Option>)> { + -> Result<(Vec<_>, Option>, usize)> { let marker = marker?; // If the C2PA block doesn't exist, we need to insert a placeholder after the global color table // if it exists, or otherwise after the logical screen descriptor. if !c2pa_block_exists { - if let Some(last_marker) = last_marker { + if let Some(last_marker) = last_marker.as_ref() { let should_insert_placeholder = match last_marker.block { Block::GlobalColorTable(_) => true, // If the current block is a global color table, then wait til the next iteration to insert. @@ -253,13 +252,14 @@ impl AssetBoxHash for GifIO { _ => false, }; if should_insert_placeholder { + offset += 1; box_maps.push( BlockMarker { block: Block::ApplicationExtension( ApplicationExtension::new_c2pa(&[])?, ), start: marker.start, - // Size doesn't matter for placeholder block. + // TODO: should this size be >1? len: 0, } .to_box_map()?, @@ -282,13 +282,15 @@ impl AssetBoxHash for GifIO { } } _ => { - box_maps.push(marker.to_box_map()?); + let mut box_map = marker.to_box_map()?; + box_map.range_start += offset; + box_maps.push(box_map); } } - Ok((box_maps, Some(marker))) + Ok((box_maps, Some(marker), offset)) }, ) - .map(|(box_maps, _)| box_maps) + .map(|(box_maps, _, _)| box_maps) } } @@ -1341,15 +1343,15 @@ mod tests { obj_locations.get(1), Some(&HashObjectPositions { offset: 781, - length: 0, + length: 1, htype: HashBlockObjectType::Cai, }) ); assert_eq!( obj_locations.get(2), Some(&HashObjectPositions { - offset: 781, - length: 739692, + offset: 782, + length: SAMPLE1.len() - 781, htype: HashBlockObjectType::Other, }) ); @@ -1381,7 +1383,7 @@ mod tests { obj_locations.get(2), Some(&HashObjectPositions { offset: 801, - length: 739692, + length: SAMPLE1.len() - 781, htype: HashBlockObjectType::Other, }) ); @@ -1415,7 +1417,7 @@ mod tests { alg: None, hash: ByteBuf::from(Vec::new()), pad: ByteBuf::from(Vec::new()), - range_start: 368494, + range_start: 368495, range_len: 778 }) ); @@ -1426,7 +1428,7 @@ mod tests { alg: None, hash: ByteBuf::from(Vec::new()), pad: ByteBuf::from(Vec::new()), - range_start: 740472, + range_start: SAMPLE1.len(), range_len: 1 }) ); From e3876699abf334d67713292f9cc007c99b91f5b3 Mon Sep 17 00:00:00 2001 From: nick Date: Wed, 17 Jul 2024 12:05:03 -0400 Subject: [PATCH 03/72] Fix box hash placeholder len (set to 1) (#511) --- sdk/src/asset_handlers/gif_io.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sdk/src/asset_handlers/gif_io.rs b/sdk/src/asset_handlers/gif_io.rs index 330d47d66..419c15899 100644 --- a/sdk/src/asset_handlers/gif_io.rs +++ b/sdk/src/asset_handlers/gif_io.rs @@ -259,8 +259,7 @@ impl AssetBoxHash for GifIO { ApplicationExtension::new_c2pa(&[])?, ), start: marker.start, - // TODO: should this size be >1? - len: 0, + len: 1, } .to_box_map()?, ); From 18b3c7565cbe8ed6c8838355795230385b74b019 Mon Sep 17 00:00:00 2001 From: mauricefisher64 <92736594+mauricefisher64@users.noreply.github.com> Date: Wed, 17 Jul 2024 12:27:21 -0400 Subject: [PATCH 04/72] Make data_types field optional when serializing data-box-map (#512) Make data_types field is optional when serializing data-box-map --- sdk/src/assertions/metadata.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/src/assertions/metadata.rs b/sdk/src/assertions/metadata.rs index a626c0631..2c43fb82b 100644 --- a/sdk/src/assertions/metadata.rs +++ b/sdk/src/assertions/metadata.rs @@ -290,6 +290,7 @@ pub struct DataBox { pub format: String, #[serde(with = "serde_bytes")] pub data: Vec, + #[serde(skip_serializing_if = "Option::is_none")] pub data_types: Option>, } From aec91294c4eadeee8621dced44530024f9132a90 Mon Sep 17 00:00:00 2001 From: Jack Farzan Date: Wed, 17 Jul 2024 14:04:13 -0400 Subject: [PATCH 05/72] draft security md (#508) * draft security md * Update SECURITY.md updated words * Finalized copy --------- Co-authored-by: Jack Farzan --- SECURITY.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..8f38f5d40 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security + +This C2PA open-source library is maintained in partnership with Adobe. At this time, Adobe is taking point on accepting security reports through its HackerOne portal and public bug bounty program. + +## Reporting a vulnerability + +Please do not create a public GitHub issue for any suspected security vulnerabilities. Instead, please file an issue through [Adobe's HackerOne page](https://hackerone.com/adobe?type=team). If for some reason this is not possible, reach out to cai-security@adobe.com. + + From 5d336f78d57f12677cf235fa33f0b43392aabe2a Mon Sep 17 00:00:00 2001 From: Gavin Peacock Date: Thu, 18 Jul 2024 05:31:27 -0700 Subject: [PATCH 06/72] Ensure Ingredient data_types make it to the store and back. (#514) --- sdk/src/ingredient.rs | 2 ++ sdk/src/manifest.rs | 42 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/sdk/src/ingredient.rs b/sdk/src/ingredient.rs index 90f2e1c45..41d0f148d 100644 --- a/sdk/src/ingredient.rs +++ b/sdk/src/ingredient.rs @@ -1082,6 +1082,7 @@ impl Ingredient { ingredient.metadata = ingredient_assertion.metadata; ingredient.description = ingredient_assertion.description; ingredient.informational_uri = ingredient_assertion.informational_uri; + ingredient.data_types = ingredient_assertion.data_types; Ok(ingredient) } @@ -1265,6 +1266,7 @@ impl Ingredient { ingredient_assertion .informational_uri .clone_from(&self.informational_uri); + ingredient_assertion.data_types.clone_from(&self.data_types); claim.add_assertion(&ingredient_assertion) } diff --git a/sdk/src/manifest.rs b/sdk/src/manifest.rs index 1a7d049de..57f0792ec 100644 --- a/sdk/src/manifest.rs +++ b/sdk/src/manifest.rs @@ -2288,14 +2288,24 @@ pub(crate) mod tests { "format": "text/plain", "relationship": "inputTo", "data": { - "format": "text/plain", - "identifier": "prompt.txt", - "data_types": [ + "format": "text/plain", + "identifier": "prompt.txt", + "data_types": [ + { + "type": "c2pa.types.generator.prompt" + } + ] + } + }, + { + "title": "Custom AI Model", + "format": "application/octet-stream", + "relationship": "inputTo", + "data_types": [ { - "type": "c2pa.types.generator.prompt" + "type": "c2pa.types.model" } - ] - } + ] } ] }"#; @@ -2347,7 +2357,8 @@ pub(crate) mod tests { let (format, image) = m.thumbnail().unwrap(); assert_eq!(format, "image/jpeg"); assert_eq!(image.to_vec(), b"my value"); - assert_eq!(m.ingredients().len(), 2); + assert_eq!(m.ingredients().len(), 3); + // Validate a prompt ingredient (with data field) assert_eq!(m.ingredients()[1].relationship(), &Relationship::InputTo); assert!(m.ingredients()[1].data_ref().is_some()); assert_eq!(m.ingredients()[1].data_ref().unwrap().format, "text/plain"); @@ -2356,6 +2367,14 @@ pub(crate) mod tests { m.ingredients()[1].resources().get(id).unwrap().into_owned(), b"pirate with bird on shoulder" ); + // Validate a custom AI model ingredient. + assert_eq!(m.ingredients()[2].title(), "Custom AI Model"); + assert_eq!(m.ingredients()[2].relationship(), &Relationship::InputTo); + assert_eq!( + m.ingredients()[2].data_types().unwrap()[0].asset_type, + "c2pa.types.model" + ); + // println!("{manifest_store}"); } @@ -2400,7 +2419,7 @@ pub(crate) mod tests { let (format, image) = m.thumbnail().unwrap(); assert_eq!(format, "image/jpeg"); assert_eq!(image.to_vec(), b"my value"); - assert_eq!(m.ingredients().len(), 2); + assert_eq!(m.ingredients().len(), 3); assert_eq!(m.ingredients()[1].relationship(), &Relationship::InputTo); assert!(m.ingredients()[1].data_ref().is_some()); assert_eq!(m.ingredients()[1].data_ref().unwrap().format, "text/plain"); @@ -2409,6 +2428,13 @@ pub(crate) mod tests { m.ingredients()[1].resources().get(id).unwrap().into_owned(), b"pirate with bird on shoulder" ); + // Validate a custom AI model ingredient. + assert_eq!(m.ingredients()[2].title(), "Custom AI Model"); + assert_eq!(m.ingredients()[2].relationship(), &Relationship::InputTo); + assert_eq!( + m.ingredients()[2].data_types().unwrap()[0].asset_type, + "c2pa.types.model" + ); // println!("{manifest_store}"); } From 1cd586773492bb5489e0ce00ad1246b3e8d1034b Mon Sep 17 00:00:00 2001 From: mauricefisher64 Date: Thu, 18 Jul 2024 15:06:53 +0000 Subject: [PATCH 07/72] Prepare 0.32.7 release --- CHANGELOG.md | 16 ++++++++++++++++ README.md | 2 +- export_schema/Cargo.toml | 2 +- make_test_images/Cargo.toml | 2 +- sdk/Cargo.toml | 2 +- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54258de9c..950127d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ This project adheres to [Semantic Versioning](https://semver.org), except that Do not manually edit this file. It will be automatically updated when a new release is published. +## 0.32.7 +_18 July 2024_ + +* Ensure Ingredient data_types make it to the store and back. ([#514](https://github.com/contentauth/c2pa-rs/pull/514)) +* draft security md ([#508](https://github.com/contentauth/c2pa-rs/pull/508)) +* Make data_types field optional when serializing data-box-map ([#512](https://github.com/contentauth/c2pa-rs/pull/512)) +* Fix box hash placeholder len (set to 1) ([#511](https://github.com/contentauth/c2pa-rs/pull/511)) +* Set data box placeholder len to at least 1 for GIF ([#510](https://github.com/contentauth/c2pa-rs/pull/510)) +* Rewind mp3 streams when reading/writing ([#509](https://github.com/contentauth/c2pa-rs/pull/509)) +* Update README.md ([#351](https://github.com/contentauth/c2pa-rs/pull/351)) +* Add GIF support ([#489](https://github.com/contentauth/c2pa-rs/pull/489)) +* Update image requirement from 0.24.7 to 0.25.1 in /make_test_images ([#445](https://github.com/contentauth/c2pa-rs/pull/445)) +* Upgrade uuid to 1.7.0 & fix removed wasm-bindgen feature ([#450](https://github.com/contentauth/c2pa-rs/pull/450)) +* Expose `SignatureInfo` publicly ([#501](https://github.com/contentauth/c2pa-rs/pull/501)) +* Cleanup empty/unused files + lints ([#500](https://github.com/contentauth/c2pa-rs/pull/500)) + ## 0.32.6 _15 July 2024_ diff --git a/README.md b/README.md index 116e63e82..8062b5435 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -c2pa = "0.32.6" +c2pa = "0.32.7" ``` If you want to read or write a manifest file, add the `file_io` dependency to your `Cargo.toml`. diff --git a/export_schema/Cargo.toml b/export_schema/Cargo.toml index e8947552b..08a7ef1f9 100644 --- a/export_schema/Cargo.toml +++ b/export_schema/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "export_schema" -version = "0.32.6" +version = "0.32.7" authors = ["Dave Kozma "] license = "MIT OR Apache-2.0" edition = "2018" diff --git a/make_test_images/Cargo.toml b/make_test_images/Cargo.toml index 741265794..c1d5aa8a4 100644 --- a/make_test_images/Cargo.toml +++ b/make_test_images/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "make_test_images" -version = "0.32.6" +version = "0.32.7" authors = ["Gavin Peacock "] license = "MIT OR Apache-2.0" edition = "2021" diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index 924be3c03..afc1d2b66 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "c2pa" -version = "0.32.6" +version = "0.32.7" description = "Rust SDK for C2PA (Coalition for Content Provenance and Authenticity) implementors" authors = [ "Maurice Fisher ", From 414a8724d528b07a99e1903c460713d51899f762 Mon Sep 17 00:00:00 2001 From: Jack Farzan Date: Thu, 18 Jul 2024 11:35:48 -0400 Subject: [PATCH 08/72] added final details (#517) * added final details * rm typo --------- Co-authored-by: Jack Farzan --- SECURITY.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index 8f38f5d40..08811c0fe 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,3 +7,16 @@ This C2PA open-source library is maintained in partnership with Adobe. At this t Please do not create a public GitHub issue for any suspected security vulnerabilities. Instead, please file an issue through [Adobe's HackerOne page](https://hackerone.com/adobe?type=team). If for some reason this is not possible, reach out to cai-security@adobe.com. +## Vulnerability SLAs + +Once we receive an actionable vulnerability (meaning there is an available patch, or a code fix is required), we will acknowledge the vulnerability within 24 hours. Our target SLAs for resolution are: + +1. 72 hours for vulnerabilities with a CVSS score of 9.0-10.0 +2. 2 weeks for vulnerabilities with a CVSS score of 7.0-8.9 + +Any vulnerability with a score below 6.9 will be resolved when possible. + + +## C2PA Vulnerabilities + +This library is not meant to address any potential vulnerabilities within the C2PA specification itself. It is only an implementation of the spec as written. Any suspected vulnerabilities within the spec can be reported [here](https://github.com/c2pa-org/specifications/issues). From 1783124d898b93391f3109300199711d018d9cf1 Mon Sep 17 00:00:00 2001 From: mauricefisher64 <92736594+mauricefisher64@users.noreply.github.com> Date: Thu, 18 Jul 2024 14:10:41 -0400 Subject: [PATCH 09/72] Make sure reading past end of JUMBF box is an error (#518) * Make sure reading past end of JUMBF box is an error * Reenable unit test * Formatting fix --- sdk/src/jumbf/boxes.rs | 62 ++++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/sdk/src/jumbf/boxes.rs b/sdk/src/jumbf/boxes.rs index 09dd72a8a..855e99481 100644 --- a/sdk/src/jumbf/boxes.rs +++ b/sdk/src/jumbf/boxes.rs @@ -2274,11 +2274,11 @@ impl BoxReader { sbox.add_data_box(next_box); } - // if our current position is past the size, bail out... - if let Ok(p) = current_pos(reader) { - if p >= dest_pos { - found = false; - } + // Error out if box reads past specified box len + match current_pos(reader).map_err(|_| JumbfParseError::InvalidBoxRange)? { + p if p == dest_pos => found = false, + p if p > dest_pos => return Err(JumbfParseError::InvalidJumbBox), + _ => continue, } } @@ -2700,31 +2700,30 @@ pub mod tests { } // ANCHOR: DescriptionBox Reader - /* - #[test] - fn desc_box_reader() { - const JUMD_DESC: &str = - "000000256A756D62000000216A756D646332706100110010800000AA00389B7103633270612E763100"; - let buffer = hex::decode(JUMD_DESC).expect("decode failed"); - let mut buf_reader = Cursor::new(buffer); - - let jumb_header = BoxReader::read_header(&mut buf_reader).unwrap(); - assert_eq!(jumb_header.size, 0x25); - assert_eq!(jumb_header.name, BoxType::JumbBox); - - let jumd_header = BoxReader::read_header(&mut buf_reader).unwrap(); - assert_eq!(jumd_header.size, 0x21); - assert_eq!(jumd_header.name, BoxType::JumdBox); - - let desc_box = BoxReader::read_desc_box(&mut buf_reader, jumd_header.size).unwrap(); - assert_eq!(desc_box.label(), labels::MANIFEST_STORE); - assert_eq!(desc_box.uuid(), "6332706100110010800000AA00389B71"); - } - */ + #[test] + fn desc_box_reader() { + const JUMD_DESC: &str = + "000000226A756D620000001E6A756D646332706100110010800000AA00389B71036332706100"; + let buffer = hex::decode(JUMD_DESC).expect("decode failed"); + let mut buf_reader = Cursor::new(buffer); + + let jumb_header = BoxReader::read_header(&mut buf_reader).unwrap(); + assert_eq!(jumb_header.size, 0x22); + assert_eq!(jumb_header.name, BoxType::Jumb); + + let jumd_header = BoxReader::read_header(&mut buf_reader).unwrap(); + assert_eq!(jumd_header.size, 0x1e); + assert_eq!(jumd_header.name, BoxType::Jumd); + + let desc_box = BoxReader::read_desc_box(&mut buf_reader, jumd_header.size).unwrap(); + assert_eq!(desc_box.label(), labels::MANIFEST_STORE); + assert_eq!(desc_box.uuid(), "6332706100110010800000AA00389B71"); + } + // ANCHOR: JSON Content Box Reader #[test] fn json_box_reader() { - const JSON_BOX: &str ="0000005a6a756d620000002d6a756d646a736f6e00110010800000aa00389b7103633270612e6c6f636174696f6e2e62726f616400000000266a736f6e7b20226c6f636174696f6e223a202253616e204672616e636973636f227d"; + const JSON_BOX: &str ="0000005b6a756d620000002d6a756d646a736f6e00110010800000aa00389b7103633270612e6c6f636174696f6e2e62726f616400000000266a736f6e7b20226c6f636174696f6e223a202253616e204672616e636973636f227d"; let buffer = hex::decode(JSON_BOX).expect("decode failed"); let mut buf_reader = Cursor::new(buffer); @@ -2740,6 +2739,15 @@ pub mod tests { assert_eq!(json_box.json().len(), 30); } + #[test] + fn test_bad_box_size() { + const JSON_BOX: &str ="0000005a6a756d620000002d6a756d646a736f6e00110010800000aa00389b7103633270612e6c6f636174696f6e2e62726f616400000000266a736f6e7b20226c6f636174696f6e223a202253616e204672616e636973636f227d"; + + let buffer = hex::decode(JSON_BOX).expect("decode failed"); + let mut buf_reader = Cursor::new(buffer); + assert!(BoxReader::read_super_box(&mut buf_reader).is_err()); + } + #[allow(dead_code)] fn check_one_box( parent_box: &JUMBFSuperBox, From 9afd2e33250ec787b4db7bc823442fafda0885ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jul 2024 16:48:19 -0700 Subject: [PATCH 10/72] Update range-set requirement from 0.0.9 to 0.0.11 in /sdk (#442) --- updated-dependencies: - dependency-name: range-set dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gavin Peacock --- sdk/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index afc1d2b66..deff9f5e1 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -104,7 +104,7 @@ pem = "3.0.2" png_pong = "0.9.1" rand = "0.8.5" rand_chacha = "0.3.1" -range-set = "0.0.9" +range-set = "0.0.11" rasn-ocsp = "0.12.5" rasn-pkix = "0.12.5" rasn = "0.12.5" From 3a9e590e3edc917e151721a8433cf81cf3301111 Mon Sep 17 00:00:00 2001 From: Gavin Peacock Date: Mon, 22 Jul 2024 10:02:35 -0700 Subject: [PATCH 11/72] Builder Archive update (#507) * various rust 1.78 clippy fixes * Update builder Archive format Adds version.txt Uses a manifests folder with c2pa files instead of numbered ingredients folders. Includes temporary backwards compatibility for earlier non-versioned format. --- sdk/src/assertion.rs | 8 ----- sdk/src/builder.rs | 79 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/sdk/src/assertion.rs b/sdk/src/assertion.rs index d37a9338c..3222a33da 100644 --- a/sdk/src/assertion.rs +++ b/sdk/src/assertion.rs @@ -471,14 +471,6 @@ impl Assertion { } } -#[allow(dead_code)] // TODO: temp, see #498 -#[derive(Serialize, Deserialize, Debug)] -pub(crate) struct JsonAssertionData { - label: String, - data: Value, - is_cbor: bool, -} - /// This error type is returned when an assertion can not be decoded. #[non_exhaustive] pub struct AssertionDecodeError { diff --git a/sdk/src/builder.rs b/sdk/src/builder.rs index 29140308d..49216995d 100644 --- a/sdk/src/builder.rs +++ b/sdk/src/builder.rs @@ -37,6 +37,9 @@ use crate::{ AsyncSigner, ClaimGeneratorInfo, Signer, }; +/// Version of the Builder Archive file +const ARCHIVE_VERSION: &str = "1"; + /// A Manifest Definition /// This is used to define a manifest and is used to build a ManifestStore /// A Manifest is a collection of ingredients and assertions @@ -259,8 +262,7 @@ impl Builder { let mut resource = Vec::new(); stream.read_to_end(&mut resource)?; // add the resource and set the resource reference - self.resources - .add(self.definition.instance_id.clone(), resource)?; + self.resources.add(&self.definition.instance_id, resource)?; self.definition.thumbnail = Some(ResourceRef::new( format, self.definition.instance_id.clone(), @@ -371,10 +373,15 @@ impl Builder { let mut zip = ZipWriter::new(stream); let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); + // write a version file + zip.start_file("version.txt", options) + .map_err(|e| Error::OtherError(Box::new(e)))?; + zip.write_all(ARCHIVE_VERSION.as_bytes())?; + // write the manifest.json file zip.start_file("manifest.json", options) .map_err(|e| Error::OtherError(Box::new(e)))?; zip.write_all(&serde_json::to_vec(self)?)?; - // add a folder to the zip file + // add resource files to a resources folder zip.start_file("resources/", options) .map_err(|e| Error::OtherError(Box::new(e)))?; for (id, data) in self.resources.resources() { @@ -382,14 +389,20 @@ impl Builder { .map_err(|e| Error::OtherError(Box::new(e)))?; zip.write_all(data)?; } - for (index, ingredient) in self.definition.ingredients.iter().enumerate() { - zip.start_file(format!("ingredients/{}/", index), options) - .map_err(|e| Error::OtherError(Box::new(e)))?; - for (id, data) in ingredient.resources().resources() { - //println!("adding ingredient {}/{}", index, id); - zip.start_file(format!("ingredients/{}/{}", index, id), options) - .map_err(|e| Error::OtherError(Box::new(e)))?; - zip.write_all(data)?; + // Write the manifest_data files + // The filename is filesystem safe version of the associated manifest_label + // with a .c2pa extension inside a "manifests" folder. + zip.start_file("manifests/", options) + .map_err(|e| Error::OtherError(Box::new(e)))?; + for ingredient in self.definition.ingredients.iter() { + if let Some(manifest_label) = ingredient.active_manifest() { + if let Some(manifest_data) = ingredient.manifest_data() { + // Convert to valid archive / file path name + let manifest_name = manifest_label.replace([':'], "_") + ".c2pa"; + zip.start_file(format!("manifests/{manifest_name}"), options) + .map_err(|e| Error::OtherError(Box::new(e)))?; + zip.write_all(&manifest_data)?; + } } } zip.finish() @@ -408,14 +421,16 @@ impl Builder { /// * If the archive cannot be read. pub fn from_archive(stream: impl Read + Seek) -> Result { let mut zip = ZipArchive::new(stream).map_err(|e| Error::OtherError(Box::new(e)))?; - let mut manifest = zip + // First read the manifest.json file. + let mut manifest_file = zip .by_name("manifest.json") .map_err(|e| Error::OtherError(Box::new(e)))?; - let mut manifest_json = Vec::new(); - manifest.read_to_end(&mut manifest_json)?; + let mut manifest_buf = Vec::new(); + manifest_file.read_to_end(&mut manifest_buf)?; let mut builder: Builder = - serde_json::from_slice(&manifest_json).map_err(|e| Error::OtherError(Box::new(e)))?; - drop(manifest); + serde_json::from_slice(&manifest_buf).map_err(|e| Error::OtherError(Box::new(e)))?; + drop(manifest_file); + // Load all the files in the resources folder. for i in 0..zip.len() { let mut file = zip .by_index(i) @@ -432,6 +447,29 @@ impl Builder { //println!("adding resource {}", id); builder.resources.add(id, data)?; } + + // Load the c2pa_manifests. + // We add the manifest data to any ingredient that has a matching active_manfiest label. + if file.name().starts_with("manifests/") && file.name() != "manifests/" { + let mut data = Vec::new(); + file.read_to_end(&mut data)?; + let manifest_label = file + .name() + .split('/') + .nth(1) + .ok_or(Error::BadParam("Invalid manifest path".to_string()))?; + let manifest_label = manifest_label.replace(['_'], ":"); + for ingredient in builder.definition.ingredients.iter_mut() { + if let Some(active_manifest) = ingredient.active_manifest() { + if manifest_label.starts_with(active_manifest) { + ingredient.set_manifest_data(data.clone())?; + } + } + } + } + + // Keep this for temporary unstable api support (un-versioned). + // Earlier method used numbered library folders instead of manifests. if file.name().starts_with("ingredients/") && file.name() != "ingredients/" { let mut data = Vec::new(); file.read_to_end(&mut data)?; @@ -465,7 +503,7 @@ impl Builder { // add the default claim generator info for this library claim_generator_info.push(ClaimGeneratorInfo::default()); - // build the claim_generator string since this is required + // Build the claim_generator string since this is required let claim_generator: String = claim_generator_info .iter() .map(|s| { @@ -493,7 +531,7 @@ impl Builder { claim.add_claim_generator_info(claim_info); } - // add claim metadata + // Add claim metadata if let Some(metadata_vec) = metadata { for m in metadata_vec { claim.add_claim_metadata(m); @@ -972,6 +1010,11 @@ mod tests { .add("thumbnail1.jpg", TEST_IMAGE.to_vec()) .unwrap(); + builder + .resources + .add("prompt.txt", "a random prompt") + .unwrap(); + builder .add_assertion("org.life.meaning", &TestAssertion { answer: 42 }) .unwrap(); From 0cc6e781b61757caf1c4fdbcb0a17c81c7386a54 Mon Sep 17 00:00:00 2001 From: nick Date: Mon, 22 Jul 2024 15:38:46 -0400 Subject: [PATCH 12/72] Fix CI tests (#520) Update image reader --- make_test_images/Cargo.toml | 4 ++-- make_test_images/src/make_thumbnail.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/make_test_images/Cargo.toml b/make_test_images/Cargo.toml index c1d5aa8a4..1acf218e7 100644 --- a/make_test_images/Cargo.toml +++ b/make_test_images/Cargo.toml @@ -13,8 +13,8 @@ c2pa = { path = "../sdk", default-features = false, features = [ "unstable_api", ] } env_logger = "0.11" -log = "0.4.8" -image = { version = "0.25.1", default-features = false, features = [ +log = "0.4.8" +image = { version = "0.25.2", default-features = false, features = [ "jpeg", "png", ] } diff --git a/make_test_images/src/make_thumbnail.rs b/make_test_images/src/make_thumbnail.rs index b59de4611..ed226be61 100644 --- a/make_test_images/src/make_thumbnail.rs +++ b/make_test_images/src/make_thumbnail.rs @@ -14,7 +14,7 @@ use std::io::{Read, Seek}; use anyhow::{Error, Result}; -use image::{io::Reader, ImageFormat}; +use image::{ImageFormat, ImageReader}; // max edge size allowed in pixels for thumbnail creation const THUMBNAIL_LONGEST_EDGE: u32 = 1024; @@ -29,7 +29,7 @@ pub fn make_thumbnail_from_stream( .or_else(|| ImageFormat::from_mime_type(format)) .ok_or(Error::msg(format!("format not supported {format}")))?; - let reader = Reader::with_format(std::io::BufReader::new(stream), format); + let reader = ImageReader::with_format(std::io::BufReader::new(stream), format); let mut img = reader.decode()?; let longest_edge = THUMBNAIL_LONGEST_EDGE; From ce91afb0680d0eb568b43f01b28fd1159c8ca358 Mon Sep 17 00:00:00 2001 From: nick Date: Tue, 23 Jul 2024 02:52:25 -0400 Subject: [PATCH 13/72] Add region of interest assertion definition (#506) * Add region of interest assertion definition * Fix some unit tests * Skip serializing None for region of interest * Remove incorrect ROI type from unit tests * Add region of interest unit tests * Fix doc warnings * Add feature flag for JsonSchema --- sdk/src/assertions/actions.rs | 83 ++++++-- sdk/src/assertions/metadata.rs | 46 ++++- sdk/src/assertions/mod.rs | 2 + sdk/src/assertions/region_of_interest.rs | 247 +++++++++++++++++++++++ sdk/src/manifest.rs | 14 -- 5 files changed, 356 insertions(+), 36 deletions(-) create mode 100644 sdk/src/assertions/region_of_interest.rs diff --git a/sdk/src/assertions/actions.rs b/sdk/src/assertions/actions.rs index 2377b5dc5..f873846a9 100644 --- a/sdk/src/assertions/actions.rs +++ b/sdk/src/assertions/actions.rs @@ -18,7 +18,7 @@ use serde_cbor::Value; use crate::{ assertion::{Assertion, AssertionBase, AssertionCbor}, - assertions::{labels, Actor, Metadata}, + assertions::{labels, region_of_interest::RegionOfInterest, Actor, Metadata}, error::Result, resource_store::UriOrResource, utils::cbor_types::DateT, @@ -90,7 +90,7 @@ impl From for SoftwareAgent { /// the action. /// /// See . -#[derive(Deserialize, Serialize, Clone, Debug, Default, PartialEq, Eq)] +#[derive(Deserialize, Serialize, Clone, Debug, Default, PartialEq)] pub struct Action { /// The label associated with this action. See ([`c2pa_action`]). action: String, @@ -113,7 +113,7 @@ pub struct Action { /// When tracking changes and the scope of the changed components is unknown, /// it should be assumed that anything might have changed. #[serde(skip_serializing_if = "Option::is_none")] - changes: Option>, + changes: Option>, /// The value of the `xmpMM:InstanceID` property for the modified (output) resource. #[serde(rename = "instanceId", skip_serializing_if = "Option::is_none")] @@ -190,6 +190,11 @@ impl Action { self.instance_id.as_deref() } + /// Returns the regions of interest that changed]. + pub fn changes(&self) -> Option<&[RegionOfInterest]> { + self.changes.as_deref() + } + /// Returns the additional parameters for this action. /// /// These vary by the type of action. @@ -313,6 +318,19 @@ impl Action { self.reason = Some(reason.into()); self } + + /// Adds a region of interest that changed. + pub fn add_change(mut self, region_of_interest: RegionOfInterest) -> Self { + match &mut self.changes { + Some(changes) => { + changes.push(region_of_interest); + } + _ => { + self.changes = Some(vec![region_of_interest]); + } + } + self + } } #[derive(Deserialize, Serialize, Debug, Default, PartialEq, Eq)] @@ -357,7 +375,7 @@ impl ActionTemplate { /// other information such as what software performed the action. /// /// See . -#[derive(Deserialize, Serialize, Debug, PartialEq, Eq)] +#[derive(Deserialize, Serialize, Debug, PartialEq)] #[non_exhaustive] pub struct Actions { /// A list of [`Action`]s. @@ -491,7 +509,10 @@ pub mod tests { use super::*; use crate::{ assertion::AssertionData, - assertions::metadata::{c2pa_source::GENERATOR_REE, DataSource, ReviewRating}, + assertions::{ + metadata::{c2pa_source::GENERATOR_REE, DataSource, ReviewRating}, + region_of_interest::{Range, RangeType, Time, TimeType}, + }, hashed_uri::HashedUri, }; @@ -540,7 +561,26 @@ pub mod tests { .set_parameter("name".to_owned(), "gaussian blur") .unwrap() .set_when("2015-06-26T16:43:23+0200") - .set_source_type("digsrctype:algorithmicMedia"), + .set_source_type("digsrctype:algorithmicMedia") + .add_change(RegionOfInterest { + region: vec![Range { + range_type: RangeType::Temporal, + shape: None, + time: Some(Time { + time_type: TimeType::Npt, + start: None, + end: None, + }), + frame: None, + text: None, + }], + name: None, + identifier: None, + region_type: None, + role: None, + description: None, + metadata: None, + }), ) .add_metadata( Metadata::new() @@ -552,7 +592,7 @@ pub mod tests { assert_eq!(original.actions.len(), 2); let assertion = original.to_assertion().expect("build_assertion"); assert_eq!(assertion.mime_type(), "application/cbor"); - assert_eq!(assertion.label(), Actions::LABEL); + assert_eq!(assertion.label(), format!("{}.v2", Actions::LABEL)); let result = Actions::from_assertion(&assertion).expect("extract_assertion"); assert_eq!(result.actions.len(), 2); @@ -571,6 +611,7 @@ pub mod tests { result.actions[1].source_type().unwrap(), "digsrctype:algorithmicMedia" ); + assert_eq!(result.actions[1].changes(), original.actions()[1].changes()); assert_eq!( result.metadata.unwrap().date_time(), original.metadata.unwrap().date_time() @@ -739,14 +780,6 @@ pub mod tests { "region": [ { "type": "temporal", - "time": {} - }, - { - "type": "identified", - "item": { - "identifier": "https://bioportal.bioontology.org/ontologies/FMA", - "value": "lips" - } } ] } @@ -782,10 +815,22 @@ pub mod tests { &SoftwareAgent::String("TestApp".to_string()) ); assert_eq!( - result.actions[3].changes.as_deref().unwrap()[0] - .get("description") - .unwrap(), - "translated to klingon" + result.actions[3].changes().unwrap(), + &[RegionOfInterest { + description: Some("translated to klingon".to_owned()), + region: vec![Range { + range_type: RangeType::Temporal, + shape: None, + time: None, + frame: None, + text: None + }], + name: None, + identifier: None, + region_type: None, + role: None, + metadata: None + }] ); } } diff --git a/sdk/src/assertions/metadata.rs b/sdk/src/assertions/metadata.rs index 2c43fb82b..ef35a2a5e 100644 --- a/sdk/src/assertions/metadata.rs +++ b/sdk/src/assertions/metadata.rs @@ -21,7 +21,7 @@ use serde_json::Value; use crate::{ assertion::{Assertion, AssertionBase, AssertionCbor}, - assertions::labels, + assertions::{labels, region_of_interest::RegionOfInterest}, error::Result, hashed_uri::HashedUri, utils::cbor_types::DateT, @@ -30,7 +30,7 @@ use crate::{ const ASSERTION_CREATION_VERSION: usize = 1; /// The Metadata structure can be used as part of other assertions or on its own to reference others -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[cfg_attr(feature = "json_schema", derive(JsonSchema))] pub struct Metadata { #[serde(rename = "reviewRatings", skip_serializing_if = "Option::is_none")] @@ -41,6 +41,8 @@ pub struct Metadata { reference: Option, #[serde(rename = "dataSource", skip_serializing_if = "Option::is_none")] data_source: Option, + #[serde(rename = "regionOfInterest", skip_serializing_if = "Option::is_none")] + region_of_interest: Option, #[serde(flatten)] other: HashMap, } @@ -59,6 +61,7 @@ impl Metadata { )), reference: None, data_source: None, + region_of_interest: None, other: HashMap::new(), } } @@ -78,6 +81,11 @@ impl Metadata { self.data_source.as_ref() } + /// Returns the [`RegionOfInterest`] for this assertion if it exists. + pub fn region_of_interest(&self) -> Option<&RegionOfInterest> { + self.region_of_interest.as_ref() + } + /// Returns map containing custom metadata fields. pub fn other(&self) -> &HashMap { &self.other @@ -119,6 +127,12 @@ impl Metadata { self } + /// Sets the region of interest. + pub fn set_region_of_interest(mut self, region_of_interest: RegionOfInterest) -> Self { + self.region_of_interest = Some(region_of_interest); + self + } + /// Adds an additional key / value pair. pub fn insert(&mut self, key: &str, value: Value) -> &mut Self { self.other.insert(key.to_string(), value); @@ -300,12 +314,34 @@ pub mod tests { #![allow(clippy::unwrap_used)] use super::*; + use crate::assertions::region_of_interest::{Range, RangeType, Time, TimeType}; #[test] fn assertion_metadata() { let review = ReviewRating::new("foo", Some("bar".to_owned()), 3); let test_value = Value::from("test"); - let mut original = Metadata::new().add_review(review); + let mut original = + Metadata::new() + .add_review(review) + .set_region_of_interest(RegionOfInterest { + region: vec![Range { + range_type: RangeType::Temporal, + shape: None, + time: Some(Time { + time_type: TimeType::Npt, + start: None, + end: None, + }), + frame: None, + text: None, + }], + name: None, + identifier: None, + region_type: None, + role: None, + description: None, + metadata: None, + }); original.insert("foo", test_value); println!("{:?}", &original); let assertion = original.to_assertion().expect("build_assertion"); @@ -316,6 +352,10 @@ pub mod tests { assert_eq!(original.date_time, result.date_time); assert_eq!(original.reviews, result.reviews); assert_eq!(original.get("foo").unwrap(), "test"); + assert_eq!( + original.region_of_interest.as_ref(), + result.region_of_interest() + ) //assert_eq!(original.reviews.unwrap().len(), 1); } } diff --git a/sdk/src/assertions/mod.rs b/sdk/src/assertions/mod.rs index dae13620e..f2523e9bc 100644 --- a/sdk/src/assertions/mod.rs +++ b/sdk/src/assertions/mod.rs @@ -57,3 +57,5 @@ pub(crate) use user_cbor::UserCbor; mod uuid_assertion; #[allow(unused_imports)] pub(crate) use uuid_assertion::Uuid; + +pub mod region_of_interest; diff --git a/sdk/src/assertions/region_of_interest.rs b/sdk/src/assertions/region_of_interest.rs new file mode 100644 index 000000000..349299152 --- /dev/null +++ b/sdk/src/assertions/region_of_interest.rs @@ -0,0 +1,247 @@ +//! A set of structs to define a region of interest within an +//! [`Action`][crate::assertions::Action] or [`Metadata`]. + +#[cfg(feature = "json_schema")] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use super::Metadata; + +/// An x, y coordinate used for specifying vertices in polygons. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +pub struct Coordinate { + /// The coordinate along the x-axis. + pub x: f64, + /// The coordinate along the y-axis. + pub y: f64, +} + +/// The type of shape for the range. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum ShapeType { + /// A rectangle. + Rectangle, + /// A circle. + Circle, + /// A polygon. + Polygon, +} + +/// The type of unit for the range. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum UnitType { + /// Use pixels. + Pixel, + /// Use percentage. + Percent, +} + +/// A spatial range representing rectangle, circle, or a polygon. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +#[skip_serializing_none] +pub struct Shape { + /// The type of shape. + #[serde(rename = "type")] + pub shape_type: ShapeType, + /// The type of unit for the shape range. + pub unit: UnitType, + /// THe origin of the coordinate in the shape. + pub origin: Coordinate, + /// The width for rectangles or diameter for circles. + /// + /// This field can be ignored for polygons. + pub width: Option, + /// The height of a rectnagle. + /// + /// This field can be ignored for circles and polygons. + pub height: Option, + /// If the range is inside the shape. + /// + /// The default value is true. + pub inside: Option, + /// The vertices of the polygon. + /// + /// This field can be ignored for rectangles and circles. + pub vertices: Option>, +} + +/// The type of time. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum TimeType { + /// Times are described using Normal Play Time (npt) as described in RFC 2326. + #[default] + Npt, +} + +/// A temporal range representing a starting time to an ending time. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +#[skip_serializing_none] +pub struct Time { + /// The type of time. + #[serde(rename = "type", default)] + pub time_type: TimeType, + /// The start time or the start of the asset if not present. + pub start: Option, + /// The end time or the end of the asset if not present. + pub end: Option, +} + +/// A frame range representing starting and ending frames or pages. +/// +/// If both `start` and `end` are missing, the frame will span the entire asset. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +pub struct Frame { + /// The start of the frame or the end of the asset if not present. + /// + /// The first frame/page starts at 0. + pub start: Option, + /// The end of the frame inclusive or the end of the asset if not present. + pub end: Option, +} + +/// Selects a range of text via a fragment identifier. +/// +/// This is modeled after the W3C Web Annotation selector model. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +#[skip_serializing_none] +pub struct TextSelector { + // TODO: can we provide more specific types? + // + /// Fragment identifier as per RFC3023 (XML) or ISO 32000-2 (PDF), Annex O. + pub fragment: String, + /// The start character offset or the start of the fragment if not present. + pub start: Option, + /// The end character offset or the end of the fragment if not present. + pub end: Option, +} + +/// One or two [`TextSelector`][TextSelector] identifiying the range to select. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +#[skip_serializing_none] +pub struct TextSelectorRange { + /// The start (or entire) text range. + pub selector: TextSelector, + /// The end of the text range. + pub end: Option, +} + +/// A textual range representing multiple (possibly discontinuous) ranges of text. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +pub struct Text { + /// The ranges of text to select. + pub selectors: Vec, +} + +/// The type of range for the region of interest. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum RangeType { + /// A spatial range, see [`Shape`] for more details. + Spatial, + /// A temporal range, see [`Time`] for more details. + Temporal, + /// A spatial range, see [`Frame`] for more details. + Frame, + /// A textual range, see [`Text`] for more details. + Textual, +} + +// TODO: this can be much more idiomatic with an enum, but then it wouldn't line up with spec +// +/// A spatial, temporal, frame, or textual range describing the region of interest. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +#[skip_serializing_none] +pub struct Range { + /// The type of range of interest. + #[serde(rename = "type")] + pub range_type: RangeType, + /// A spatial range. + pub shape: Option, + /// A temporal range. + pub time: Option