Public release: C2PA write support. GitOrigin-RevId: 24834eac456c5fadc48965b4a7a69447fc3a2327 Change-Id: I8617ef3b9b5e003e1313a2f3569d23760673d8c9
diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 0000000..deeb3d6 --- /dev/null +++ b/.bazelversion
@@ -0,0 +1 @@ +9.2.0
diff --git a/MODULE.bazel b/MODULE.bazel index 0c82647..8568f24 100644 --- a/MODULE.bazel +++ b/MODULE.bazel
@@ -14,19 +14,22 @@ # module( - name = "c2pa", + name = "credentio", version = "0.1.0", ) +# apple_support must come before rules_cc so that its Apple C++ toolchain takes +# precedence on macOS. +bazel_dep(name = "apple_support", version = "2.9.1") bazel_dep(name = "abseil-cpp", version = "20260526.0") bazel_dep(name = "googletest", version = "1.17.0.bcr.2") -bazel_dep(name = "protobuf", version = "34.1") -bazel_dep(name = "rules_cc", version = "0.2.17") +bazel_dep(name = "protobuf", version = "36.2") +bazel_dep(name = "rules_cc", version = "0.2.25") bazel_dep(name = "rules_foreign_cc", version = "0.15.1") bazel_dep(name = "riegeli", version = "0.0.0-20250822-9f2744d") bazel_dep(name = "nlohmann_json", version = "3.12.0.bcr.1") -bazel_dep(name = "boringssl", version = "0.20260413.0") -bazel_dep(name = "tink_cc", version = "2.7.0") +bazel_dep(name = "boringssl", version = "0.20260813.0") +bazel_dep(name = "tink_cc", version = "2.9.1") bazel_dep(name = "re2", version = "2025-11-05.bcr.1") # Add rules_android and its dependencies @@ -51,18 +54,24 @@ register_toolchains("@androidsdk//:sdk-toolchain", "@androidsdk//:all") -http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") +# libcppbor has no Bazel module or release archive, so it is fetched directly +# from AOSP. It is pinned to a commit and fetched over git rather than via the +# gitiles "+archive" endpoint: that endpoint builds tarballs on demand (it +# returns 503 under load) and its output is not byte-reproducible, so it can +# neither be verified with a sha256 nor cached by Bazel. +git_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository") -http_archive( +git_repository( name = "libcppbor", build_file = "@//:external/libcppbor.BUILD", - urls = ["https://android.googlesource.com/platform/system/libcppbor/+archive/refs/heads/main.tar.gz"], + commit = "ef9626806649d45cd1b5dd692695eae82aff5542", + remote = "https://android.googlesource.com/platform/system/libcppbor", ) archive_override( module_name = "boringssl", patch_strip = 1, patches = ["@//:external/boringssl_pki_visibility.patch"], - strip_prefix = "boringssl-0.20260413.0", - urls = ["https://github.com/google/boringssl/archive/0.20260413.0.tar.gz"], + strip_prefix = "boringssl-0.20260813.0", + urls = ["https://github.com/google/boringssl/archive/0.20260813.0.tar.gz"], )
diff --git a/README.md b/README.md index 3588b6b..a8805b6 100644 --- a/README.md +++ b/README.md
@@ -8,25 +8,66 @@ ## Supported Media Formats -The library supports C2PA provenance extraction and validation for files with -the following extensions: +The library supports C2PA provenance validation (extracting and verifying an +embedded C2PA manifest store) and generation (embedding a newly generated and +signed C2PA manifest store) for the following container formats: <!-- mdformat off(no multiline table cells) --> -Category | Extensions -:-------------- | :--------- -**Image** | `.avif`, `.dng`, `.gif`, `.heic`, `.heif`, `.jpeg`, `.jpg`, `.png`, `.tif`, `.tiff`, `.webp` -**Video/Audio** | `.avi`, `.m4a`, `.mov`, `.mp3`, `.mp4`, `.wav`, `.flac` -**Document** | `.pdf`, `.docx`, `.pptx`, `.xlsx` +Format | Extensions | Validation | Generation +:--------------------------- | :---------------------------------- | :--------- | :--------- +JPEG | `.jpg`, `.jpeg` | Yes | [Yes](formats/jpeg/writer.h) +PNG | `.png` | Yes | [Yes](formats/png/writer.h) +MP4 | `.mp4` | Yes | [Yes](formats/bmff/writer.h) +fMP4 (fragmented MP4) | | No | No +RIFF (e.g., WebP, WAV, AVI) | `.webp`, `.wav`, `.avi` | Yes | [Yes](formats/riff/writer.h) +TIFF (e.g., TIFF, DNG) | `.tif`, `.tiff`, `.dng` | Yes | [Yes](formats/tiff/writer.h) +AVIF | `.avif` | Yes | [Yes](formats/bmff/writer.h) +M4A | `.m4a` | Yes | [Yes](formats/bmff/writer.h) +HEIF, HEIC | `.heif`, `.heic` | Yes | [Yes](formats/bmff/writer.h) +MOV | `.mov` | Yes | [Yes](formats/bmff/writer.h) +GIF | `.gif` | Yes | [Yes](formats/gif/writer.h) +MP3 | `.mp3` | Yes | [Yes](formats/id3/writer.h) +FLAC | `.flac` | Yes | [Yes](formats/id3/writer.h) +PDF | `.pdf` | Yes | [Yes](formats/pdf/writer.h) +SVG | | No | No +OGG Vorbis | | No | No +Text | | No | No +Live stream video | | No | No +ZIP (e.g., DOCX, PPTX, XLSX) | `.zip`, `.docx`, `.pptx`, `.xlsx` | Yes | [Yes](formats/zip/writer.h) +Fonts | | No | No <!-- mdformat on --> ## Prerequisites -* Clang -* Bazel +* Clang with libc++ (tested with Clang 19). `.bazelrc` builds with `clang` and + `clang++` against libc++, so both must be on `PATH` and the libc++ + development packages must be installed. +* [Bazelisk](https://github.com/bazelbuild/bazelisk), installed as `bazel`. It + downloads the Bazel version pinned in `.bazelversion`. * Git +On Ubuntu, CI sets these up as follows: + +```shell +# Clang 19, including libc++, from the LLVM apt repository. +sudo apt-get install -y lsb-release wget software-properties-common gnupg +wget https://apt.llvm.org/llvm.sh +chmod +x llvm.sh +sudo ./llvm.sh 19 all +sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-19 100 +sudo update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-19 100 + +# Bazelisk. +curl -sLO https://github.com/bazelbuild/bazelisk/releases/latest/download/bazelisk-linux-amd64 +chmod +x bazelisk-linux-amd64 +sudo mv bazelisk-linux-amd64 /usr/local/bin/bazel +``` + +Bazel caches the detected C++ toolchain, so after installing or changing the +compiler or libc++, run `bazel clean --expunge` before building again. + ## Dependencies * [Abseil (`abseil-cpp`)](https://abseil.io/) @@ -40,22 +81,29 @@ ## Quickstart -Credentio includes a fast standalone command-line tool for inspecting and -verifying C2PA metadata embedded in media assets. +Credentio includes fast standalone command-line tools for inspecting, verifying, +and writing C2PA metadata embedded in media assets. -#### Building the CLI +### Building the CLIs + +Build the tools once: ```bash -bazel build tools:c2pa_validate +bazel build tools:c2pa_validate tools:asset_write ``` +The binaries are written to `bazel-bin/tools/c2pa_validate` and +`bazel-bin/tools/asset_write`. + +### Validating C2PA Metadata + #### Validating with Trust Anchors You can validate assets against specific PEM-encoded trust anchors for claim signers and Time Stamping Authorities (TSA): ```bash -bazel run tools:c2pa_validate -- \ +bazel-bin/tools/c2pa_validate \ --asset=/path/to/asset.jpg \ --claim_signer_trust=/path/to/claim_signer_trust_anchors.pem \ --tsa_trust=/path/to/tsa_trust_anchors.pem @@ -66,6 +114,90 @@ > organization on GitHub > ([https://github.com/c2pa-org/conformance-public/tree/main/trust-list](https://github.com/c2pa-org/conformance-public/tree/main/trust-list)). +### Writing C2PA Metadata + +The `asset_write` tool generates a C2PA manifest store, signs it with the +provided claim signer key, and embeds it into a copy of the input asset. The +media type is inferred from the input file extension. + +#### Creating Test Keys and Certificates + +`asset_write` needs an EC P-256 key and certificate for the claim signer, and +optionally another pair for the Time Stamping Authority (TSA). For local +testing, [`tools/create_test_pki.sh`](tools/create_test_pki.sh) creates a +private root CA and issues both certificates from it. It requires OpenSSL 1.1.1 +or later: + +```bash +tools/create_test_pki.sh test_pki +``` + +The script refuses to overwrite existing keys. It produces: + +* `claim_signer_key.pem` and `claim_signer_cert.pem`: pass to + `--claim_signer_key` and `--claim_signer_cert`. +* `tsa_key.pem` and `tsa_cert.pem`: pass to `--tsa_key` and `--tsa_cert`. +* `root_ca_cert.pem`: pass to `--claim_signer_trust` and `--tsa_trust` when + validating. + +#### Writing a Manifest Store + +```bash +bazel-bin/tools/asset_write \ + --input=/path/to/input.jpg \ + --output=/path/to/output.jpg \ + --claim_signer_key=/path/to/claim_signer_key.pem \ + --claim_signer_cert=/path/to/claim_signer_cert_chain.pem \ + --tsa_key=/path/to/tsa_key.pem \ + --tsa_cert=/path/to/tsa_cert.pem +``` + +Flags: + +* `--claim_signer_key`: PEM-encoded EC P-256 private key of the claim signer. +* `--claim_signer_cert`: PEM-encoded claim signer certificate chain, leaf + first. +* `--claim_signer_ocsp` (optional): DER-encoded OCSP response for the claim + signer certificate. +* `--tsa_key`, `--tsa_cert` (optional): PEM-encoded EC P-256 private key and + certificate used to timestamp the claim signature locally. If omitted, + timestamping is disabled. +* `--disable_timestamping` (optional): Disables timestamping. +* `--request_txtpb` (optional): Text-format + [`GenerateManifestStoreRequest`](proto/generator.proto) describing the + manifest to generate. Defaults to a single `c2pa.created` action. + +#### End-to-End Example + +Using the files from +[Creating Test Keys and Certificates](#creating-test-keys-and-certificates), +sign an asset and validate it against the test root CA. Run these commands from +the repository root: + +```bash +tools/create_test_pki.sh test_pki + +bazel-bin/tools/asset_write \ + --input=/path/to/input.jpg \ + --output=signed.jpg \ + --claim_signer_key=test_pki/claim_signer_key.pem \ + --claim_signer_cert=test_pki/claim_signer_cert.pem \ + --tsa_key=test_pki/tsa_key.pem \ + --tsa_cert=test_pki/tsa_cert.pem + +bazel-bin/tools/c2pa_validate \ + --asset=signed.jpg \ + --claim_signer_trust=test_pki/root_ca_cert.pem \ + --tsa_trust=test_pki/root_ca_cert.pem +``` + +The validation result should report `claimSignature.validated`, +`signingCredential.trusted` and `timeStamp.trusted` with no failures. + +> **Note:** `asset_write` signs with locally supplied keys and is intended for +> development and testing. Manifest stores it produces are only trusted by +> validators that trust the supplied certificates. + ## Building & Testing To build the entire project:
diff --git a/assertion/assertion_encoder.cc b/assertion/assertion_encoder.cc index 70b0524..2599280 100644 --- a/assertion/assertion_encoder.cc +++ b/assertion/assertion_encoder.cc
@@ -90,13 +90,9 @@ return std::move(assertion_builder).Finalize(); // NOLINT: rvalue method call } -const AssertionEncoder& GetAssertionEncoder( - bool skip_validity_checks_for_test) { - static const absl::NoDestructor<StandardAssertionEncoder> encoder_for_test( - /*skip_validity_checks_for_test=*/true); - static const absl::NoDestructor<StandardAssertionEncoder> encoder( - /*skip_validity_checks_for_test=*/false); - return skip_validity_checks_for_test ? *encoder_for_test : *encoder; +const AssertionEncoder& GetAssertionEncoder() { + static const absl::NoDestructor<StandardAssertionEncoder> encoder; + return *encoder; } } // namespace credentio
diff --git a/assertion/assertion_encoder.h b/assertion/assertion_encoder.h index 1a964eb..822bb04 100644 --- a/assertion/assertion_encoder.h +++ b/assertion/assertion_encoder.h
@@ -49,7 +49,7 @@ }; // Returns a singleton StandardAssertionEncoder. -const AssertionEncoder& GetAssertionEncoder(bool skip_validity_checks_for_test); +const AssertionEncoder& GetAssertionEncoder(); } // namespace credentio
diff --git a/assertion/assertion_encoder_test.cc b/assertion/assertion_encoder_test.cc index 46759a4..1faa065 100644 --- a/assertion/assertion_encoder_test.cc +++ b/assertion/assertion_encoder_test.cc
@@ -328,20 +328,8 @@ EXPECT_THAT(result, EqualsProto(assertion.boxes_hash())); } -TEST(GetAssertionEncoderTest, ReturnsSameInstanceForSameParam) { - const auto& encoder1 = GetAssertionEncoder(true); - const auto& encoder2 = GetAssertionEncoder(true); - EXPECT_EQ(&encoder1, &encoder2); - - const auto& encoder3 = GetAssertionEncoder(false); - const auto& encoder4 = GetAssertionEncoder(false); - EXPECT_EQ(&encoder3, &encoder4); -} - -TEST(GetAssertionEncoderTest, ReturnsDifferentInstancesForDifferentParams) { - const auto& encoder1 = GetAssertionEncoder(true); - const auto& encoder2 = GetAssertionEncoder(false); - EXPECT_NE(&encoder1, &encoder2); +TEST(GetAssertionEncoderTest, ReturnsSameInstance) { + EXPECT_EQ(&GetAssertionEncoder(), &GetAssertionEncoder()); } absl::StatusOr<Assertion> ParseAssertionBox(absl::string_view assertion_data) { @@ -365,8 +353,7 @@ } TEST(AssertionEncoderTest, ToJumbf) { - StandardAssertionEncoder encoder( - /*skip_validity_checks_for_test=*/false); + StandardAssertionEncoder encoder; auto assertion = ParseTextProtoOrDie<Assertion>(R"pb( label: "c2pa.actions.v2" actions {
diff --git a/assertion/parse_assertion_test.cc b/assertion/parse_assertion_test.cc index 2d327ad..37c434c 100644 --- a/assertion/parse_assertion_test.cc +++ b/assertion/parse_assertion_test.cc
@@ -901,7 +901,7 @@ } )pb"); credentio::AiDisclosureAssertion actual; - ASSERT_THAT(credentio::ParseAssertion(cbor, &actual), IsOk()); + ABSL_ASSERT_OK(credentio::ParseAssertion(cbor, &actual)); EXPECT_THAT(actual, EqualsProto(expected)); } @@ -933,7 +933,7 @@ } )pb"); credentio::EnvironmentalSustainabilityAssertion actual; - ASSERT_THAT(credentio::ParseAssertion(cbor, &actual), IsOk()); + ABSL_ASSERT_OK(credentio::ParseAssertion(cbor, &actual)); EXPECT_THAT(actual, EqualsProto(expected)); } @@ -977,7 +977,7 @@ } )pb"); credentio::RepositoryReceiptAssertion actual; - ASSERT_THAT(credentio::ParseAssertion(json, &actual), IsOk()); + ABSL_ASSERT_OK(credentio::ParseAssertion(json, &actual)); EXPECT_THAT(actual, EqualsProto(expected)); }
diff --git a/bindings/BUILD b/bindings/BUILD index 995fea2..72d7bbb 100644 --- a/bindings/BUILD +++ b/bindings/BUILD
@@ -144,6 +144,19 @@ ], ) +cc_test( + name = "bmff_exclusion_checker_test", + srcs = ["bmff_exclusion_checker_test.cc"], + deps = [ + ":bmff_exclusion_checker", + "//crypto:algorithms", + "//formats/bmff:hasher", + "//proto:bmff_based_hash_assertion_cc_proto", + "//testing:proto_test_utils", + "@googletest//:gtest_main", + ], +) + cc_library( name = "merkle_validator", srcs = ["merkle_validator.cc"], @@ -227,6 +240,7 @@ "@abseil-cpp//absl/log", "@abseil-cpp//absl/status", "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", "@abseil-cpp//absl/strings", "@abseil-cpp//absl/strings:str_format", "@abseil-cpp//absl/strings:string_view", @@ -436,3 +450,34 @@ "@riegeli//riegeli/bytes:reader", ], ) + +cc_test( + name = "data_hash_hard_binding_validator_test", + srcs = ["data_hash_hard_binding_validator_test.cc"], + deps = [ + ":data_hash_hard_binding_validator", + "//formats:asset_byte_info", + "//proto:assertion_cc_proto", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:proto_test_utils", + "//utils:dual_status_tracker", + "//utils:status_tracker", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@protobuf//:differencer", + "@riegeli//riegeli/base:object", + "@riegeli//riegeli/base:types", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + ], +)
diff --git a/bindings/bmff_exclusion_checker_test.cc b/bindings/bmff_exclusion_checker_test.cc new file mode 100644 index 0000000..e0bf5e8 --- /dev/null +++ b/bindings/bmff_exclusion_checker_test.cc
@@ -0,0 +1,177 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "bindings/bmff_exclusion_checker.h" + +#include <string> + +#include "crypto/algorithms.h" +#include "formats/bmff/hasher.h" +#include "gtest/gtest.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using ::credentio_testing::ParseTextProtoOrDie; + +TEST(NoAdditionalExclusionsTest, NoExclusions) { + BmffBasedHashAssertion assertion; + EXPECT_FALSE(HasAdditionalExclusions(assertion)); +} + +TEST(NoAdditionalExclusionsTest, OnlyRequiredExclusions) { + BmffBasedHashAssertion assertion = + GetBmffHashTemplate(HashAlgorithm::kSha256); + + EXPECT_FALSE(HasAdditionalExclusions(assertion)); +} + +TEST(NoAdditionalExclusionsTest, WithSkipExclusion) { + auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { xpath: "/skip" } + )pb"); + + EXPECT_FALSE(HasAdditionalExclusions(assertion)); +} + +TEST(AdditionalExclusionsTest, MerkleMapExclusionWithoutMerkleMaps) { + BmffBasedHashAssertion assertion = + GetBmffHashTemplate(HashAlgorithm::kSha256); + auto exclusion = ParseTextProtoOrDie<BmffRange>(R"pb( + xpath: "/mdat" + subsets { offset: 16 length: 0 } + )pb"); + *assertion.add_exclusions() = exclusion; + + EXPECT_TRUE(HasAdditionalExclusions(assertion)); +} + +TEST(NoAdditionalExclusionsTest, WithMerkleMaps) { + BmffBasedHashAssertion assertion = + GetBmffHashTemplate(HashAlgorithm::kSha256); + auto exclusion = ParseTextProtoOrDie<BmffRange>(R"pb( + xpath: "/mdat" + subsets { offset: 16 length: 0 } + )pb"); + *assertion.add_exclusions() = exclusion; + auto merkle = ParseTextProtoOrDie<BmffMerkle>(R"pb( + unique_id: 1 + local_id: 1 + count: 1 + alg: "sha256" + init_hash: "\001\002\003" + hashes: "\004\005\006" + fixed_block_size: 1 + )pb"); + *assertion.add_merkles() = merkle; + + EXPECT_FALSE(HasAdditionalExclusions(assertion)); +} + +TEST(NoAdditionalExclusionsTest, ExclusionIsContainedWithinRequired) { + auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { xpath: "/free[1]" } + )pb"); + + EXPECT_FALSE(HasAdditionalExclusions(assertion)); +} + +TEST(AdditionalExclusionsTest, ExclusionIsNotGuaranteedToBeContained) { + auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { xpath: "/uuid/data" } + )pb"); + + EXPECT_TRUE(HasAdditionalExclusions(assertion)); +} + +TEST(AdditionalExclusionsTest, WhenHasDifferentData) { + auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { + xpath: "/uuid" + data { offset: 0 value: "\001\002\003" } + } + )pb"); + + EXPECT_TRUE(HasAdditionalExclusions(assertion)); +} + +TEST(AdditionalExclusionsTest, WhenHasDifferentSubsets) { + auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { + xpath: "/mdat" + subsets { offset: 10 length: 4 } + } + )pb"); + + EXPECT_TRUE(HasAdditionalExclusions(assertion)); +} + +TEST(AdditionalExclusionsTest, RequiredExclusionWithSubsets) { + auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { + xpath: "/uuid" + data { + offset: 8 + value: "\330\376\303\326\033\016H<\222\227X(\207~\304\201" + } + subsets { offset: 0 length: 128 } + } + )pb"); + + EXPECT_TRUE(HasAdditionalExclusions(assertion)); +} + +TEST(AdditionalExclusionsTest, FtypExclusionWithSubsets) { + auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { + xpath: "/ftyp" + subsets { offset: 0 length: 8 } + } + )pb"); + + EXPECT_TRUE(HasAdditionalExclusions(assertion)); +} + +TEST(AdditionalExclusionsTest, MfraExclusionWithSubsets) { + auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { + xpath: "/mfra" + subsets { offset: 0 length: 8 } + } + )pb"); + + EXPECT_TRUE(HasAdditionalExclusions(assertion)); +} + +TEST(AdditionalExclusionsTest, FtypExclusionWithPathExtension) { + auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { xpath: "/ftyp/extra" } + )pb"); + + EXPECT_TRUE(HasAdditionalExclusions(assertion)); +} + +TEST(AdditionalExclusionsTest, MfraExclusionWithPathExtension) { + auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { xpath: "/mfra/extra" } + )pb"); + + EXPECT_TRUE(HasAdditionalExclusions(assertion)); +} + +} // namespace +} // namespace credentio
diff --git a/bindings/data_hash_hard_binding_validator_test.cc b/bindings/data_hash_hard_binding_validator_test.cc new file mode 100644 index 0000000..cd4fcb6 --- /dev/null +++ b/bindings/data_hash_hard_binding_validator_test.cc
@@ -0,0 +1,666 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "bindings/data_hash_hard_binding_validator.h" + +#include <cstddef> +#include <memory> +#include <optional> +#include <string> +#include <type_traits> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/escaping.h" +#include "absl/strings/string_view.h" +#include "formats/asset_byte_info.h" +#include "gmock/gmock.h" +#include "google/protobuf/util/message_differencer.h" +#include "gtest/gtest.h" +#include "proto/assertion.pb.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "riegeli/base/object.h" +#include "riegeli/base/types.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" +#include "testing/proto_test_utils.h" +#include "utils/dual_status_tracker.h" +#include "utils/status_tracker.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::HasSubstr; +using ::testing::Pointee; +using ::testing::TestWithParam; + +// Matches a `ValidationResultProto` against `expected` (a proto or a text +// proto), ignoring the assertions of all manifests. +MATCHER_P(EqualsProtoIgnoringAssertions, expected, "") { + using Proto = std::decay_t<decltype(arg)>; + Proto expected_proto; + if constexpr (std::is_convertible_v<decltype(expected), absl::string_view>) { + expected_proto = ParseTextProtoOrDie(expected); + } else { + expected_proto = expected; + } + google::protobuf::util::MessageDifferencer differencer; + differencer.IgnoreField( + Manifest::descriptor()->FindFieldByName("assertions")); + std::string diff; + differencer.ReportDifferencesToString(&diff); + if (!differencer.Compare(expected_proto, arg)) { + *result_listener << "\nDifference:\n" << diff; + return false; + } + return true; +} + +// A custom reader that supports size but fails on read. +class FailingReader : public riegeli::Reader { + public: + explicit FailingReader(riegeli::Closed) : Reader(riegeli::kClosed) {} + + FailingReader() = default; + + bool PullSlow(size_t min_length, size_t recommended_length) override { + return Fail(absl::InternalError("Simulated read error")); + } + + bool SupportsSize() override { return true; } + std::optional<riegeli::Position> SizeImpl() override { return 10; } +}; + +struct DataHashValidatorTestCase { + std::string test_name; + std::string contents; + DataHashAssertion data_hash_assertion; + std::optional<std::string> hash; + AssetByteInfo asset_byte_info; + ValidationStatusSet expected_validation_result; +}; +using DataHashValidatorTest = TestWithParam<DataHashValidatorTestCase>; + +// ------------------------------------------------ + +TEST(DataHashHardBindingValidatorTest, MissingAssertion) { + auto input = riegeli::StringReader<std::string>(""); + auto partial_result = std::make_unique< + PartialValidationResultProto>(ParseTextProtoOrDie(R"pb( + active_manifest: { + label: "urn:uuid:1234" + assertions: { + label: "another_data_hash_assertion" + data_hash: { alg: "sha256" hash: "\x11\x22\x33" pad: "\x00\x00" } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:uuid:1234/c2pa.assertions/data_hash_assertion" + )pb")); + + EXPECT_THAT(DataHashHardBindingValidator().Validate( + input, AssetByteInfo(), std::move(partial_result)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("missing assertion"))); +} + +TEST(DataHashHardBindingValidatorTest, NotDataHashAssertion) { + auto input = riegeli::StringReader<std::string>(""); + auto partial_result = std::make_unique< + PartialValidationResultProto>(ParseTextProtoOrDie(R"pb( + active_manifest: { + label: "urn:uuid:1234" + assertions: { + label: "bmff_based_hash_assertion" + bmff_based_hash: { + alg: "some alg", + name: "Example `c2pa.hash.bmff.v2` assertion" + hash: "\x11\x22\x33'" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:uuid:1234/c2pa.assertions/bmff_based_hash_assertion" + )pb")); + + EXPECT_THAT(DataHashHardBindingValidator().Validate( + input, AssetByteInfo(), std::move(partial_result)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("not a data hash assertion"))); +} + +TEST(DataHashHardBindingValidatorTest, InvalidAlgorithm) { + auto input = riegeli::StringReader<std::string>(""); + + auto partial_validation_result = std::make_unique< + PartialValidationResultProto>(ParseTextProtoOrDie(R"pb( + active_manifest { + label: "manifest_label" + assertions { + label: "data_hash_assertion" + data_hash { hash: "\x11\x22\x33" alg: "some algo" } + } + } + hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + )pb")); + EXPECT_THAT( + DataHashHardBindingValidator().Validate( + input, AssetByteInfo(), std::move(partial_validation_result)), + IsOkAndHolds(Pointee(EqualsProtoIgnoringAssertions(R"pb( + active_manifest { + label: "manifest_label" + validation { + failures { + code: "algorithm.unsupported" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + } + } + )pb")))); +} + +TEST(DataHashHardBindingValidatorTest, ClosedReaderRecordsGeneralError) { + auto input = riegeli::StringReader<std::string>(""); + EXPECT_TRUE(input.Close()); + + auto partial_validation_result = std::make_unique< + PartialValidationResultProto>(ParseTextProtoOrDie(R"pb( + active_manifest { + label: "manifest_label" + assertions { + label: "data_hash_assertion" + data_hash { hash: "123" alg: "sha256" } + } + } + hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + )pb")); + EXPECT_THAT( + DataHashHardBindingValidator().Validate( + input, AssetByteInfo(), std::move(partial_validation_result)), + IsOkAndHolds(Pointee(EqualsProtoIgnoringAssertions(R"pb( + active_manifest { + label: "manifest_label" + validation { + failures { + code: "com.google.internal.error" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + explanation: "Asset size cannot be determined" + } + } + } + )pb")))); +} + +TEST(DataHashHardBindingValidatorTest, FailingReaderRecordsGeneralError) { + FailingReader input; + + auto partial_validation_result = std::make_unique< + PartialValidationResultProto>(ParseTextProtoOrDie(R"pb( + active_manifest { + label: "manifest_label" + assertions { + label: "data_hash_assertion" + data_hash { hash: "123" alg: "sha256" } + } + } + hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + )pb")); + EXPECT_THAT( + DataHashHardBindingValidator().Validate( + input, AssetByteInfo(), std::move(partial_validation_result)), + IsOkAndHolds(Pointee(EqualsProtoIgnoringAssertions(R"pb( + active_manifest { + label: "manifest_label" + validation { + failures { + code: "general.error" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + explanation: "Simulated read error; at byte 0" + } + } + } + )pb")))); +} + +TEST(DataHashHardBindingValidatorTest, NoManifestStoreLocation) { + auto input = + riegeli::StringReader<std::string>("adding_some_data_to_the_input"); + + auto partial_validation_result = std::make_unique< + PartialValidationResultProto>(ParseTextProtoOrDie(R"pb( + active_manifest { + label: "manifest_label" + assertions { + label: "data_hash_assertion" + data_hash { + hash: "\x11\x22\x33" + alg: "sha256" + exclusions: { start: 1, length: 4 }, + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + )pb")); + absl::StatusOr<std::unique_ptr<StatusTracker>> tracker = + DualStatusTracker::FromPartialValidationResult( + partial_validation_result.get()); + ABSL_ASSERT_OK(tracker); + DataHashHardBindingValidator().Validate( + input, std::nullopt, + partial_validation_result->active_manifest().assertions(0).data_hash(), + partial_validation_result->hard_binding_uri(), **tracker, + /*assertion_in_ingredient_manifest=*/false); + + EXPECT_THAT( + partial_validation_result->active_manifest().validation(), + EqualsProto(R"pb( + failures { + code: "assertion.dataHash.mismatch" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb")); +} + +TEST(DataHashHardBindingValidatorTest, TestTruncatedWindow) { + auto input = riegeli::StringReader<std::string>("---ab___cd----------"); + + auto partial_validation_result = std::make_unique< + PartialValidationResultProto>(ParseTextProtoOrDie(R"pb( + active_manifest { + label: "manifest_label" + assertions { + label: "data_hash_assertion" + data_hash { + hash: "\x11\x22\x33" + alg: "sha256" + exclusions: { start: 2, length: 3 }, + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + )pb")); + + absl::Base64Unescape("iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk=", + partial_validation_result->mutable_active_manifest() + ->mutable_assertions(0) + ->mutable_data_hash() + ->mutable_hash()); + absl::Base64Unescape("0000", + partial_validation_result->mutable_active_manifest() + ->mutable_assertions(0) + ->mutable_data_hash() + ->mutable_pad()); + + absl::StatusOr<std::unique_ptr<StatusTracker>> tracker = + DualStatusTracker::FromPartialValidationResult( + partial_validation_result.get()); + ABSL_ASSERT_OK(tracker); + DataHashHardBindingValidator().Validate( + input, std::nullopt, + partial_validation_result->active_manifest().assertions(0).data_hash(), + partial_validation_result->hard_binding_uri(), **tracker, + /*assertion_in_ingredient_manifest=*/false, /*start_offset=*/3, + /*end_offset=*/10); + + EXPECT_THAT( + partial_validation_result->active_manifest().validation(), + EqualsProto(R"pb( + successes { + code: "assertion.dataHash.match" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb")); +} + +TEST(DataHashHardBindingValidatorTest, NegativeOffsetAdjustment) { + auto input = + riegeli::StringReader<std::string>("012345678901234567890123456789"); + + auto partial_validation_result = std::make_unique< + PartialValidationResultProto>(ParseTextProtoOrDie(R"pb( + active_manifest { + label: "manifest_label" + assertions { + label: "data_hash_assertion" + data_hash { + hash: "\x11\x22\x33" + alg: "sha256" + exclusions: { start: 10, + length: 10 } # This will set offset_adjustment + exclusions: { start: 1, length: 1 } # This should trigger the failure + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + )pb")); + + absl::StatusOr<std::unique_ptr<StatusTracker>> tracker = + DualStatusTracker::FromPartialValidationResult( + partial_validation_result.get()); + ABSL_ASSERT_OK(tracker); + DataHashHardBindingValidator().Validate( + input, /*manifest_store_location=*/{{.offset = 20, .length = 5}}, + partial_validation_result->active_manifest().assertions(0).data_hash(), + partial_validation_result->hard_binding_uri(), **tracker, + /*assertion_in_ingredient_manifest=*/true, /*start_offset=*/10, + /*end_offset=*/30); + + EXPECT_THAT( + partial_validation_result->active_manifest().validation(), + EqualsProto(R"pb( + failures { + code: "assertion.dataHash.malformed" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb")); +} + +TEST_P(DataHashValidatorTest, ValidateBindingHashData) { + const DataHashValidatorTestCase& test_case = GetParam(); + auto input = riegeli::StringReader<>(test_case.contents); + + auto partial_validation_result = std::make_unique< + PartialValidationResultProto>(ParseTextProtoOrDie(R"pb( + active_manifest { + label: "manifest_label" + assertions { label: "data_hash_assertion" } + } + hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + )pb")); + partial_validation_result->mutable_active_manifest() + ->mutable_assertions(0) + ->mutable_data_hash() + ->MergeFrom(test_case.data_hash_assertion); + if (test_case.hash.has_value()) { + absl::Base64Unescape(*test_case.hash, + partial_validation_result->mutable_active_manifest() + ->mutable_assertions(0) + ->mutable_data_hash() + ->mutable_hash()); + absl::Base64Unescape("0000", + partial_validation_result->mutable_active_manifest() + ->mutable_assertions(0) + ->mutable_data_hash() + ->mutable_pad()); + } + AssetByteInfo asset_byte_info = test_case.asset_byte_info; + ValidationResultProto expected_validation_result = ParseTextProtoOrDie(R"pb( + active_manifest { + label: "manifest_label" + validation {} + } + )pb"); + *expected_validation_result.mutable_active_manifest()->mutable_validation() = + test_case.expected_validation_result; + EXPECT_THAT(DataHashHardBindingValidator().Validate( + input, asset_byte_info, std::move(partial_validation_result)), + IsOkAndHolds(Pointee( + EqualsProtoIgnoringAssertions(expected_validation_result)))); +} + +INSTANTIATE_TEST_SUITE_P( + DataHashHardBindingDataHashValidatorTest, DataHashValidatorTest, + testing::ValuesIn<DataHashValidatorTestCase>({ + { + .test_name = "Sha256", + .contents = "abcd", + .data_hash_assertion = ParseTextProtoOrDie(R"pb( + hash: "\x11\x22\x33" + alg: "sha256" + )pb"), + .hash = "iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk=", + .asset_byte_info = AssetByteInfo(), + .expected_validation_result = ParseTextProtoOrDie(R"pb( + successes { + code: "assertion.dataHash.match" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb"), + }, + { + .test_name = "Sha384", + .contents = "abcd", + .data_hash_assertion = ParseTextProtoOrDie(R"pb( + hash: "\x11\x22\x33" + alg: "sha384" + )pb"), + .hash = "EWWzQG/" + "wtSo9JHIfeFRiyiJ2yfRUoRbCsrogFxp5BepaAmaC62WcTV8RXDY6o8eb", + .asset_byte_info = AssetByteInfo(), + .expected_validation_result = ParseTextProtoOrDie(R"pb( + successes { + code: "assertion.dataHash.match" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb"), + }, + { + .test_name = "Sha512", + .contents = "abcd", + .data_hash_assertion = ParseTextProtoOrDie(R"pb( + hash: "\x11\x22\x33" + alg: "sha512" + )pb"), + .hash = "2AIvIGCtbv0perc9zFNVybIUBUsNF3ahNqZp0mp9OxT3OqDQ6/" + "8Z7jMzaPAWS2QZqW2knj5IF1Pn6Wtxa9zLbw==", + .asset_byte_info = AssetByteInfo(), + .expected_validation_result = ParseTextProtoOrDie(R"pb( + successes { + code: "assertion.dataHash.match" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb"), + }, + { + .test_name = "NoExclusionRange", + .contents = "", + .data_hash_assertion = ParseTextProtoOrDie(R"pb( + hash: "\x11\x22\x33" + alg: "sha256" + pad: "\x00\x00" + )pb"), + .hash = std::nullopt, + .asset_byte_info = AssetByteInfo(), + .expected_validation_result = ParseTextProtoOrDie(R"pb( + failures { + code: "assertion.dataHash.mismatch" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb"), + }, + { + .test_name = "ExcludesManifestStore", + .contents = "a___bcd", + .data_hash_assertion = ParseTextProtoOrDie(R"pb( + hash: "\x11\x22\x33", + alg: "sha256", + exclusions: { start: 1, length: 3 }, + )pb"), + .hash = "iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk=", + .asset_byte_info = {.manifest_store_location = {.offset = 1, + .length = 3}}, + .expected_validation_result = ParseTextProtoOrDie(R"pb( + successes { + code: "assertion.dataHash.match" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb"), + }, + { + .test_name = "ExcludesManifestStoreAndAdditionalExclusions", + .contents = "a-b______c-d", + .data_hash_assertion = ParseTextProtoOrDie(R"pb( + hash: "\x11\x22\x33", + alg: "sha256", + pad: "\x00\x00", + exclusions: { start: 1, length: 1 } + exclusions: { start: 3, length: 6 } + exclusions: { start: 10, length: 1 } + )pb"), + .hash = "iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk=", + .asset_byte_info = {.manifest_store_location = {.offset = 3, + .length = 6}}, + .expected_validation_result = ParseTextProtoOrDie(R"pb( + successes { + code: "assertion.dataHash.match" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + informationals { + code: "assertion.dataHash.additionalExclusionsPresent" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb"), + }, + { + .test_name = "NegativeStartOffset", + .contents = "ab______cd", + .data_hash_assertion = ParseTextProtoOrDie(R"pb( + hash: "\x11\x22\x33", + alg: "sha256", + pad: "\x00\x00", + exclusions: { start: -1, length: 10 } + )pb"), + .hash = std::nullopt, + .asset_byte_info = {.manifest_store_location = {.offset = 2, + .length = 6}}, + .expected_validation_result = ParseTextProtoOrDie(R"pb( + failures { + code: "assertion.dataHash.malformed" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb"), + }, + { + .test_name = "NegativeLength", + .contents = "ab______cd", + .data_hash_assertion = ParseTextProtoOrDie(R"pb( + hash: "\x11\x22\x33", + alg: "sha256", + pad: "\x00\x00", + exclusions: { start: 1, length: -10 } + )pb"), + .hash = std::nullopt, + .asset_byte_info = {.manifest_store_location = {.offset = 2, + .length = 6}}, + .expected_validation_result = ParseTextProtoOrDie(R"pb( + failures { + code: "assertion.dataHash.malformed" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb"), + }, + { + .test_name = "EndsPastEndOfAsset", + .contents = "ab______cd", + .data_hash_assertion = ParseTextProtoOrDie(R"pb( + hash: "\x11\x22\x33", + alg: "sha256", + pad: "\x00\x00", + exclusions: { start: 2, length: 6 } + exclusions: { start: 9, length: 5 } + )pb"), + .hash = std::nullopt, + .asset_byte_info = {.manifest_store_location = {.offset = 2, + .length = 6}}, + .expected_validation_result = ParseTextProtoOrDie(R"pb( + failures { + code: "assertion.dataHash.mismatch" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb"), + }, + { + .test_name = "OverlappingExclusions", + .contents = "ab______cd", + .data_hash_assertion = ParseTextProtoOrDie(R"pb( + hash: "\x11\x22\x33", + alg: "sha256", + pad: "\x00\x00", + exclusions: { start: 1, length: 4 } + exclusions: { start: 2, length: 6 } + )pb"), + .hash = std::nullopt, + .asset_byte_info = {.manifest_store_location = {.offset = 2, + .length = 6}}, + .expected_validation_result = ParseTextProtoOrDie(R"pb( + failures { + code: "assertion.dataHash.malformed" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb"), + }, + { + .test_name = "BackToBackExclusions", + .contents = "ab--______cd", + .data_hash_assertion = ParseTextProtoOrDie(R"pb( + hash: "\x11\x22\x33", + alg: "sha256", + pad: "\x00\x00", + exclusions: { start: 2, length: 1 } + exclusions: { start: 3, length: 1 } + exclusions: { start: 4, length: 6 } + )pb"), + .hash = "iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk=", + .asset_byte_info = {.manifest_store_location = {.offset = 4, + .length = 6}}, + .expected_validation_result = ParseTextProtoOrDie(R"pb( + successes { + code: "assertion.dataHash.match" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + informationals { + code: "assertion.dataHash.additionalExclusionsPresent" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb"), + }, + { + .test_name = "ExclusionsNotInOrder", + .contents = "ab-c______d", + .data_hash_assertion = ParseTextProtoOrDie(R"pb( + hash: "\x11\x22\x33", + alg: "sha256", + pad: "\x00\x00", + exclusions: { start: 3, length: 1 } + exclusions: { start: 2, length: 1 } + exclusions: { start: 4, length: 6 } + )pb"), + .hash = "iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk=", + .asset_byte_info = {.manifest_store_location = {.offset = 4, + .length = 6}}, + .expected_validation_result = ParseTextProtoOrDie(R"pb( + failures { + code: "assertion.dataHash.malformed" + url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion" + } + )pb"), + }, + }), + [](const testing::TestParamInfo<DataHashValidatorTest::ParamType>& info) { + return info.param.test_name; + }); + +} // namespace +} // namespace credentio
diff --git a/bindings/input_hasher_test.cc b/bindings/input_hasher_test.cc index 9839bc5..1291fbf 100644 --- a/bindings/input_hasher_test.cc +++ b/bindings/input_hasher_test.cc
@@ -76,14 +76,14 @@ EXPECT_CALL(*mock_hasher, Digest()).WillOnce(Return("mock_digest")); auto input_hasher = InputHasher::Create(std::move(mock_hasher)); - ASSERT_THAT(input_hasher, IsOk()); + ABSL_ASSERT_OK(input_hasher); (*input_hasher)->Update("test"); EXPECT_THAT((*input_hasher)->Digest(), Eq("mock_digest")); } TEST(InputHasherTest, UpdateStringView) { auto hasher = InputHasher::Create("sha256"); - ASSERT_THAT(hasher, IsOk()); + ABSL_ASSERT_OK(hasher); (*hasher)->Update("hello"); (*hasher)->Update(" "); (*hasher)->Update("world"); @@ -94,7 +94,7 @@ TEST(InputHasherTest, UpdateRiegeliEmpty) { auto hasher = InputHasher::Create("sha256"); - ASSERT_THAT(hasher, IsOk()); + ABSL_ASSERT_OK(hasher); std::string data = ""; riegeli::StringReader reader(data); EXPECT_THAT((*hasher)->Update(reader, 0, 0), IsOk()); @@ -105,7 +105,7 @@ TEST(InputHasherTest, UpdateRiegeliSmall) { auto hasher = InputHasher::Create("sha256"); - ASSERT_THAT(hasher, IsOk()); + ABSL_ASSERT_OK(hasher); std::string data = "small data"; riegeli::StringReader reader(data); EXPECT_THAT((*hasher)->Update(reader, 0, data.length()), IsOk()); @@ -116,7 +116,7 @@ TEST(InputHasherTest, UpdateRiegeliLarge) { auto hasher = InputHasher::Create("sha256"); - ASSERT_THAT(hasher, IsOk()); + ABSL_ASSERT_OK(hasher); std::string data(20000, 'a'); // Larger than default chunk size riegeli::StringReader reader(data); EXPECT_THAT((*hasher)->Update(reader, 0, data.length()), IsOk()); @@ -127,7 +127,7 @@ TEST(InputHasherTest, UpdateRiegeliWithOffset) { auto hasher = InputHasher::Create("sha256"); - ASSERT_THAT(hasher, IsOk()); + ABSL_ASSERT_OK(hasher); std::string data = "prefix_and_data"; riegeli::StringReader reader(data); EXPECT_THAT((*hasher)->Update(reader, 7, 3), IsOk()); // "and" @@ -138,7 +138,7 @@ TEST(InputHasherTest, UpdateRiegeliMultiple) { auto hasher = InputHasher::Create("sha256"); - ASSERT_THAT(hasher, IsOk()); + ABSL_ASSERT_OK(hasher); std::string data1 = "first part"; std::string data2 = " second part"; @@ -156,7 +156,7 @@ TEST(InputHasherTest, UpdateRiegeliClosedReaderFails) { auto hasher = InputHasher::Create("sha256"); - ASSERT_THAT(hasher, IsOk()); + ABSL_ASSERT_OK(hasher); riegeli::StringReader reader("test"); reader.Close(); @@ -167,7 +167,7 @@ TEST(InputHasherTest, UpdateRiegeliOffsetOutOfRange) { auto hasher = InputHasher::Create("sha256"); - ASSERT_THAT(hasher, IsOk()); + ABSL_ASSERT_OK(hasher); riegeli::StringReader reader("test"); EXPECT_THAT( @@ -177,7 +177,7 @@ TEST(InputHasherTest, UpdateRiegeliLengthExceedsSourceFails) { auto hasher = InputHasher::Create("sha256"); - ASSERT_THAT(hasher, IsOk()); + ABSL_ASSERT_OK(hasher); riegeli::StringReader reader("test"); EXPECT_THAT((*hasher)->Update(reader, 0, 100),
diff --git a/bindings/merkle_validator.cc b/bindings/merkle_validator.cc index dc04cbc..c359e64 100644 --- a/bindings/merkle_validator.cc +++ b/bindings/merkle_validator.cc
@@ -237,24 +237,52 @@ std::string computed_hash = std::move(leaf_hash.value()); - uint64_t running_row_index = i; if (!auxiliary_merkle_maps.empty()) { - BmffMerkleMap auxiliary = auxiliary_merkle_maps[i]; - int64_t location = auxiliary.location(); - uint64_t lvl = 0; - int64_t trailing_boundary = (merkle.count() - 1) & ~1LL; - for (const auto& hash : auxiliary.hashes()) { - bool sibling_on_left = (running_row_index % 2 == 1) || - (location >= trailing_boundary && lvl >= 1); - if (sibling_on_left) { - ABSL_ASSIGN_OR_RETURN(computed_hash, - JoinHashes(**factory, hash, computed_hash)); - } else { - ABSL_ASSIGN_OR_RETURN(computed_hash, - JoinHashes(**factory, computed_hash, hash)); + const BmffMerkleMap& auxiliary = auxiliary_merkle_maps[i]; + int64_t current_index = auxiliary.location(); + int64_t current_count = merkle.count(); + int proof_index = 0; + + for (int64_t r = 0; r < derived_data->delta_rows; ++r) { + bool has_sibling = false; + bool sibling_on_left = false; + + if (current_index % 2 == 1) { + has_sibling = true; + sibling_on_left = true; + } else if (current_index + 1 < current_count) { + has_sibling = true; + sibling_on_left = false; } - running_row_index >>= 1; - ++lvl; + + if (has_sibling) { + if (proof_index >= auxiliary.hashes_size()) { + tracker.RecordFailure( + FailureStatusCode::kAssertionBmffHashMalformed, + {.url = assertion_uri_, + .explanation = "missing auxiliary proof hash"}); + return absl::InvalidArgumentError("missing auxiliary proof hash"); + } + absl::string_view hash = auxiliary.hashes(proof_index++); + if (sibling_on_left) { + ABSL_ASSIGN_OR_RETURN(computed_hash, + JoinHashes(**factory, hash, computed_hash)); + } else { + ABSL_ASSIGN_OR_RETURN(computed_hash, + JoinHashes(**factory, computed_hash, hash)); + } + } + + current_index >>= 1; + current_count = (current_count + 1) >> 1; + } + + if (proof_index != auxiliary.hashes_size()) { + tracker.RecordFailure( + FailureStatusCode::kAssertionBmffHashMalformed, + {.url = assertion_uri_, + .explanation = "surplus auxiliary proof hashes"}); + return absl::InvalidArgumentError("surplus auxiliary proof hashes"); } }
diff --git a/bindings/merkle_validator_test.cc b/bindings/merkle_validator_test.cc index 009133e..4f43c6b 100644 --- a/bindings/merkle_validator_test.cc +++ b/bindings/merkle_validator_test.cc
@@ -17,6 +17,7 @@ #include <algorithm> #include <cctype> +#include <cstddef> #include <memory> #include <string> #include <utility> @@ -25,8 +26,11 @@ #include "absl/log/log.h" #include "absl/status/status.h" #include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/escaping.h" #include "absl/strings/str_cat.h" #include "absl/strings/str_format.h" +#include "absl/strings/str_join.h" #include "absl/strings/string_view.h" #include "constants/status_codes.h" #include "crypto/algorithms.h" @@ -170,7 +174,7 @@ void SetUp() override { auto input_reader = std::make_unique<riegeli::StringReader<>>(GetParam().file_contents); - ASSERT_THAT(input_reader->status(), IsOk()); + ABSL_ASSERT_OK(input_reader->status()); input_ = std::move(input_reader); factory_ = std::make_unique<JoiningHasherFactoryProvider>(); @@ -259,6 +263,72 @@ "fragmented merkle validations are not supported yet"}}, }, MerkleValidatorTestCase{ + .name = "NonPowerOfTwoSuccess", + .file_contents = []() -> std::string { + std::string aux_box_1 = cbor::FromJson(R"json({ + "hashes": [ "b64'Yg=='", "b64'Y2Q='", "b64'ZQ=='" ], + "localId": 1, + "location": 0, + "uniqueId": 1 +})json"); + std::string aux_box_2 = cbor::FromJson(R"json({ + "hashes": [ "b64'YQ=='", "b64'Y2Q='", "b64'ZQ=='" ], + "localId": 1, + "location": 1, + "uniqueId": 1 +})json"); + std::string aux_box_3 = cbor::FromJson(R"json({ + "hashes": [ "b64'ZA=='", "b64'YWI='", "b64'ZQ=='" ], + "localId": 1, + "location": 2, + "uniqueId": 1 +})json"); + std::string aux_box_4 = cbor::FromJson(R"json({ + "hashes": [ "b64'Yw=='", "b64'YWI='", "b64'ZQ=='" ], + "localId": 1, + "location": 3, + "uniqueId": 1 +})json"); + std::string aux_box_5 = cbor::FromJson(R"json({ + "hashes": [ "b64'YWJjZA=='" ], + "localId": 1, + "location": 4, + "uniqueId": 1 +})json"); + + return absl::StrCat( + credentio_testing::Box("mdat", "abcde"), + credentio_testing::Box( + "uuid", + credentio_testing::C2paBoxPayload("merkle", aux_box_1)), + credentio_testing::Box( + "uuid", + credentio_testing::C2paBoxPayload("merkle", aux_box_2)), + credentio_testing::Box( + "uuid", + credentio_testing::C2paBoxPayload("merkle", aux_box_3)), + credentio_testing::Box( + "uuid", + credentio_testing::C2paBoxPayload("merkle", aux_box_4)), + credentio_testing::Box( + "uuid", + credentio_testing::C2paBoxPayload("merkle", aux_box_5))); + }(), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { + unique_id: 1 + local_id: 1 + count: 5 + fixed_block_size: 1 + hashes: "abcde" + } + )pb"), + .expected_status = absl::OkStatus(), + .expected_failures = {}, + }, + MerkleValidatorTestCase{ .name = "FailsHashesBelowLeafs", .file_contents = credentio_testing::Box("mdat", "abcdef"), .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( @@ -590,7 +660,7 @@ TEST(DefaultHasherFactoryProviderTest, CreateSha256) { DefaultHasherFactoryProvider provider; auto factory = provider.Create("sha256"); - ASSERT_THAT(factory, IsOk()); + ABSL_ASSERT_OK(factory); EXPECT_THAT(*factory, NotNull()); EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha256); } @@ -598,7 +668,7 @@ TEST(DefaultHasherFactoryProviderTest, CreateSha384) { DefaultHasherFactoryProvider provider; auto factory = provider.Create("sha384"); - ASSERT_THAT(factory, IsOk()); + ABSL_ASSERT_OK(factory); EXPECT_THAT(*factory, NotNull()); EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha384); } @@ -606,7 +676,7 @@ TEST(DefaultHasherFactoryProviderTest, CreateSha512) { DefaultHasherFactoryProvider provider; auto factory = provider.Create("sha512"); - ASSERT_THAT(factory, IsOk()); + ABSL_ASSERT_OK(factory); EXPECT_THAT(*factory, NotNull()); EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha512); } @@ -617,5 +687,135 @@ StatusIs(absl::StatusCode::kInvalidArgument)); } +void TestTreeValidationPermutations(int leaf_count, int delta_rows) { + DefaultHasherFactoryProvider provider; + auto factory_status = provider.Create("sha256"); + ABSL_ASSERT_OK(factory_status); + auto factory = std::move(*factory_status); + + constexpr int kBlockSize = 4; + + std::string mdat_payload; + mdat_payload.reserve(leaf_count * kBlockSize); + for (int i = 0; i < leaf_count; ++i) { + mdat_payload.append(absl::StrFormat("%04d", i)); + } + + std::vector<std::vector<std::string>> rows; + std::vector<std::string> leaves; + leaves.reserve(leaf_count); + for (int i = 0; i < leaf_count; ++i) { + auto hasher = factory->Create(); + ABSL_ASSERT_OK(hasher); + (*hasher)->Update(mdat_payload.substr(i * kBlockSize, kBlockSize)); + leaves.push_back((*hasher)->Digest()); + } + + rows.push_back(std::move(leaves)); + for (int r = 0; r < delta_rows; ++r) { + const auto& current_row = rows.back(); + if (current_row.size() <= 1) break; + std::vector<std::string> next_row; + size_t i = 0; + while (i < current_row.size()) { + if (i + 1 < current_row.size()) { + auto hasher = factory->Create(); + ABSL_ASSERT_OK(hasher); + (*hasher)->Update(current_row[i]); + (*hasher)->Update(current_row[i + 1]); + next_row.push_back((*hasher)->Digest()); + i += 2; + } else { + next_row.push_back(current_row[i]); + i += 1; + } + } + rows.push_back(std::move(next_row)); + } + + int actual_delta_rows = rows.size() - 1; + const std::vector<std::string>& target_row = rows.back(); + + std::string aux_boxes; + if (actual_delta_rows > 0) { + for (int leaf_idx = 0; leaf_idx < leaf_count; ++leaf_idx) { + std::vector<std::string> proof_hashes; + int curr_idx = leaf_idx; + for (int r = 0; r < actual_delta_rows; ++r) { + const auto& row = rows[r]; + if (curr_idx % 2 == 1) { + proof_hashes.push_back(row[curr_idx - 1]); + } else if (curr_idx + 1 < row.size()) { + proof_hashes.push_back(row[curr_idx + 1]); + } + curr_idx /= 2; + } + + std::vector<std::string> hash_entries; + for (const auto& h : proof_hashes) { + hash_entries.push_back( + absl::StrCat("\"b64'", absl::Base64Escape(h), "'\"")); + } + std::string hashes_field = + hash_entries.empty() + ? "" + : absl::StrCat("\"hashes\": [", absl::StrJoin(hash_entries, ", "), + "],"); + + std::string json = absl::StrFormat( + R"json({ + %s + "localId": 1, + "location": %d, + "uniqueId": 1 +})json", + hashes_field, leaf_idx); + + std::string cbor_bytes = cbor::FromJson(json); + absl::StrAppend(&aux_boxes, credentio_testing::Box( + "uuid", credentio_testing::C2paBoxPayload( + "merkle", cbor_bytes))); + } + } + + std::string file_contents = + absl::StrCat(credentio_testing::Box("mdat", mdat_payload), aux_boxes); + + BmffBasedHashAssertion assertion; + assertion.set_alg("sha256"); + assertion.set_hash("unused"); + auto* merkle = assertion.add_merkles(); + merkle->set_unique_id(1); + merkle->set_local_id(1); + merkle->set_count(leaf_count); + merkle->set_fixed_block_size(kBlockSize); + for (const auto& h : target_row) { + merkle->add_hashes(h); + } + + riegeli::StringReader reader(file_contents); + MerkleValidator validator(reader, assertion, "assertion_uri"); + TrackingStatusTracker tracker; + auto status = validator.Validate(tracker); + EXPECT_THAT(status, IsOk()) << "Failed for leaf_count=" << leaf_count + << ", delta_rows=" << delta_rows; + EXPECT_THAT(tracker.failures_, IsEmpty()) + << "Failures recorded for leaf_count=" << leaf_count + << ", delta_rows=" << delta_rows; +} + +TEST(MerkleValidatorTest, ValidatesMerkleTreePermutations) { + const std::vector<int> leaf_counts = {1, 2, 3, 4, 5, 7, 8, 15, 26, 100}; + const std::vector<int> delta_rows_list = {0, 1, 2, 3, 10}; + + for (int leaf_count : leaf_counts) { + for (int delta_rows : delta_rows_list) { + SCOPED_TRACE(absl::StrFormat("leaf_count: %d, delta_rows: %d", leaf_count, + delta_rows)); + TestTreeValidationPermutations(leaf_count, delta_rows); + } + } +} + } // namespace } // namespace credentio
diff --git a/cbor/cbor_generator.cc b/cbor/cbor_generator.cc index 2faba71..e838419 100644 --- a/cbor/cbor_generator.cc +++ b/cbor/cbor_generator.cc
@@ -28,6 +28,7 @@ #include "absl/strings/string_view.h" #include "cbor/tags.pb.h" #include "google/protobuf/compiler/code_generator.h" +#include "google/protobuf/compiler/cpp/names.h" #include "google/protobuf/descriptor.h" #include "google/protobuf/io/printer.h" #include "google/protobuf/io/zero_copy_stream.h" @@ -36,7 +37,7 @@ namespace { std::string QualifiedCppName(const google::protobuf::Descriptor* descriptor) { - return absl::StrReplaceAll(descriptor->full_name(), {{".", "::"}}); + return google::protobuf::compiler::cpp::QualifiedClassName(descriptor); } absl::string_view StripProtoExtension(absl::string_view filename) {
diff --git a/cbor/cbor_generator.h b/cbor/cbor_generator.h index 389ab10..3fbc935 100644 --- a/cbor/cbor_generator.h +++ b/cbor/cbor_generator.h
@@ -42,7 +42,7 @@ } google::protobuf::Edition GetMaximumEdition() const override { - return google::protobuf::Edition::EDITION_2024; + return google::protobuf::Edition::EDITION_2026; } };
diff --git a/cbor/parse_test.cc b/cbor/parse_test.cc index f620b15..90e9e5e 100644 --- a/cbor/parse_test.cc +++ b/cbor/parse_test.cc
@@ -44,20 +44,6 @@ using ::testing::IsFalse; using ::testing::IsTrue; -#ifndef ASSERT_OK_AND_ASSIGN -#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y -#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) - -#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ - ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) - -#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ - auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ - ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ - ::absl_testing::IsOk()); \ - lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) -#endif - TEST(ParseTest, Okay) { auto cbor = FromJson(R"json({ "str": "Google", @@ -175,11 +161,10 @@ auto value = cbor::Parse(bytes_view); ASSERT_TRUE(value.ok()); auto result = std::move(value).value(); - auto map_or = result->AsMap(); - ASSERT_TRUE(map_or.ok()); - auto map = std::move(map_or).value(); - EXPECT_THAT(map.GetString("when"), IsOkAndHolds("2026-05-19T00:00:00Z")); - EXPECT_THAT(map.GetUint64("epoch"), IsOkAndHolds(1684454400)); + absl::StatusOr<cbor::MapView> map = result->AsMap(); + ABSL_ASSERT_OK(map); + EXPECT_THAT(map->GetString("when"), IsOkAndHolds("2026-05-19T00:00:00Z")); + EXPECT_THAT(map->GetUint64("epoch"), IsOkAndHolds(1684454400)); } TEST(ParseTest, OptionalString) { @@ -398,10 +383,13 @@ TEST(ParseTest, TrailingBytesIgnored) { auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json"); - ASSERT_OK_AND_ASSIGN(auto result, cbor::Parse(cbor + "trailing bytes")); - ASSERT_OK_AND_ASSIGN(auto result_map, result->AsMap()); - EXPECT_THAT(result_map.GetString("vendor"), IsOkAndHolds("Google")); - EXPECT_THAT(result_map.GetUint64("schema"), IsOkAndHolds(0)); + absl::StatusOr<std::unique_ptr<cbor::ParseResult>> result = + cbor::Parse(cbor + "trailing bytes"); + ABSL_ASSERT_OK(result); + absl::StatusOr<cbor::MapView> result_map = (*result)->AsMap(); + ABSL_ASSERT_OK(result_map); + EXPECT_THAT(result_map->GetString("vendor"), IsOkAndHolds("Google")); + EXPECT_THAT(result_map->GetUint64("schema"), IsOkAndHolds(0)); } void CheckMap(const cbor::MapView& map, absl::string_view str_key,
diff --git a/cbor/test/BUILD b/cbor/test/BUILD index 1ff1ff3..a61b28b 100644 --- a/cbor/test/BUILD +++ b/cbor/test/BUILD
@@ -30,11 +30,6 @@ srcs = ["external_skip_new.proto"], ) -cc_proto_library( - name = "external_skip_new_cc_proto", - deps = [":external_skip_new_proto"], -) - proto_library( name = "external_skip_proto", srcs = ["external_skip.proto"], @@ -45,11 +40,6 @@ ) cc_proto_library( - name = "external_skip_cc_proto", - deps = [":external_skip_proto"], -) - -cc_proto_library( name = "external_cc_proto", deps = [":external_proto"], ) @@ -105,10 +95,34 @@ ], ) +proto_library( + name = "custom_namespace_proto", + srcs = ["custom_namespace.proto"], + option_deps = ["@protobuf//src/google/protobuf:cpp_options_proto"], + deps = [ + "//cbor:tags_proto", + ], +) + +cc_proto_library( + name = "custom_namespace_cc_proto", + deps = [":custom_namespace_proto"], +) + +cc_cbor_proto_library( + name = "custom_namespace_cbor_proto", + srcs = [":custom_namespace_proto"], + deps = [ + ":custom_namespace_cc_proto", + ], +) + cc_test( name = "cbor_generator_test", srcs = ["cbor_generator_test.cc"], deps = [ + ":custom_namespace_cbor_proto", + ":custom_namespace_cc_proto", ":external_cc_proto", ":migrated_cbor_proto", ":test_cbor_proto",
diff --git a/cbor/test/cbor_generator_test.cc b/cbor/test/cbor_generator_test.cc index 1779716..61f0cb3 100644 --- a/cbor/test/cbor_generator_test.cc +++ b/cbor/test/cbor_generator_test.cc
@@ -21,6 +21,8 @@ #include "absl/status/status_matchers.h" #include "cbor/cbor.h" #include "cbor/options.h" +#include "cbor/test/custom_namespace.cbor.h" +#include "cbor/test/custom_namespace.pb.h" #include "cbor/test/external.pb.h" #include "cbor/test/migrated.cbor.h" // IWYU pragma: keep #include "cbor/test/test.cbor.h" @@ -34,6 +36,28 @@ using ::testing::Eq; +TEST(CborGeneratorTest, CustomNamespaceRoundTrip) { + custom_cbor::test::CustomNamespaceMessage msg; + msg.set_name("custom_name"); + msg.set_version(42); + + // Serialize to CBOR map + auto map_or = cbor::FromProto(msg); + ABSL_ASSERT_OK(map_or); + + auto map = std::move(map_or.value()); + ASSERT_NE(map, nullptr); + + // Deserialize from CBOR map + custom_cbor::test::CustomNamespaceMessage parsed_msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &parsed_msg); + ABSL_ASSERT_OK(status); + + EXPECT_EQ(parsed_msg.name(), "custom_name"); + EXPECT_EQ(parsed_msg.version(), 42); +} + TEST(CborGeneratorTest, RoundTrip) { TestMessage msg; msg.set_name("hello");
diff --git a/cbor/test/custom_namespace.proto b/cbor/test/custom_namespace.proto new file mode 100644 index 0000000..3af81fa --- /dev/null +++ b/cbor/test/custom_namespace.proto
@@ -0,0 +1,29 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +edition = "2026"; + +package cbor_test; + +import "cbor/tags.proto"; + +import option "google/protobuf/cpp_options.proto"; + +option (pb.file.cpp).namespace = "custom_cbor::test"; + +message CustomNamespaceMessage { + string name = 1 [(cbor.tags) = { name: "Name" }]; + int32 version = 2 [(cbor.tags) = { name: "Version" }]; +}
diff --git a/cbor/test/test.proto b/cbor/test/test.proto index ff51b3d..6f54634 100644 --- a/cbor/test/test.proto +++ b/cbor/test/test.proto
@@ -13,7 +13,7 @@ // limitations under the License. // -edition = "2024"; +edition = "2026"; package cbor_test;
diff --git a/claim/BUILD b/claim/BUILD index 82d2821..a945a02 100644 --- a/claim/BUILD +++ b/claim/BUILD
@@ -77,7 +77,6 @@ "//constants:status_codes", "//cose:verifier", "//jumbf:box", - "//proto:assertion_cc_proto", "//proto:cose_verification_result_cc_proto", "//proto:generator_info_cbor_proto", "//proto:generator_info_cc_proto", @@ -86,7 +85,7 @@ "//proto:signature_info_cc_proto", "//uuid", "//validator:tracker", - "@abseil-cpp//absl/log", + "@abseil-cpp//absl/log:absl_log", "@abseil-cpp//absl/log:die_if_null", "@abseil-cpp//absl/status", "@abseil-cpp//absl/status:statusor", @@ -123,3 +122,408 @@ "@googletest//:gtest_main", ], ) + +cc_library( + name = "signer", + hdrs = ["signer.h"], + deps = [ + "//async:async_context", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "local_signer", + srcs = ["local_signer.cc"], + hdrs = ["local_signer.h"], + deps = [ + ":signer", + "//async:async_context", + "//cose:sig_structure", + "//cose:timestamp_creator", + "//crypto:crypto_write_handler", + "//crypto:hash", + "//utils:cbor_padding", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:absl_log", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + ], +) + +cc_test( + name = "local_signer_test", + srcs = ["local_signer_test.cc"], + deps = [ + ":local_signer", + ":signer", + "//async:async_context", + "//cose:sig_structure", + "//cose:verifier", + "//crypto:algorithms", + "//crypto:crypto_read_handler", + "//crypto:crypto_write_handler", + "//crypto:timestamp_write_handler", + "//crypto/default:default_crypto_read_handler", + "//crypto/default:hasher", + "//proto:cose_verification_result_cc_proto", + "//proto:signature_info_cc_proto", + "//testing:create_test_crypto_write_handler", + "//testing:local_key_crypto_write_handler", + "//testing:proto_test_utils", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@googletest//:gtest_main", + "@protobuf//:differencer", + ], +) + +cc_library( + name = "manifest_store_builder", + srcs = ["manifest_store_builder.cc"], + hdrs = ["manifest_store_builder.h"], + deps = [ + ":manifest_store_view", + "//constants:labels", + "//crypto:hash", + "//jumbf:box", + "//jumbf:box_builder", + "//jumbf:encode_params", + "//jumbf:utils", + "//proto:hashed_uri_cc_proto", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "manifest_store_builder_test", + srcs = ["manifest_store_builder_test.cc"], + deps = [ + ":manifest_store_builder", + "//assertion:assertion_encoder", + "//constants:labels", + "//crypto:algorithms", + "//crypto:hash", + "//crypto/default:hasher", + "//jumbf:box", + "//jumbf:box_builder", + "//jumbf:encode_params", + "//jumbf:parse", + "//jumbf:test_utils", + "//proto:assertion_cc_proto", + "//proto:hashed_uri_cc_proto", + "//testing:proto_test_utils", + "//uuid", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "manifest_box", + hdrs = ["manifest_box.h"], + deps = [ + ":manifest_store_builder", + "//proto:assertion_cc_proto", + "//proto:generator_cc_proto", + ], +) + +cc_library( + name = "manifest_box_encoder", + srcs = ["manifest_box_encoder.cc"], + hdrs = ["manifest_box_encoder.h"], + deps = [ + ":claim_cbor_encoder", + ":manifest_box", + ":signer", + "//assertion:assertion_encoder", + "//assertion:assertion_referencer", + "//async:async_context", + "//constants:labels", + "//crypto:algorithms", + "//jumbf:box_builder", + "//jumbf:constants", + "//proto:actions_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//proto:generator_info_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:manifest_cc_proto", + "//proto:multi_asset_hash_assertion_cc_proto", + "//uuid", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@protobuf//:protobuf_lite", + ], +) + +cc_test( + name = "manifest_box_encoder_test", + srcs = ["manifest_box_encoder_test.cc"], + deps = [ + ":claim_cbor_encoder", + ":local_signer", + ":manifest_box", + ":manifest_box_encoder", + ":manifest_store_builder", + ":signer", + "//assertion:assertion_encoder", + "//assertion:assertion_referencer", + "//async:async_context", + "//constants:labels", + "//crypto:algorithms", + "//crypto:crypto_read_handler", + "//crypto:crypto_write_handler", + "//crypto:hash", + "//crypto/default:default_crypto_read_handler", + "//crypto/default:hasher", + "//jumbf:box_builder", + "//proto:actions_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//proto:generator_cc_proto", + "//proto:generator_info_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:manifest_cc_proto", + "//proto:signature_info_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:create_test_crypto_write_handler", + "//testing:local_key_crypto_write_handler", + "//testing:manifest_store_test_utils", + "//testing:proto_test_utils", + "//uuid", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@googletest//:gtest_main", + "@protobuf//:differencer", + ], +) + +cc_library( + name = "timestamp_updater", + srcs = ["timestamp_updater.cc"], + hdrs = ["timestamp_updater.h"], + deps = [ + ":manifest_store_view", + "//async:async_context", + "//cose:sig_structure", + "//cose:timestamp_creator", + "//cose:timestamp_updater", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "timestamp_updater_test", + srcs = ["timestamp_updater_test.cc"], + deps = [ + ":timestamp_updater", + "//async:async_context", + "//constants:labels", + "//cose:sig_structure", + "//cose:timestamp_creator", + "//crypto:crypto_read_handler", + "//crypto:hash", + "//crypto:timestamp_write_handler", + "//crypto/default:default_crypto_read_handler", + "//crypto/default:hasher", + "//jumbf:test_utils", + "//proto:validation_result_cc_proto", + "//testing:create_test_crypto_write_handler", + "//testing:manifest_store_test_utils", + "//validator:manifest_store_validator_impl", + "//validator:validator_options", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/time:clock_interface", + "@abseil-cpp//absl/time:simulated_clock", + "@googletest//:gtest_main", + "@protobuf//:timestamp_cc_proto", + ], +) + +cc_library( + name = "manifest_box_assembler", + srcs = ["manifest_box_assembler.cc"], + hdrs = ["manifest_box_assembler.h"], + deps = [ + ":manifest_box", + ":manifest_store_builder", + "//constants:ingredient_relationships", + "//constants:labels", + "//constants:spec_version", + "//crypto:hash", + "//proto:actions_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:collection_data_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:generator_cc_proto", + "//proto:generator_info_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:ingredient_validation_result_cc_proto", + "//proto:multi_asset_hash_assertion_cc_proto", + "//utils:cbor_padding", + "//uuid", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "manifest_box_assembler_test", + srcs = ["manifest_box_assembler_test.cc"], + deps = [ + ":manifest_box", + ":manifest_box_assembler", + ":manifest_store_builder", + "//constants:labels", + "//constants:spec_version", + "//crypto/default:hasher", + "//jumbf:test_utils", + "//proto:assertion_cc_proto", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:generator_cc_proto", + "//proto:generator_info_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:ingredient_validation_result_cc_proto", + "//proto:manifest_cc_proto", + "//proto:signature_info_cc_proto", + "//testing:proto_test_utils", + "//uuid", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "generator", + srcs = ["generator.cc"], + hdrs = ["generator.h"], + deps = [ + ":claim_cbor_encoder", + ":manifest_box", + ":manifest_box_assembler", + ":manifest_box_encoder", + ":signer", + "//assertion:assertion_encoder", + "//assertion:assertion_referencer", + "//async:async_context", + "//constants:spec_version", + "//crypto:hash", + "//proto:actions_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:generator_cc_proto", + "//proto:generator_info_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:manifest_cc_proto", + "//uuid", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + ], +) + +cc_test( + name = "generator_test", + srcs = ["generator_test.cc"], + deps = [ + ":claim_cbor_encoder", + ":generator", + ":local_signer", + ":manifest_box", + ":manifest_box_assembler", + ":manifest_box_encoder", + ":manifest_store_builder", + ":signer", + "//assertion:assertion_encoder", + "//async:async_context", + "//crypto:crypto_read_handler", + "//crypto:crypto_write_handler", + "//crypto/default:default_crypto_read_handler", + "//crypto/default:hasher", + "//jumbf:box_builder", + "//proto:assertion_cc_proto", + "//proto:generator_cc_proto", + "//proto:generator_info_cc_proto", + "//proto:manifest_cc_proto", + "//proto:signature_info_cc_proto", + "//proto:validation_result_cc_proto", + "//testing:create_test_crypto_write_handler", + "//testing:manifest_store_test_utils", + "//testing:proto_test_utils", + "//uuid", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@googletest//:gtest_main", + "@protobuf//:differencer", + ], +)
diff --git a/claim/claim_cbor_encoder.cc b/claim/claim_cbor_encoder.cc index 2e1379a..2c950d3 100644 --- a/claim/claim_cbor_encoder.cc +++ b/claim/claim_cbor_encoder.cc
@@ -30,8 +30,7 @@ class StandardClaimCborEncoder : public ClaimCborEncoder { public: - explicit StandardClaimCborEncoder(bool skip_validity_checks_for_test = false) - : options_({.skip_validation = skip_validity_checks_for_test}) {} + StandardClaimCborEncoder() = default; ~StandardClaimCborEncoder() override = default; absl::StatusOr<std::string> ToCbor(const Claim& claim) const override { @@ -45,17 +44,9 @@ } // namespace -const ClaimCborEncoder& GetClaimCborEncoder( - bool skip_validity_checks_for_test) { - if (skip_validity_checks_for_test) { - static const absl::NoDestructor<StandardClaimCborEncoder> encoder_for_test( - /*skip_validity_checks_for_test=*/true); - return *encoder_for_test; - } else { - static const absl::NoDestructor<StandardClaimCborEncoder> encoder( - /*skip_validity_checks_for_test=*/false); - return *encoder; - } +const ClaimCborEncoder& GetClaimCborEncoder() { + static const absl::NoDestructor<StandardClaimCborEncoder> encoder; + return *encoder; } } // namespace credentio
diff --git a/claim/claim_cbor_encoder.h b/claim/claim_cbor_encoder.h index d54c3f9..082bf8a 100644 --- a/claim/claim_cbor_encoder.h +++ b/claim/claim_cbor_encoder.h
@@ -32,7 +32,7 @@ }; // Returns a standard singleton ClaimCborEncoder. -const ClaimCborEncoder& GetClaimCborEncoder(bool skip_validity_checks_for_test); +const ClaimCborEncoder& GetClaimCborEncoder(); } // namespace credentio
diff --git a/claim/generator.cc b/claim/generator.cc new file mode 100644 index 0000000..1417c1a --- /dev/null +++ b/claim/generator.cc
@@ -0,0 +1,121 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "claim/generator.h" + +#include <memory> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "assertion/assertion_encoder.h" +#include "assertion/assertion_referencer.h" +#include "async/async_context.h" +#include "claim/claim_cbor_encoder.h" +#include "claim/manifest_box.h" +#include "claim/manifest_box_assembler.h" +#include "claim/manifest_box_encoder.h" +#include "claim/signer.h" +#include "crypto/hash.h" +#include "proto/actions_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/generator.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/manifest.pb.h" + +namespace credentio { +namespace { + +ClaimSigner::Options ToClaimSignerOptions( + const GenerateManifestStoreOptions& options, AsyncContext context) { + ClaimSigner::Options result; + result.async_context = std::move(context); + switch (options.time_stamping_mode()) { + case GenerateManifestStoreOptions::TIME_STAMPING_REQUIRED: + result.time_stamping_mode = TimeStampingMode::kRequired; + break; + case GenerateManifestStoreOptions::TIME_STAMPING_OMITTED_WITH_PADDING: + result.time_stamping_mode = TimeStampingMode::kOmittedWithPadding; + break; + case GenerateManifestStoreOptions::TIME_STAMPING_OPTIONAL: + case GenerateManifestStoreOptions::TIME_STAMPING_MODE_UNSPECIFIED: + default: + result.time_stamping_mode = TimeStampingMode::kOptional; + break; + } + return result; +} + +class ManifestEncoderImpl : public ManifestEncoder { + public: + explicit ManifestEncoderImpl( + GeneratorInfo generator_info, + const HasherFactory* absl_nonnull hash_factory, + ClaimSigner* absl_nonnull claim_signer, + const AssertionEncoder* absl_nonnull assertion_cbor_encoder, + const ClaimCborEncoder* absl_nonnull claim_cbor_encoder) + : generator_info_(std::move(generator_info)), + hash_factory_(*ABSL_DIE_IF_NULL(hash_factory)), + claim_signer_(*ABSL_DIE_IF_NULL(claim_signer)), + assertion_cbor_encoder_(*ABSL_DIE_IF_NULL(assertion_cbor_encoder)), + claim_cbor_encoder_(*ABSL_DIE_IF_NULL(claim_cbor_encoder)) {} + + void Encode(ManifestBox* absl_nonnull manifest, + const GenerateManifestStoreOptions& options, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<ManifestEncoderResult>) &&> + cb) const override { + auto assertion_reference = + std::make_shared<AssertionReferencer>(&hash_factory_); + auto manifest_box_encoder = std::make_shared<ManifestBoxEncoder>( + assertion_reference.get(), &generator_info_, &claim_signer_, + &assertion_cbor_encoder_, &claim_cbor_encoder_, manifest, + hash_factory_.algorithm()); + manifest_box_encoder->Encode( + ToClaimSignerOptions(options, std::move(context)), + [assertion_reference, manifest_box_encoder, cb = std::move(cb)]( + absl::StatusOr<ManifestEncoderResult> result) mutable { + std::move(cb)(std::move(result)); + }); + } + + private: + GeneratorInfo generator_info_; + const HasherFactory& hash_factory_; + ClaimSigner& claim_signer_; + const AssertionEncoder& assertion_cbor_encoder_; + const ClaimCborEncoder& claim_cbor_encoder_; +}; +} // namespace + +absl::StatusOr<std::unique_ptr<ManifestBoxAssembler>> CreateManifestAssembler( + const CreateManifestAssemblerOptions& options) { + return std::make_unique<StandardManifestBoxAssembler>( + options.uuid_generator, options.hash_factory, options.spec_version); +} + +absl::StatusOr<std::unique_ptr<ManifestEncoder>> CreateManifestEncoder( + const CreateManifestEncoderOptions& options) { + return std::make_unique<ManifestEncoderImpl>( + options.generator_info, options.hash_factory, options.claim_signer, + options.assertion_cbor_encoder, options.claim_cbor_encoder); +} +} // namespace credentio
diff --git a/claim/generator.h b/claim/generator.h new file mode 100644 index 0000000..a2f59d5 --- /dev/null +++ b/claim/generator.h
@@ -0,0 +1,88 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_CLAIM_GENERATOR_H_ +#define THIRD_PARTY_CREDENTIO_CLAIM_GENERATOR_H_ + +#include <memory> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/statusor.h" +#include "assertion/assertion_encoder.h" +#include "async/async_context.h" +#include "claim/claim_cbor_encoder.h" +#include "claim/manifest_box.h" +#include "claim/manifest_box_assembler.h" +#include "claim/manifest_box_encoder.h" +#include "claim/signer.h" +#include "constants/spec_version.h" +#include "crypto/hash.h" +#include "proto/generator.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/manifest.pb.h" +#include "uuid/uuid.h" + +namespace credentio { + +// An abstract class to serialize a Manifest in JUMBF format as a Manifest +// Store SuperBox. +class ManifestEncoder { + public: + virtual ~ManifestEncoder() = default; + // Encodes the given Manifest to a JUMBF-type box. The manifest will be + // wrapped in a Manifest Store SuperBox. Consumes the provided ManifestBox, + // which must outlive the task. + virtual void Encode( + ManifestBox* absl_nonnull manifest, + const GenerateManifestStoreOptions& options, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<ManifestEncoderResult>) &&> cb) + const = 0; +}; + +struct CreateManifestAssemblerOptions { + // Required. + const UuidGenerator* absl_nonnull uuid_generator; + // Required. + const HasherFactory* absl_nonnull hash_factory; + // The C2PA specification version the generated manifests conform to. + SpecVersion spec_version = SpecVersion::kC2pa_2_2; +}; + +using ManifestAssemblerFactory = + absl::AnyInvocable<absl::StatusOr<std::unique_ptr<ManifestBoxAssembler>>( + const CreateManifestAssemblerOptions& options) const>; + +absl::StatusOr<std::unique_ptr<ManifestBoxAssembler>> CreateManifestAssembler( + const CreateManifestAssemblerOptions& options); + +struct CreateManifestEncoderOptions { + // Required. + GeneratorInfo generator_info; + // Required. + ClaimSigner* absl_nonnull claim_signer; + // Required. + const AssertionEncoder* absl_nonnull assertion_cbor_encoder; + // Required. + const ClaimCborEncoder* absl_nonnull claim_cbor_encoder; + // Required. + const HasherFactory* absl_nonnull hash_factory; +}; + +absl::StatusOr<std::unique_ptr<ManifestEncoder>> CreateManifestEncoder( + const CreateManifestEncoderOptions& options); +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CLAIM_GENERATOR_H_
diff --git a/claim/generator_test.cc b/claim/generator_test.cc new file mode 100644 index 0000000..8484500 --- /dev/null +++ b/claim/generator_test.cc
@@ -0,0 +1,864 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "claim/generator.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/log/absl_check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "assertion/assertion_encoder.h" +#include "async/async_context.h" +#include "claim/claim_cbor_encoder.h" +#include "claim/local_signer.h" +#include "claim/manifest_box.h" +#include "claim/manifest_box_assembler.h" +#include "claim/manifest_box_encoder.h" +#include "claim/manifest_store_builder.h" +#include "claim/signer.h" +#include "crypto/crypto_read_handler.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/default/default_crypto_read_handler.h" +#include "crypto/default/hasher.h" +#include "gmock/gmock.h" +#include "google/protobuf/util/message_differencer.h" +#include "gtest/gtest.h" +#include "jumbf/box_builder.h" +#include "proto/assertion.pb.h" +#include "proto/generator.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/manifest.pb.h" +#include "proto/signature_info.pb.h" +#include "proto/validation_result.pb.h" +#include "testing/create_test_crypto_write_handler.h" +#include "testing/manifest_store_test_utils.h" +#include "testing/proto_test_utils.h" +#include "uuid/uuid.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::credentio_testing::PartiallyEqualsProto; +using ::testing::HasSubstr; +using ::testing::Pointee; +using ::testing::Pointwise; + +MATCHER_P(EqualsProtoIgnoringTimestampAndOrdering, expected, "") { + google::protobuf::util::MessageDifferencer differencer; + differencer.set_repeated_field_comparison( + google::protobuf::util::MessageDifferencer::AS_SET); + differencer.IgnoreField( + SignatureInfo::descriptor()->FindFieldByName("timestamping_time")); + std::string diff; + differencer.ReportDifferencesToString(&diff); + bool match = differencer.Compare(expected, arg); + if (!match) { + *result_listener << "\nDifference:\n" << diff; + } + return match; +} + +constexpr Uuid kUuid = + Uuid::FromStringOrDie("03020100-0504-0706-0908-0f0e0d0c0b0a"); + +class FakeUuidGenerator : public UuidGenerator { + public: + Uuid Generate() const override { return kUuid; } +}; + +class ManifestAssemblerTest : public testing::Test { + protected: + ManifestAssemblerTest() { + absl::StatusOr<std::unique_ptr<ManifestBoxAssembler>> manifest_generator = + CreateManifestAssembler({.uuid_generator = &uuid_generator_, + .hash_factory = &hash_factory_}); + ABSL_CHECK_OK(manifest_generator); + manifest_assembler_ = std::move(*manifest_generator); + } + FakeUuidGenerator uuid_generator_; + Sha256HasherFactory hash_factory_; + std::unique_ptr<ManifestBoxAssembler> manifest_assembler_; +}; + +TEST_F(ManifestAssemblerTest, NoHardBindingInvalid) { + GenerateManifestStoreRequest request; + EXPECT_THAT( + manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("hard binding"))); +} + +TEST_F(ManifestAssemblerTest, PopulateBmffBasedHardBinding) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { + bmff_based_hash { + exclusions { xpath: "" } + alg: "some alg" + hash: "some hash" + } + } + actions { created {} } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { + exclusions { xpath: "" } + alg: "some alg" + hash: "some hash" + } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + absl::StatusOr<ManifestBox> manifest_box = + manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box); + EXPECT_EQ(manifest_box->label, expected.label); + EXPECT_THAT(manifest_box->hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box->actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box->ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, PopulateActionsAssertionWithAllActionsIncluded) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { + bmff_based_hash { + exclusions { xpath: "" } + alg: "some alg" + hash: "some hash" + } + } + actions { + created {} + all_actions_included: true + } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { + exclusions { xpath: "" } + alg: "some alg" + hash: "some hash" + } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + all_actions_included: true + actions { action: "c2pa.created" } + } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + absl::StatusOr<ManifestBox> manifest_box = + manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box); + EXPECT_EQ(manifest_box->label, expected.label); + EXPECT_THAT(manifest_box->hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box->actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box->ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, PopulateBoxesBasedHardBinding) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { + box_hash { + alg: "some alg" + boxes { names: "some name" names: "other name" hash: "some hash" } + } + } + actions { created {} } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.boxes" + boxes_hash { + alg: "some alg" + boxes { + names: "some name" + names: "other name" + hash: "some hash" + pad: "" + pad2: "" + } + } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + absl::StatusOr<ManifestBox> manifest_box = + manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box); + EXPECT_EQ(manifest_box->label, expected.label); + EXPECT_THAT(manifest_box->hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box->actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box->ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, PopulateDataBasedHardBinding) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { + data_hash { + alg: "some alg" + hash: "some hash" + exclusions { start: 1 length: 10 } + } + } + actions { created {} } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.data" + data_hash { + alg: "some alg" + hash: "some hash" + exclusions { start: 1 length: 10 } + pad: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + absl::StatusOr<ManifestBox> manifest_box = + manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box); + EXPECT_EQ(manifest_box->label, expected.label); + EXPECT_THAT(manifest_box->hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box->actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box->ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, UnspecifiedInceptionActionInvalid) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash { exclusions { xpath: "" } } } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("missing inception action"))); +} + +TEST_F(ManifestAssemblerTest, PlacedAction) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash { exclusions { xpath: "" } } } + actions { + created {} + actions { placed { ingredient_indexes: 0 } } + } + ingredients { metadata { title: "ingredient0" } } + )pb"); + absl::StatusOr<ManifestBox> manifest_box = + manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box); + EXPECT_THAT(manifest_box->actions_assertion, + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.actions.v2" + actions { + actions { action: "c2pa.created" } + actions { action: "c2pa.placed" } + } + )pb"))); +} + +TEST_F(ManifestAssemblerTest, HasCreatedAction) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash { exclusions { xpath: "" } } } + actions { + created { + action_metadata { + software_agent {} + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + software_agent {} + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + absl::StatusOr<ManifestBox> manifest_box = + manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box); + EXPECT_EQ(manifest_box->label, expected.label); + EXPECT_THAT(manifest_box->hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box->actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box->ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, OpenedActionWithoutIngredientIndex) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash { exclusions { xpath: "" } } } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + } + } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("requires an ingredient index."))); +} + +TEST_F(ManifestAssemblerTest, OpenedActionWithInvalidIngredientIndex) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash { exclusions { xpath: "" } } } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + ingredient_index: 1 + } + } + ingredients { metadata { title: "test ingredient" } } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("ingredient index out of range"))); +} + +TEST_F(ManifestAssemblerTest, OpenedActionWithUnreferencedIngredient) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash { exclusions { xpath: "" } } } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + ingredient_index: 1 + } + } + ingredients { metadata { title: "test ingredient" } } + ingredients { metadata { title: "test ingredient at index 1" } } + )pb"); + EXPECT_THAT( + manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("ingredient is not referenced by any action"))); +} + +TEST_F(ManifestAssemblerTest, HasSimpleOpenedAction) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash { exclusions { xpath: "" } } } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + ingredient_index: 0 + } + } + ingredients { metadata { title: "test ingredient" } } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + software_agent {} + description: "opened for testing" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + )pb"), + .ingredient_assertions = {ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { title: "test ingredient" relationship: "parentOf" } + )pb")}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + absl::StatusOr<ManifestBox> manifest_box = + manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box); + EXPECT_EQ(manifest_box->label, expected.label); + EXPECT_THAT(manifest_box->hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box->actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box->ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, HasOpenedAction) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash { exclusions { xpath: "" } } } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + ingredient_index: 0 + } + } + ingredients { + metadata { + title: "test ingredient" + media_type: "video/mp4" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + software_agent {} + description: "opened for testing" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + )pb"), + .ingredient_assertions = {ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb")}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + absl::StatusOr<ManifestBox> manifest_box = + manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box); + EXPECT_EQ(manifest_box->label, expected.label); + EXPECT_THAT(manifest_box->hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box->actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box->ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +absl::StatusOr<jumbf::BuiltSuperBox> Encode(ManifestEncoder& manifest_encoder, + ManifestBox& manifest_box) { + absl::Notification done; + absl::StatusOr<ManifestEncoderResult> result; + GenerateManifestStoreOptions options; + manifest_encoder.Encode( + &manifest_box, options, AsyncContext(), + [&result, &done](absl::StatusOr<ManifestEncoderResult> res) { + result = std::move(res); + done.Notify(); + }); + done.WaitForNotification(); + if (!result.ok()) { + return result.status(); + } + return std::move(result->box); +} + +class ManifestEncoderTest : public testing::Test { + protected: + ManifestEncoderTest() { + generator_info_.set_name("Test generator"); + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> crypto_writer = + CreateTestCryptoWriteHandler({}); + ABSL_CHECK_OK(crypto_writer); + crypto_writer_ = std::move(*crypto_writer); + absl::StatusOr<std::unique_ptr<CryptoReadHandler>> crypto_reader = + CreateDefaultCryptoReadHandler({ + .trust_environment = CryptoReadHandler::TrustEnvironment::kAny, + }); + ABSL_CHECK_OK(crypto_reader); + crypto_reader_ = std::move(*crypto_reader); + claim_signer_ = std::make_unique<LocalClaimSigner>(crypto_writer_.get(), + &hash_factory_); + absl::StatusOr<std::unique_ptr<ManifestEncoder>> manifest_encoder = + CreateManifestEncoder({ + .generator_info = generator_info_, + .claim_signer = claim_signer_.get(), + .assertion_cbor_encoder = &GetAssertionEncoder(), + .claim_cbor_encoder = &GetClaimCborEncoder(), + .hash_factory = &hash_factory_, + }); + ABSL_CHECK_OK(manifest_encoder); + manifest_encoder_ = std::move(*manifest_encoder); + } + std::unique_ptr<CryptoWriteHandler> crypto_writer_; + std::unique_ptr<CryptoReadHandler> crypto_reader_; + std::unique_ptr<ClaimSigner> claim_signer_; + GeneratorInfo generator_info_; + Sha256HasherFactory hash_factory_; + std::unique_ptr<ManifestEncoder> manifest_encoder_; +}; + +TEST_F(ManifestEncoderTest, EncodeManifest) { + ManifestBox manifest_box = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "12345678-1234-1234-1234-123456789012", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + software_agent { name: "test generator" } + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + Claim expected_claim = ParseTextProtoOrDie(R"pb( + instance_id: "12345678-1234-1234-1234-123456789012" + claim_generator_info { name: "Test generator" } + signature_info { + cert_serial_number: "02" + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA" + timestamping_time {} + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3" + algorithm: "sha256" + hash: "\016^\204(\332\347\371\222!\343x\220d\3607&7\237\332\211ps\004\231\222q\362\021\2261\330\261" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\331\271\373\3228\310\335>y\263\334\342\335\367\211Q\360\325\227\r\345$]\214\005\3047[\r#1\312" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + )pb"); + PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie( + R"pb( + active_manifest { + label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + claim { + claim_generator_info { name: "Test generator" } + signature_info { + cert_serial_number: "02" + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA" + timestamping_time {} + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3" + algorithm: "sha256" + hash: "\016^\204(\332\347\371\222!\343x\220d\3607&7\237\332\211ps\004\231\222q\362\021\2261\330\261" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\331\271\373\3228\310\335>y\263\334\342\335\367\211Q\360\325\227\r\345$]\214\005\3047[\r#1\312" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "12345678-1234-1234-1234-123456789012" + } + assertions { + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + software_agent { name: "test generator" } + } + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + )pb"); + absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box = + Encode(*manifest_encoder_, manifest_box); + ABSL_ASSERT_OK(built_manifest_store_box); + absl::Cord result_manifest_store_bytes = + std::move(*built_manifest_store_box).AsCord(); + EXPECT_THAT( + ValidateClaim(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(EqualsProtoIgnoringTimestampAndOrdering(expected_claim))); + EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(Pointee(EqualsProtoIgnoringTimestampAndOrdering( + expected_validated_result)))); +} + +TEST_F(ManifestEncoderTest, EncodeManifestWithAllActionsIncluded) { + ManifestBox manifest_box = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "12345678-1234-1234-1234-123456789012", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + all_actions_included: true + actions { + action: "c2pa.created" + software_agent { name: "test generator" } + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + Claim expected_claim = ParseTextProtoOrDie(R"pb( + instance_id: "12345678-1234-1234-1234-123456789012" + claim_generator_info { name: "Test generator" } + signature_info { + cert_serial_number: "02" + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA" + timestamping_time {} + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3" + algorithm: "sha256" + hash: "\016^\204(\332\347\371\222!\343x\220d\3607&7\237\332\211ps\004\231\222q\362\021\2261\330\261" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\332\t\376b\010\364\"\024\206\261\340\007Y\255<\361\257\277A\205Q\371\251k\004\227\202\004Ie\237\034" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + )pb"); + PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie( + R"pb( + active_manifest { + label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + claim { + claim_generator_info { name: "Test generator" } + signature_info { + cert_serial_number: "02" + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA" + timestamping_time {} + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3" + algorithm: "sha256" + hash: "\016^\204(\332\347\371\222!\343x\220d\3607&7\237\332\211ps\004\231\222q\362\021\2261\330\261" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\332\t\376b\010\364\"\024\206\261\340\007Y\255<\361\257\277A\205Q\371\251k\004\227\202\004Ie\237\034" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "12345678-1234-1234-1234-123456789012" + } + assertions { + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + } + assertions { + label: "c2pa.actions.v2" + actions { + all_actions_included: true + actions { + action: "c2pa.created" + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + software_agent { name: "test generator" } + } + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + )pb"); + absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box = + Encode(*manifest_encoder_, manifest_box); + ABSL_ASSERT_OK(built_manifest_store_box); + absl::Cord result_manifest_store_bytes = + std::move(*built_manifest_store_box).AsCord(); + EXPECT_THAT( + ValidateClaim(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(EqualsProtoIgnoringTimestampAndOrdering(expected_claim))); + EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(Pointee(EqualsProtoIgnoringTimestampAndOrdering( + expected_validated_result)))); +} + +} // namespace +} // namespace credentio
diff --git a/claim/local_signer.cc b/claim/local_signer.cc new file mode 100644 index 0000000..896ad7e --- /dev/null +++ b/claim/local_signer.cc
@@ -0,0 +1,175 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "claim/local_signer.h" + +#include <cstddef> +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/functional/any_invocable.h" +#include "absl/log/absl_log.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/time/clock.h" +#include "absl/time/time.h" +#include "async/async_context.h" +#include "claim/signer.h" +#include "cose/sig_structure.h" +#include "cose/timestamp_creator.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/hash.h" +#include "utils/cbor_padding.h" + +namespace credentio { + +namespace { + +absl::StatusOr<std::string> FinalizeSignature( + const absl::StatusOr<std::string>& timestamp, + TimeStampingMode time_stamping_mode, uint64_t max_timestamp_byte_size, + CoseSign1TaggedStructure* cose_sign1, uint64_t max_signature_byte_size, + std::optional<size_t> pad_to_size) { + if (!timestamp.ok() && time_stamping_mode == TimeStampingMode::kRequired) { + return absl::Status(timestamp.status().code(), + absl::StrCat("timestamping is required but failed: ", + timestamp.status().message())); + } + if (timestamp.ok()) { + if (timestamp.value().size() > max_timestamp_byte_size) { + if (time_stamping_mode == TimeStampingMode::kRequired) { + return absl::InternalError( + absl::StrCat("Timestamp size ", timestamp.value().size(), + " is larger than the maximum allowed size ", + max_timestamp_byte_size)); + } else { + ABSL_LOG_EVERY_N_SEC(WARNING, 60) + << "Ignoring timestamp because the timestamp size " + << timestamp.value().size() + << " is larger than the maximum allowed size " + << max_timestamp_byte_size; + } + } else if (!timestamp.value().empty()) { + cose_sign1->unprotected_header.sig_tst2 = + TstContainer{.tst_tokens = {{.val = timestamp.value()}}}; + } + } + if (pad_to_size.has_value()) { + // This logic is necessary for remote signing fallback to local where + // the template and final signatures must be padded to the same size. + // e.g. template uses remote signing but final uses local signing. + ABSL_RETURN_IF_ERROR( + PadCoseSign1TaggedStructure(*cose_sign1, *pad_to_size)); + } else { + ABSL_RETURN_IF_ERROR(PadCoseSign1TaggedStructure( + *cose_sign1, max_signature_byte_size, max_timestamp_byte_size)); + } + return EncodeCoseSign1TaggedStructure(*cose_sign1); +} + +} // namespace + +void LocalClaimSigner::Sign( + absl::string_view claim, Options options, + absl::AnyInvocable<void(absl::StatusOr<SignResult>) &&> cb) const { + AsyncResolver<SignResult> resolver(options.async_context, std::move(cb)); + + auto& signer = crypto_writer_.GetSigner(); + auto protected_header_res = EncodeProtectedHeader( + ProtectedHeader{.alg = signer.algorithm(), + .certificate_chain = signer.certificate_chain()}); + if (!protected_header_res.ok()) { + resolver.Return(protected_header_res.status()); + return; + } + std::string protected_header = *std::move(protected_header_res); + + Sig1Structure sig_structure{ + .context = "Signature1", + .body_protected = std::move(protected_header), + .payload = std::string(claim), + }; + auto sign_res = signer.Sign(EncodeSig1Structure(sig_structure)); + if (!sign_res.ok()) { + resolver.Return(sign_res.status()); + return; + } + if (sign_res->signature.size() > signer.max_signature_byte_size()) { + resolver.Return(absl::InternalError( + absl::StrCat("Signature size ", sign_res->signature.size(), + " is larger than the maximum allowed size ", + signer.max_signature_byte_size()))); + return; + } + + auto cose_sign1 = std::make_shared<CoseSign1TaggedStructure>(); + cose_sign1->protected_header = sig_structure.body_protected; + cose_sign1->signature = std::move(sign_res->signature); + // Add OCSP responses. + const std::vector<std::string>& ocsp_responses = signer.ocsp_responses(); + if (!ocsp_responses.empty()) { + cose_sign1->unprotected_header.ocsp_responses = ocsp_responses; + } + // Create timestamp and finalize signature. + uint64_t max_signature_byte_size = signer.max_signature_byte_size(); + uint64_t max_timestamp_byte_size = + crypto_writer_.max_timestamp_byte_size(hasher_factory_.algorithm()); + auto time_stamping_mode = options.time_stamping_mode; + + if (time_stamping_mode == TimeStampingMode::kOmittedWithPadding) { + auto signature = FinalizeSignature( + /*timestamp=*/"", time_stamping_mode, max_timestamp_byte_size, + cose_sign1.get(), max_signature_byte_size, pad_to_size_); + if (!signature.ok()) { + resolver.Return(signature.status()); + return; + } + SignResult sign_result{.signature = std::move(*signature)}; + resolver.Return(std::move(sign_result)); + return; + } + + TimestampCreator::Options timestamp_creator_options{ + .async_context = options.async_context, + }; + + auto create_timestamp_cb = + [resolver = std::move(resolver), max_timestamp_byte_size, cose_sign1, + max_signature_byte_size, pad_to_size = pad_to_size_, time_stamping_mode]( + absl::StatusOr<std::string> timestamp_result) mutable { + auto signature = FinalizeSignature( + timestamp_result, time_stamping_mode, max_timestamp_byte_size, + cose_sign1.get(), max_signature_byte_size, pad_to_size); + if (!signature.ok()) { + resolver.Return(signature.status()); + return; + } + SignResult sign_result{.signature = std::move(*signature)}; + resolver.Return(std::move(sign_result)); + }; + + timestamp_creator_.CreateTimestamp(*cose_sign1, + std::move(timestamp_creator_options), + std::move(create_timestamp_cb)); +} + +} // namespace credentio
diff --git a/claim/local_signer.h b/claim/local_signer.h new file mode 100644 index 0000000..7937c3b --- /dev/null +++ b/claim/local_signer.h
@@ -0,0 +1,64 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_CLAIM_LOCAL_SIGNER_H_ +#define THIRD_PARTY_CREDENTIO_CLAIM_LOCAL_SIGNER_H_ + +#include <cstddef> +#include <optional> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/log/die_if_null.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "claim/signer.h" +#include "cose/timestamp_creator.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/hash.h" + +namespace credentio { +// A signer to sign a Claim according to +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_signing_a_claim. +class LocalClaimSigner : public ClaimSigner { + public: + explicit LocalClaimSigner(CryptoWriteHandler* absl_nonnull crypto_writer, + const HasherFactory* absl_nonnull hasher_factory, + std::optional<size_t> pad_to_size = std::nullopt) + : crypto_writer_(*ABSL_DIE_IF_NULL(crypto_writer)), + hasher_factory_(*ABSL_DIE_IF_NULL(hasher_factory)), + timestamp_creator_(&crypto_writer_, &hasher_factory_), + pad_to_size_(pad_to_size) {} + + // Signs the serialized CBOR of the claim and returns the serialized + // COSE_Sign1_Tagged structure that will be written into the C2PA Claim + // Signature box. For more details, see + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_signing_a_claim. + // It guarantees that the size of the result is fixed and is a function of + // `max_signature_bytes_size()` and `max_timestamp_byte_size()` of the + // CryptoWriteHandler rather than of the actual signature and timestamp sizes. + void Sign(absl::string_view claim, Options options, + absl::AnyInvocable<void(absl::StatusOr<SignResult>) &&> cb) + const override; + + private: + CryptoWriteHandler& crypto_writer_; + const HasherFactory& hasher_factory_; + TimestampCreator timestamp_creator_; + std::optional<size_t> pad_to_size_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CLAIM_LOCAL_SIGNER_H_
diff --git a/claim/local_signer_test.cc b/claim/local_signer_test.cc new file mode 100644 index 0000000..d929352 --- /dev/null +++ b/claim/local_signer_test.cc
@@ -0,0 +1,444 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "claim/local_signer.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "async/async_context.h" +#include "claim/signer.h" +#include "cose/sig_structure.h" +#include "cose/verifier.h" +#include "crypto/algorithms.h" +#include "crypto/crypto_read_handler.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/default/default_crypto_read_handler.h" +#include "crypto/default/hasher.h" +#include "crypto/timestamp_write_handler.h" +#include "gmock/gmock.h" +#include "google/protobuf/util/message_differencer.h" +#include "gtest/gtest.h" +#include "proto/cose_verification_result.pb.h" +#include "proto/signature_info.pb.h" +#include "testing/create_test_crypto_write_handler.h" +#include "testing/local_key_crypto_write_handler.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::ElementsAre; +using ::testing::HasSubstr; +using ::testing::IsFalse; +using ::testing::IsTrue; + +MATCHER_P(EqualsProtoIgnoringTimestampAndOrdering, expected, "") { + google::protobuf::util::MessageDifferencer differencer; + differencer.set_repeated_field_comparison( + google::protobuf::util::MessageDifferencer::AS_SET); + differencer.IgnoreField( + SignatureInfo::descriptor()->FindFieldByName("timestamping_time")); + std::string diff; + differencer.ReportDifferencesToString(&diff); + bool match = differencer.Compare(expected, arg); + if (!match) { + *result_listener << "\nDifference:\n" << diff; + } + return match; +} + +absl::StatusOr<std::unique_ptr<CryptoReadHandler>> +CreateTrustAnyoneCryptoReadHandlerForTest() { + return CreateDefaultCryptoReadHandler({ + .trust_environment = CryptoReadHandler::TrustEnvironment::kAny, + }); +} + +absl::StatusOr<std::string> Sign(LocalClaimSigner& signer, + absl::string_view claim, + ClaimSigner::Options options = {}) { + absl::Notification done; + absl::StatusOr<std::string> signature = + absl::InternalError("Callback not called"); + { + std::string claim_copy(claim); + signer.Sign(claim_copy, std::move(options), + [&](absl::StatusOr<ClaimSigner::SignResult> cb_result) { + if (cb_result.ok()) { + signature = std::move(cb_result->signature); + } else { + signature = cb_result.status(); + } + done.Notify(); + }); + } + done.WaitForNotification(); + return signature; +} + +TEST(ClaimSigner, Sign) { + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> crypto_writer = + CreateTestCryptoWriteHandler({}); + ABSL_ASSERT_OK(crypto_writer); + Sha256HasherFactory hasher_factory; + LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory); + absl::StatusOr<std::string> signature = Sign(claim_signer, "abc"); + ABSL_ASSERT_OK(signature); + + absl::StatusOr<std::unique_ptr<CryptoReadHandler>> + crypto_read_handler_for_verifier = + CreateTrustAnyoneCryptoReadHandlerForTest(); + ABSL_ASSERT_OK(crypto_read_handler_for_verifier); + std::unique_ptr<CoseVerifier> cose_verifier = CreateCoseVerifier( + {.crypto_read_handler = std::move(*crypto_read_handler_for_verifier)}); + CoseVerificationResult result = cose_verifier->Verify(*signature, "abc"); + CoseVerificationResult expected_result = ParseTextProtoOrDie(R"pb( + signature_info { + cert_serial_number: "02" + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA" + } + validation_status { + successes { code: "timeStamp.validated" url: "c2pa.signature" } + successes { code: "timeStamp.trusted" url: "c2pa.signature" } + successes { code: "signingCredential.trusted" url: "c2pa.signature" } + successes { code: "claimSignature.insideValidity" url: "c2pa.signature" } + successes { code: "claimSignature.validated" url: "c2pa.signature" } + informationals { + code: "signingCredential.ocsp.skipped" + url: "c2pa.signature" + } + } + )pb"); + EXPECT_THAT(result, EqualsProtoIgnoringTimestampAndOrdering(expected_result)); +} + +// This signer produces signatures that are larger than its advertised +// max_signature_byte_size. +class InvalidFixedKeySigner : public FixedKeySigner { + public: + SigningAlgorithm algorithm() const override { + return SigningAlgorithm::kPs256; + } + const std::vector<std::string>& certificate_chain() const override { + return certificate_chain_; + } + const std::vector<std::string>& ocsp_responses() const override { + return ocsp_responses_; + } + uint64_t max_signature_byte_size() const override { return 100; } + absl::StatusOr<SignResponse> Sign( + absl::string_view to_be_signed) const override { + return SignResponse{.signature = std::string(200, '\0')}; + } + + private: + std::vector<std::string> certificate_chain_ = {"cert1", "cert2"}; + std::vector<std::string> ocsp_responses_; +}; + +class InvalidCryptoWriteHandler : public CryptoWriteHandler { + public: + const FixedKeySigner& GetSigner() override { return signer_; } + void CreateTimestamp(HashAlgorithm hash_algorithm, std::string hashed_message, + TimestampWriteHandler::Options options, + absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> + cb) const override { + AsyncResolver<std::string>(options.async_context, std::move(cb)) + .Return(std::string(100, '\0')); + } + uint64_t max_timestamp_byte_size( + HashAlgorithm hash_algorithm) const override { + return 100; + } + + private: + InvalidFixedKeySigner signer_; +}; + +TEST(ClaimSigner, SignatureTooLarge) { + InvalidCryptoWriteHandler crypto_writer; + Sha256HasherFactory hasher_factory; + LocalClaimSigner claim_signer(&crypto_writer, &hasher_factory); + EXPECT_THAT(Sign(claim_signer, "abc"), StatusIs(absl::StatusCode::kInternal, + HasSubstr("Signature size"))); +} + +class DelegatingCryptoWriteHandler : public CryptoWriteHandler { + public: + explicit DelegatingCryptoWriteHandler(CryptoWriteHandler* delegate) + : delegate_(delegate) {} + + const FixedKeySigner& GetSigner() override { return delegate_->GetSigner(); } + + void CreateTimestamp(HashAlgorithm hash_algorithm, std::string hashed_message, + TimestampWriteHandler::Options options, + absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> + cb) const override { + delegate_->CreateTimestamp(hash_algorithm, std::move(hashed_message), + options, std::move(cb)); + } + + uint64_t max_timestamp_byte_size( + HashAlgorithm hash_algorithm) const override { + return delegate_->max_timestamp_byte_size(hash_algorithm); + } + + protected: + CryptoWriteHandler* delegate_; +}; + +class InvalidTimestampCryptoWriteHandler : public DelegatingCryptoWriteHandler { + public: + using DelegatingCryptoWriteHandler::DelegatingCryptoWriteHandler; + + uint64_t max_timestamp_byte_size( + HashAlgorithm hash_algorithm) const override { + return 10; + } +}; + +TEST(ClaimSigner, TimestampTooLarge) { + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> crypto_writer = + CreateTestCryptoWriteHandler({}); + ABSL_ASSERT_OK(crypto_writer); + InvalidTimestampCryptoWriteHandler invalid_crypto_writer( + crypto_writer->get()); + Sha256HasherFactory hasher_factory; + LocalClaimSigner claim_signer(&invalid_crypto_writer, &hasher_factory); + absl::StatusOr<std::string> signature = Sign(claim_signer, "abc"); + ABSL_ASSERT_OK(signature); + absl::StatusOr<CoseSign1TaggedStructure> cose_sign1 = + DecodeCoseSign1TaggedStructure(*signature); + ABSL_ASSERT_OK(cose_sign1); + EXPECT_THAT(cose_sign1->unprotected_header.sig_tst2.has_value(), IsFalse()); +} + +TEST(ClaimSigner, TimestampTooLargeTimestampingRequired) { + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> crypto_writer = + CreateTestCryptoWriteHandler({}); + ABSL_ASSERT_OK(crypto_writer); + InvalidTimestampCryptoWriteHandler invalid_crypto_writer( + crypto_writer->get()); + Sha256HasherFactory hasher_factory; + LocalClaimSigner claim_signer(&invalid_crypto_writer, &hasher_factory); + EXPECT_THAT( + Sign(claim_signer, "abc", + {.time_stamping_mode = TimeStampingMode::kRequired}), + StatusIs(absl::StatusCode::kInternal, HasSubstr("Timestamp size"))); +} + +class FixedMaxTimestampCryptoWriteHandler + : public DelegatingCryptoWriteHandler { + public: + FixedMaxTimestampCryptoWriteHandler(CryptoWriteHandler* delegate, + uint64_t max_timestamp_size) + : DelegatingCryptoWriteHandler(delegate), + max_timestamp_size_(max_timestamp_size) {} + + uint64_t max_timestamp_byte_size( + HashAlgorithm hash_algorithm) const override { + return max_timestamp_size_; + } + + private: + uint64_t max_timestamp_size_; +}; + +class UnavailableTimestampCryptoWriteHandler + : public DelegatingCryptoWriteHandler { + public: + UnavailableTimestampCryptoWriteHandler(CryptoWriteHandler* delegate, + uint64_t max_timestamp_size) + : DelegatingCryptoWriteHandler(delegate), + max_timestamp_size_(max_timestamp_size) {} + + void CreateTimestamp(HashAlgorithm hash_algorithm, std::string hashed_message, + TimestampWriteHandler::Options options, + absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> + cb) const override { + AsyncResolver<std::string>(options.async_context, std::move(cb)) + .Return(absl::UnavailableError("Timestamping is unavailable.")); + } + + uint64_t max_timestamp_byte_size( + HashAlgorithm hash_algorithm) const override { + return max_timestamp_size_; + } + + private: + uint64_t max_timestamp_size_; +}; + +TEST(ClaimSignerTest, TimeStampingRequired) { + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> delegate = + CreateTestCryptoWriteHandler({}); + ABSL_ASSERT_OK(delegate); + UnavailableTimestampCryptoWriteHandler crypto_writer(delegate->get(), 100); + Sha256HasherFactory hasher_factory; + LocalClaimSigner claim_signer(&crypto_writer, &hasher_factory); + EXPECT_THAT(Sign(claim_signer, "abc", + {.time_stamping_mode = TimeStampingMode::kRequired}), + StatusIs(absl::StatusCode::kUnavailable, + HasSubstr("Timestamping is unavailable."))); +} + +TEST(ClaimSigner, TimeStampingOmittedWithPadding) { + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> delegate = + CreateTestCryptoWriteHandler({}); + ABSL_ASSERT_OK(delegate); + FixedMaxTimestampCryptoWriteHandler crypto_writer(delegate->get(), 2000); + Sha256HasherFactory hasher_factory; + LocalClaimSigner claim_signer(&crypto_writer, &hasher_factory); + absl::StatusOr<std::string> signature = + Sign(claim_signer, "abc", + {.time_stamping_mode = TimeStampingMode::kOmittedWithPadding}); + ABSL_ASSERT_OK(signature); + absl::StatusOr<CoseSign1TaggedStructure> cose_sign1 = + DecodeCoseSign1TaggedStructure(*signature); + ABSL_ASSERT_OK(cose_sign1); + EXPECT_FALSE(cose_sign1->signature.empty()); + EXPECT_THAT(cose_sign1->unprotected_header.sig_tst2.has_value(), IsFalse()); + + // Check if the padding is of correct size. + absl::StatusOr<std::string> signature2 = Sign( + claim_signer, "abc", {.time_stamping_mode = TimeStampingMode::kRequired}); + ABSL_ASSERT_OK(signature2); + EXPECT_EQ(signature->size(), signature2->size()); +} + +class ClaimSignerTest : public testing::TestWithParam<int64_t> {}; + +TEST_P(ClaimSignerTest, SignatureSizeUnaffectedByTimestamp) { + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> delegate = + CreateTestCryptoWriteHandler({}); + ABSL_ASSERT_OK(delegate); + int64_t timestamp_size = GetParam(); + UnavailableTimestampCryptoWriteHandler unavailable_crypto_writer( + delegate->get(), timestamp_size); + Sha256HasherFactory hasher_factory; + LocalClaimSigner claim_signer(&unavailable_crypto_writer, &hasher_factory); + absl::StatusOr<std::string> signature1 = Sign(claim_signer, "abc"); + ABSL_ASSERT_OK(signature1); + + FixedMaxTimestampCryptoWriteHandler normal_crypto_writer(delegate->get(), + timestamp_size); + LocalClaimSigner claim_signer2(&normal_crypto_writer, &hasher_factory); + absl::StatusOr<std::string> signature2 = Sign(claim_signer2, "abc"); + ABSL_ASSERT_OK(signature2); + EXPECT_EQ(signature1->size(), signature2->size()); +} +INSTANTIATE_TEST_SUITE_P(ClaimSignerTestWithParams, ClaimSignerTest, + testing::Values(100, 200, 500, 1000, 10000)); + +class FixedKeySignerWithOcsp : public FixedKeySigner { + public: + FixedKeySignerWithOcsp(const FixedKeySigner& delegate, + std::vector<std::string> ocsp_responses) + : delegate_(delegate), ocsp_responses_(std::move(ocsp_responses)) {} + SigningAlgorithm algorithm() const override { return delegate_.algorithm(); } + const std::vector<std::string>& certificate_chain() const override { + return delegate_.certificate_chain(); + } + const std::vector<std::string>& ocsp_responses() const override { + return ocsp_responses_; + } + uint64_t max_signature_byte_size() const override { + return delegate_.max_signature_byte_size(); + } + absl::StatusOr<SignResponse> Sign( + absl::string_view to_be_signed) const override { + return delegate_.Sign(to_be_signed); + } + + private: + const FixedKeySigner& delegate_; + std::vector<std::string> ocsp_responses_; +}; + +class CryptoWriteHandlerWithOcsp : public DelegatingCryptoWriteHandler { + public: + CryptoWriteHandlerWithOcsp(CryptoWriteHandler* delegate, + std::vector<std::string> ocsp_responses) + : DelegatingCryptoWriteHandler(delegate), + signer_(delegate->GetSigner(), std::move(ocsp_responses)) {} + const FixedKeySigner& GetSigner() override { return signer_; } + + private: + FixedKeySignerWithOcsp signer_; +}; + +TEST(ClaimSigner, OcspResponsesWithTimestamp) { + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> delegate = + CreateTestCryptoWriteHandler({}); + ABSL_ASSERT_OK(delegate); + CryptoWriteHandlerWithOcsp crypto_writer(delegate->get(), {"ocsp1", "ocsp2"}); + Sha256HasherFactory hasher_factory; + LocalClaimSigner claim_signer(&crypto_writer, &hasher_factory); + absl::StatusOr<std::string> signature = Sign(claim_signer, "abc"); + ABSL_ASSERT_OK(signature); + + absl::StatusOr<CoseSign1TaggedStructure> cose_sign1 = + DecodeCoseSign1TaggedStructure(*signature); + ABSL_ASSERT_OK(cose_sign1); + EXPECT_THAT(cose_sign1->unprotected_header.ocsp_responses, + ElementsAre("ocsp1", "ocsp2")); + EXPECT_THAT(cose_sign1->unprotected_header.sig_tst2.has_value(), IsTrue()); +} + +TEST(ClaimSigner, OcspResponsesWithoutTimestamp) { + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> delegate = + CreateTestCryptoWriteHandler({.disable_timestamping = true}); + ABSL_ASSERT_OK(delegate); + CryptoWriteHandlerWithOcsp crypto_writer(delegate->get(), {"ocsp1", "ocsp2"}); + Sha256HasherFactory hasher_factory; + LocalClaimSigner claim_signer(&crypto_writer, &hasher_factory); + absl::StatusOr<std::string> signature = Sign(claim_signer, "abc"); + ABSL_ASSERT_OK(signature); + + absl::StatusOr<CoseSign1TaggedStructure> cose_sign1 = + DecodeCoseSign1TaggedStructure(*signature); + ABSL_ASSERT_OK(cose_sign1); + EXPECT_THAT(cose_sign1->unprotected_header.ocsp_responses, + ElementsAre("ocsp1", "ocsp2")); + EXPECT_THAT(cose_sign1->unprotected_header.sig_tst2.has_value(), IsFalse()); +} + +TEST(ClaimSigner, PadToSize) { + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> crypto_writer = + CreateTestCryptoWriteHandler({}); + ABSL_ASSERT_OK(crypto_writer); + Sha256HasherFactory hasher_factory; + LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory, + /*pad_to_size=*/99999); + absl::StatusOr<std::string> signature = Sign(claim_signer, "abc"); + ABSL_ASSERT_OK(signature); + EXPECT_EQ(signature->size(), 99999); +} + +} // namespace +} // namespace credentio
diff --git a/claim/manifest_box.h b/claim/manifest_box.h new file mode 100644 index 0000000..767c638 --- /dev/null +++ b/claim/manifest_box.h
@@ -0,0 +1,45 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_H_ +#define THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "claim/manifest_store_builder.h" +#include "proto/assertion.pb.h" +#include "proto/generator.pb.h" + +namespace credentio { + +// An intermediate representation of a manifest box to be used for claim +// generation. +struct ManifestBox { + std::string label; + std::string instance_id; + Assertion hard_binding_assertion; + Assertion actions_assertion; + std::vector<Assertion> part_hash_assertions; + std::optional<Assertion> multi_part_assertion; + std::vector<Assertion> ingredient_assertions; + std::vector<GatheredAssertion> gathered_assertions; + ManifestStoreBuilder manifest_store_builder; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_H_
diff --git a/claim/manifest_box_assembler.cc b/claim/manifest_box_assembler.cc new file mode 100644 index 0000000..aa421d3 --- /dev/null +++ b/claim/manifest_box_assembler.cc
@@ -0,0 +1,631 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "claim/manifest_box_assembler.h" + +#include <cstdint> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/container/flat_hash_map.h" +#include "absl/container/flat_hash_set.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "claim/manifest_box.h" +#include "claim/manifest_store_builder.h" +#include "constants/ingredient_relationships.h" +#include "constants/labels.h" +#include "constants/spec_version.h" +#include "proto/actions_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/generator.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/multi_asset_hash_assertion.pb.h" +#include "utils/cbor_padding.h" + +namespace credentio { +namespace { + +absl::Status ValidateRequest(const GenerateManifestStoreRequest& request) { + if (request.hard_binding().binding_case() == HardBinding::BINDING_NOT_SET) { + return absl::InvalidArgumentError( + "The request does not have a hard binding."); + } + for (const auto& gathered : request.gathered_assertions()) { + if (gathered.label().empty()) { + return absl::InvalidArgumentError( + "Gathered assertion must have a non-empty label."); + } + if (gathered.content_type() == + GatheredAssertion::CONTENT_TYPE_UNSPECIFIED) { + return absl::InvalidArgumentError( + "Gathered assertion content type must be specified."); + } + } + return absl::OkStatus(); +} + +void ConvertActionMetadata(const ActionMetadata& action_metadata, + ActionItem* action_item) { + if (action_metadata.has_software_agent()) { + *action_item->mutable_software_agent() = action_metadata.software_agent(); + } + if (action_metadata.has_description()) { + action_item->set_description(action_metadata.description()); + } + if (action_metadata.has_multiple_instances()) { + action_item->mutable_parameters()->set_multiple_instances( + action_metadata.multiple_instances()); + } +} + +absl::StatusOr<Assertion> ConvertIngredientAssertion( + const IngredientInput& ingredient, absl::string_view label, + absl::string_view relationship, + const std::optional<IngredientManifestUris>& ingredient_uris, + const std::optional<IngredientValidationResults>& + ingredient_validation_results, + const std::optional<std::string>& digital_source_type) { + Assertion assertion; + + // Convert Assertion box fields. + assertion.set_label(label); + auto* ingredient_v3 = assertion.mutable_ingredient_v3(); + + // Convert Ingredient Assertion V3 fields. + if (ingredient.metadata().has_title()) { + ingredient_v3->set_title(ingredient.metadata().title()); + } + if (ingredient.metadata().has_media_type()) { + ingredient_v3->set_media_type(ingredient.metadata().media_type()); + } else if (ingredient.ingredient_validation_results().has_media_type()) { + ingredient_v3->set_media_type( + ingredient.ingredient_validation_results().media_type()); + } + ingredient_v3->set_relationship(relationship); + if (ingredient.metadata().has_instance_id()) { + ingredient_v3->set_instance_id(ingredient.metadata().instance_id()); + } + if (ingredient.metadata().has_description()) { + ingredient_v3->set_description(ingredient.metadata().description()); + } + if (ingredient.metadata().has_informational_uri()) { + ingredient_v3->set_informational_uri( + ingredient.metadata().informational_uri()); + } + if (ingredient_uris.has_value()) { + // The ingredient URIs provided by the ManifestStoreBuilder should always + // have the active manifest and claim signature URIs set. + if (ingredient_uris->active_manifest.url().empty()) { + return absl::InternalError( + "Should not happen. Ingredient URI missing active manifest."); + } + *ingredient_v3->mutable_active_manifest() = + ingredient_uris->active_manifest; + if (ingredient_uris->claim_signature.url().empty()) { + return absl::InternalError( + "Should not happen. Ingredient URI missing claim signature."); + } + *ingredient_v3->mutable_claim_signature() = + ingredient_uris->claim_signature; + } + if (ingredient_validation_results.has_value()) { + *ingredient_v3->mutable_validation_results() = + ingredient_validation_results.value(); + } + if (digital_source_type.has_value()) { + ingredient_v3->set_digital_source_type(*digital_source_type); + } + return assertion; +} + +// An ingredient listed in a GenerateManifestStoreRequest. +class Ingredient { + public: + explicit Ingredient(std::string label, const IngredientInput* input) + : label_(std::move(label)), input_(*ABSL_DIE_IF_NULL(input)) {} + + absl::Status SetRelationship(absl::string_view relationship) { + if (relationship_.empty()) { + relationship_ = relationship; + } else if (relationship != relationship_) { + return absl::InvalidArgumentError( + absl::Substitute("Inconsistent use of ingredient ($0 and $1)", + relationship_, relationship)); + } + return absl::OkStatus(); + } + + absl::string_view label() const { return label_; } + absl::string_view relationship() const { return relationship_; } + const IngredientInput& input() const { return input_; } + + private: + const std::string label_; + const IngredientInput& input_; + absl::string_view relationship_; +}; + +// Creates an assertion label per +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_multiple_instances. +std::string CreateLabel(absl::string_view base_label, int index) { + if (index == 0) { + return std::string(base_label); + } + return absl::StrCat(base_label, "__", index); +} + +class PartLabelProvider { + public: + absl::StatusOr<std::string> GetLabel(PartBinding::BindingCase binding_case) { + absl::string_view base_label; + switch (binding_case) { + case PartBinding::kBmffBasedHash: + base_label = kBmffBasedHashAssertionV3PartLabel; + break; + case PartBinding::kBoxHash: + base_label = kBoxesHashAssertionPartLabel; + break; + case PartBinding::kDataHash: + base_label = kDataHashAssertionPartLabel; + break; + default: + return absl::InvalidArgumentError( + absl::StrCat("Unsupported assertion type: ", binding_case)); + } + return CreateLabel(base_label, assertion_type_to_count_[base_label]++); + } + + private: + absl::flat_hash_map<absl::string_view, int32_t> assertion_type_to_count_; +}; + +absl::Status AddMultiAssetHashToManifestBox( + const GenerateManifestStoreRequest& request, ManifestBox& manifest_box) { + if (request.hard_binding().part_bindings_size() == 0) { + // Doesn't have any multi-asset hash assertion information. + return absl::OkStatus(); + } + + Assertion& multi_asset_hash_assertion = + manifest_box.multi_part_assertion.emplace(); + multi_asset_hash_assertion.set_label(kMultiAssetHashAssertionLabel); + + PartLabelProvider part_label_provider; + for (const auto& part : request.hard_binding().part_bindings()) { + Assertion& part_hash_assertion = + manifest_box.part_hash_assertions.emplace_back(); + ABSL_ASSIGN_OR_RETURN(std::string label, + part_label_provider.GetLabel(part.binding_case())); + part_hash_assertion.set_label(std::move(label)); + switch (part.binding_case()) { + case PartBinding::kBmffBasedHash: + *part_hash_assertion.mutable_bmff_based_hash() = part.bmff_based_hash(); + break; + case PartBinding::kBoxHash: + *part_hash_assertion.mutable_boxes_hash() = part.box_hash(); + PadBoxesHashAssertion(*part_hash_assertion.mutable_boxes_hash()); + break; + case PartBinding::kDataHash: + *part_hash_assertion.mutable_data_hash() = part.data_hash(); + PadDataHashAssertion(*part_hash_assertion.mutable_data_hash()); + break; + default: + return absl::InvalidArgumentError( + absl::StrCat("Unsupported assertion type: ", part.binding_case())); + } + + MultiAssetPart& multi_asset_part = + *multi_asset_hash_assertion.mutable_multi_asset_hash()->add_parts(); + multi_asset_part.mutable_location()->set_byte_offset( + part.location().byte_offset()); + multi_asset_part.mutable_location()->set_length(part.location().length()); + multi_asset_part.mutable_hash_assertion()->set_url(absl::StrCat( + "self#jumbf=c2pa.assertions/", part_hash_assertion.label())); + multi_asset_part.set_is_optional(part.is_optional()); + } + + PadMultiAssetHashAssertion( + *multi_asset_hash_assertion.mutable_multi_asset_hash()); + + return absl::OkStatus(); +} + +} // namespace + +// A single manifest box assembly operation. +class StandardManifestBoxAssembler::AssembleOp { + public: + explicit AssembleOp(const StandardManifestBoxAssembler& assembler, + const GenerateManifestStoreRequest& request) + : assembler_(assembler), request_(request) { + ingredients_.reserve(request.ingredients_size()); + for (int i = 0; i < request.ingredients_size(); ++i) { + ingredients_.push_back( + Ingredient(CreateLabel(kIngredientAssertionV3Label, i), + &request.ingredients(i))); + } + } + + absl::StatusOr<ManifestBox> Run() { + ABSL_RETURN_IF_ERROR(ValidateRequest(request_)); + ManifestBox manifest_box = {.manifest_store_builder = ManifestStoreBuilder( + &assembler_.hash_factory_)}; + manifest_box.label = absl::StrCat( + "urn:c2pa:", assembler_.uuid_generator_.Generate().ToString()); + if (request_.has_instance_id()) { + manifest_box.instance_id = request_.instance_id(); + } else { + manifest_box.instance_id = + assembler_.uuid_generator_.Generate().ToString(); + } + + // Convert hard binding assertion. + { + Assertion& hard_binding = manifest_box.hard_binding_assertion; + if (request_.hard_binding().has_bmff_based_hash()) { + hard_binding.set_label(kBmffBasedHashAssertionV3Label); + *hard_binding.mutable_bmff_based_hash() = + request_.hard_binding().bmff_based_hash(); + } else if (request_.hard_binding().has_box_hash()) { + hard_binding.set_label(kBoxesHashAssertionLabel); + *hard_binding.mutable_boxes_hash() = request_.hard_binding().box_hash(); + PadBoxesHashAssertion(*hard_binding.mutable_boxes_hash()); + } else if (request_.hard_binding().has_data_hash()) { + hard_binding.set_label(kDataHashAssertionLabel); + *hard_binding.mutable_data_hash() = request_.hard_binding().data_hash(); + PadDataHashAssertion(*hard_binding.mutable_data_hash()); + } else if (request_.hard_binding().has_collection_data_hash()) { + hard_binding.set_label(kCollectionDataHashAssertionLabel); + *hard_binding.mutable_collection_data_hash() = + request_.hard_binding().collection_data_hash(); + } + } + + ABSL_RETURN_IF_ERROR( + AddMultiAssetHashToManifestBox(request_, manifest_box)); + + // Process inception action. + Assertion& actions_assertion = manifest_box.actions_assertion; + ActionsAssertion& actions = *actions_assertion.mutable_actions(); + std::optional<int> opened_ingredient_index = std::nullopt; + { + actions_assertion.set_label(kActionsAssertionV2Label); + if (request_.actions().has_all_actions_included()) { + actions.set_all_actions_included( + request_.actions().all_actions_included()); + } + if (request_.actions().has_created()) { + ABSL_RETURN_IF_ERROR(ConvertActionWithInputIngredients( + "c2pa.created", request_.actions().created(), + actions.add_actions())); + } else if (request_.actions().has_opened()) { + OpenedAction opened_action = request_.actions().opened(); + ABSL_RETURN_IF_ERROR( + ConvertOpenedAction(opened_action, actions.add_actions())); + opened_ingredient_index = opened_action.ingredient_index(); + } else { + return absl::InvalidArgumentError( + "missing inception action (created or opened)"); + } + } + + // Process remaining actions. + for (const auto& action : request_.actions().actions()) { + switch (action.action_case()) { + case Action::kAddedText: + ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction( + "c2pa.addedText", action.added_text(), actions.add_actions())); + break; + case Action::kAdjustedColor: + ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction( + "c2pa.adjustedColor", action.adjusted_color(), + actions.add_actions())); + break; + case Action::kChangedSpeed: + ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction( + "c2pa.changedSpeed", action.changed_speed(), + actions.add_actions())); + break; + case Action::kConverted: + ABSL_RETURN_IF_ERROR(ConvertSimpleAction( + "c2pa.converted", action.converted(), actions.add_actions())); + break; + case Action::kCropped: + ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction( + "c2pa.cropped", action.cropped(), actions.add_actions())); + break; + case Action::kDeleted: + ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction( + "c2pa.deleted", action.deleted(), actions.add_actions())); + break; + case Action::kDrawing: + ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction( + "c2pa.drawing", action.drawing(), actions.add_actions())); + break; + case Action::kDubbed: + ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction( + "c2pa.dubbed", action.dubbed(), actions.add_actions())); + break; + case Action::kEdited: + ABSL_RETURN_IF_ERROR(ConvertActionWithInputIngredients( + "c2pa.edited", action.edited(), actions.add_actions())); + break; + case Action::kEditedMetadata: + ABSL_RETURN_IF_ERROR(ConvertSimpleAction("c2pa.edited.metadata", + action.edited_metadata(), + actions.add_actions())); + break; + case Action::kEnhanced: + ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction( + "c2pa.enhanced", action.enhanced(), actions.add_actions())); + break; + case Action::kFiltered: + ABSL_RETURN_IF_ERROR(ConvertActionWithInputIngredients( + "c2pa.filtered", action.filtered(), actions.add_actions())); + break; + case Action::kOrientation: + ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction( + "c2pa.orientation", action.orientation(), actions.add_actions())); + break; + case Action::kPlaced: + ABSL_RETURN_IF_ERROR( + ConvertPlacedAction(action.placed(), actions.add_actions())); + break; + case Action::kRepackaged: + ABSL_RETURN_IF_ERROR(ConvertRepackagedAction( + "c2pa.repackaged", action.repackaged(), actions.add_actions(), + opened_ingredient_index)); + break; + case Action::kResized: + ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction( + "c2pa.resized", action.resized(), actions.add_actions())); + break; + case Action::kTranscoded: + ABSL_RETURN_IF_ERROR(ConvertTranscodedAction( + "c2pa.transcoded", action.transcoded(), actions.add_actions(), + opened_ingredient_index)); + break; + case Action::kTrimmed: + ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction( + "c2pa.trimmed", action.trimmed(), actions.add_actions())); + break; + case Action::kUnknown: + ABSL_RETURN_IF_ERROR(ConvertSimpleAction( + "c2pa.unknown", action.unknown(), actions.add_actions())); + break; + case Action::ACTION_NOT_SET: + return absl::InvalidArgumentError("empty action"); + } + } + + // Add redaction actions from ingredients. + absl::flat_hash_set<absl::string_view> seen_redaction_uris; + for (const auto& ingredient : ingredients_) { + if (ingredient.input().has_ingredient_validation_results()) { + const IngredientValidationResultProto& result = + ingredient.input().ingredient_validation_results(); + for (const auto& redaction : result.redactions()) { + // Currently, duplicate manifests are skipped in order of iteration + // over `ingredients_`. + if (!seen_redaction_uris.insert(redaction.uri()).second) { + continue; + } + ActionItem* action = actions.add_actions(); + action->set_action("c2pa.redacted"); + action->set_reason(redaction.reason()); + action->mutable_parameters()->set_redacted_uri(redaction.uri()); + } + } + } + + // Import ingredients. + for (const auto& ingredient : ingredients_) { + ABSL_RETURN_IF_ERROR( + ImportIngredient(ingredient, manifest_box.ingredient_assertions, + manifest_box.manifest_store_builder)); + } + + // Copy gathered assertions. + manifest_box.gathered_assertions.assign( + request_.gathered_assertions().begin(), + request_.gathered_assertions().end()); + + return manifest_box; + } + + private: + absl::StatusOr<Ingredient*> GetIngredient(int ingredient_index) { + if (ingredient_index < 0 || ingredient_index >= ingredients_.size()) { + return absl::InvalidArgumentError( + absl::Substitute("ingredient index out of range [0, $0]: $1", + ingredients_.size(), ingredient_index)); + } + return &ingredients_[ingredient_index]; + } + + absl::Status AddIngredientToAction(int ingredient_index, + absl::string_view relationship, + ActionItem* action_item) { + ABSL_ASSIGN_OR_RETURN(Ingredient * ingredient, + GetIngredient(ingredient_index)); + ABSL_RETURN_IF_ERROR(ingredient->SetRelationship(relationship)); + action_item->mutable_parameters()->add_ingredients()->set_url( + absl::StrCat(kAssertionStoreUrlPrefix, ingredient->label())); + return absl::OkStatus(); + } + + absl::Status ConvertOpenedAction(const OpenedAction& action, + ActionItem* action_item) { + ABSL_RETURN_IF_ERROR( + ConvertSimpleAction("c2pa.opened", action, action_item)); + if (!action.has_ingredient_index()) { + return absl::InvalidArgumentError( + "Opened action requires an ingredient index."); + } + return AddIngredientToAction(action.ingredient_index(), + kIngredientRelationshipParentOf, action_item); + } + + absl::Status ConvertPlacedAction(const PlacedAction& action, + ActionItem* action_item) { + ABSL_RETURN_IF_ERROR( + ConvertSimpleAction("c2pa.placed", action, action_item)); + if (action.ingredient_indexes_size() == 0) { + return absl::InvalidArgumentError("missing ingredients in placed action"); + } + for (int ingredient_index : action.ingredient_indexes()) { + ABSL_RETURN_IF_ERROR(AddIngredientToAction( + ingredient_index, kIngredientRelationshipComponentOf, action_item)); + } + return absl::OkStatus(); + } + + absl::Status ConvertTranscodedAction( + absl::string_view name, const TranscodedAction& action, + ActionItem* action_item, std::optional<int> opened_ingredient_index) { + ABSL_RETURN_IF_ERROR( + ConvertDigitalSourceTypeAction(name, action, action_item)); + if (opened_ingredient_index.has_value()) { + // We add this redundant ingredient reference to satisfy 2.1 validators, + // which require an ingredient reference in c2pa.transcoded and + // c2pa.repackaged actions. + return AddIngredientToAction(*opened_ingredient_index, + kIngredientRelationshipParentOf, + action_item); + } + return absl::OkStatus(); + } + + absl::Status ConvertRepackagedAction( + absl::string_view name, const RepackagedAction& action, + ActionItem* action_item, std::optional<int> opened_ingredient_index) { + if (!opened_ingredient_index.has_value()) { + return absl::InvalidArgumentError( + absl::Substitute("$0 action requires an `opened` action.", name)); + } + ABSL_RETURN_IF_ERROR(ConvertSimpleAction(name, action, action_item)); + // We add this redundant ingredient reference to satisfy 2.1 validators, + // which require an ingredient reference in c2pa.transcoded and + // c2pa.repackaged actions. + return AddIngredientToAction(*opened_ingredient_index, + kIngredientRelationshipParentOf, action_item); + } + + // CreatedAction, EditedAction, FilteredAction. + template <typename T> + absl::Status ConvertActionWithInputIngredients(absl::string_view name, + const T& action, + ActionItem* action_item) { + ABSL_RETURN_IF_ERROR( + ConvertDigitalSourceTypeAction(name, action, action_item)); + for (int ingredient_index : action.input_ingredient_indexes()) { + ABSL_RETURN_IF_ERROR(AddIngredientToAction( + ingredient_index, kIngredientRelationshipInputTo, action_item)); + } + return absl::OkStatus(); + } + + template <typename T> + absl::Status ConvertDigitalSourceTypeAction(absl::string_view name, + const T& action, + ActionItem* action_item) { + ABSL_RETURN_IF_ERROR(ConvertSimpleAction(name, action, action_item)); + if (action.has_digital_source_type()) { + action_item->set_digital_source_type(action.digital_source_type()); + } + return absl::OkStatus(); + } + + template <typename T> + absl::Status ConvertSimpleAction(absl::string_view name, const T& action, + ActionItem* action_item) { + action_item->set_action(name); + if (action.has_action_metadata()) { + ConvertActionMetadata(action.action_metadata(), action_item); + } + return absl::OkStatus(); + } + + // Imports an ingredient's manifest store and creates an ingredient + // assertion. + absl::Status ImportIngredient(const Ingredient& ingredient, + std::vector<Assertion>& ingredient_assertions, + ManifestStoreBuilder& manifest_store_builder) { + if (ingredient.relationship().empty()) { + return absl::InvalidArgumentError( + "ingredient is not referenced by any action."); + } + std::optional<IngredientManifestUris> ingredient_manifest_uris; + std::optional<IngredientValidationResults> ingredient_validation_results; + std::optional<std::string> digital_source_type; + if (ingredient.input().has_ingredient_validation_results()) { + const IngredientValidationResultProto& result = + ingredient.input().ingredient_validation_results(); + if (result.has_legacy_ingredient_metadata()) { + // Treat ingredients with unsupported manifests like ingredients without + // C2PA at all. + } else if (result.has_manifest_store_bytes()) { + if (!result.has_ingredient_validation_results()) { + return absl::InvalidArgumentError( + "Ingredient manifest store validation results " + "cannot be empty."); + } + ABSL_ASSIGN_OR_RETURN(ingredient_manifest_uris, + manifest_store_builder.AddIngredientManifestStore( + result.manifest_store_bytes()), + _ << "adding ingredient manifest store"); + ingredient_validation_results = result.ingredient_validation_results(); + } else if (result.has_digital_source_type() && + assembler_.spec_version_ >= SpecVersion::kC2pa_2_4) { + digital_source_type = result.digital_source_type(); + } + } + + ABSL_ASSIGN_OR_RETURN( + Assertion ingredient_assertion, + ConvertIngredientAssertion( + ingredient.input(), ingredient.label(), ingredient.relationship(), + ingredient_manifest_uris, ingredient_validation_results, + digital_source_type)); + ingredient_assertions.push_back(ingredient_assertion); + return absl::OkStatus(); + } + + const StandardManifestBoxAssembler& assembler_; + const GenerateManifestStoreRequest& request_; + std::vector<Ingredient> ingredients_; +}; + +absl::StatusOr<ManifestBox> StandardManifestBoxAssembler::Assemble( + const GenerateManifestStoreRequest& request) const { + AssembleOp op(*this, request); + return op.Run(); +} +} // namespace credentio
diff --git a/claim/manifest_box_assembler.h b/claim/manifest_box_assembler.h new file mode 100644 index 0000000..106dc28 --- /dev/null +++ b/claim/manifest_box_assembler.h
@@ -0,0 +1,62 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_ASSEMBLER_H_ +#define THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_ASSEMBLER_H_ + +#include "absl/log/die_if_null.h" +#include "absl/status/statusor.h" +#include "claim/manifest_box.h" +#include "constants/spec_version.h" +#include "crypto/hash.h" +#include "proto/generator.pb.h" +#include "uuid/uuid.h" + +namespace credentio { + +class ManifestBoxAssembler { + public: + virtual ~ManifestBoxAssembler() = default; + + // Assembles a manifest box from the given request. + virtual absl::StatusOr<ManifestBox> Assemble( + const GenerateManifestStoreRequest& request) const = 0; +}; + +class StandardManifestBoxAssembler : public ManifestBoxAssembler { + public: + // `spec_version` is the C2PA specification version the generated manifests + // conform to. It gates spec-version-dependent features. + explicit StandardManifestBoxAssembler( + const UuidGenerator* uuid_generator, const HasherFactory* hash_factory, + SpecVersion spec_version = SpecVersion::kC2pa_2_2) + : uuid_generator_(*ABSL_DIE_IF_NULL(uuid_generator)), + hash_factory_(*ABSL_DIE_IF_NULL(hash_factory)), + spec_version_(spec_version) {} + ~StandardManifestBoxAssembler() override = default; + + absl::StatusOr<ManifestBox> Assemble( + const GenerateManifestStoreRequest& request) const override; + + private: + class AssembleOp; + const UuidGenerator& uuid_generator_; + const HasherFactory& hash_factory_; + const SpecVersion spec_version_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_ASSEMBLER_H_
diff --git a/claim/manifest_box_assembler_test.cc b/claim/manifest_box_assembler_test.cc new file mode 100644 index 0000000..9c8ef05 --- /dev/null +++ b/claim/manifest_box_assembler_test.cc
@@ -0,0 +1,2094 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "claim/manifest_box_assembler.h" + +#include <memory> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "claim/manifest_box.h" +#include "claim/manifest_store_builder.h" +#include "constants/labels.h" +#include "constants/spec_version.h" +#include "crypto/default/hasher.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/test_utils.h" +#include "proto/assertion.pb.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/generator.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/manifest.pb.h" +#include "proto/signature_info.pb.h" +#include "testing/proto_test_utils.h" +#include "uuid/uuid.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::ElementsAre; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::Optional; +using ::testing::Pointwise; + +constexpr auto kUuid = + Uuid::FromStringOrDie("03020100-0504-0706-0908-0f0e0d0c0b0a"); +constexpr auto kInstanceId = + Uuid::FromStringOrDie("12345678-1234-1234-1234-123456789012"); + +std::string CreateSimpleManifestStoreBytes() { + std::string cbor_box = jumbf::EncodeCborBox("cbor_box"); + std::string signature_box = jumbf::EncodeSuperBox( + kClaimSignatureUuid, kClaimSignatureLabel, {cbor_box}); + std::string test_manifest = jumbf::EncodeSuperBox( + kStandardManifestUuid, "urn:c2pa:11111111-1111-1111-1111-111111111111", + {signature_box}); + return jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel, + {test_manifest}); +} + +std::string CreateLegacyManifestStoreBytes() { + std::string cbor_box = jumbf::EncodeCborBox("cbor_box"); + std::string signature_box = jumbf::EncodeSuperBox( + kClaimSignatureUuid, kClaimSignatureLabel, {cbor_box}); + std::string test_manifest = jumbf::EncodeSuperBox( + kStandardManifestUuid, "urn:uuid:legacy-c2pa", {signature_box}); + return jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel, + {test_manifest}); +} + +class FakeUuidGenerator : public UuidGenerator { + public: + Uuid Generate() const override { + if (first_) { + first_ = false; + return kUuid; + } + return kInstanceId; + } + + private: + mutable bool first_ = true; +}; + +class ManifestAssemblerTest : public testing::Test { + protected: + ManifestAssemblerTest() { + manifest_assembler_ = std::make_unique<StandardManifestBoxAssembler>( + &uuid_generator_, &hash_factory_); + } + + // Recreates the assembler targeting the given C2PA spec version. + void SetSpecVersion(SpecVersion spec_version) { + manifest_assembler_ = std::make_unique<StandardManifestBoxAssembler>( + &uuid_generator_, &hash_factory_, spec_version); + } + + FakeUuidGenerator uuid_generator_; + Sha256HasherFactory hash_factory_; + std::unique_ptr<ManifestBoxAssembler> manifest_assembler_; +}; + +TEST_F(ManifestAssemblerTest, NoHardBindingInvalid) { + GenerateManifestStoreRequest request; + EXPECT_THAT( + manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("hard binding"))); +} + +TEST_F(ManifestAssemblerTest, PopulateBmffBasedHardBinding) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash { alg: "some alg" hash: "some hash" } } + actions { created {} } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { alg: "some alg" hash: "some hash" } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_)}; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, PopulateActionsAssertionWithAllActionsIncluded) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash { alg: "some alg" hash: "some hash" } } + actions { + created {} + all_actions_included: true + } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { alg: "some alg" hash: "some hash" } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + all_actions_included: true + actions { action: "c2pa.created" } + } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_)}; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, UsesInstanceIdFromRequest) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash { alg: "some alg" hash: "some hash" } } + actions { created {} } + instance_id: "11111111-1111-1111-1111-111111111111" + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "11111111-1111-1111-1111-111111111111", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { alg: "some alg" hash: "some hash" } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, expected.instance_id); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, PopulateBoxesBasedHardBinding) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { + box_hash { + alg: "some alg" + boxes { names: "some name" names: "other name" hash: "some hash" } + } + } + actions { created {} } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.boxes" + boxes_hash { + alg: "some alg" + boxes { + names: "some name" + names: "other name" + hash: "some hash" + pad: "" + pad2: "" + } + } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, PopulateDataBasedHardBinding) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { + data_hash { + alg: "some alg" + hash: "some hash" + exclusions { start: 1 length: 10 } + } + } + actions { created {} } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.data" + data_hash { + alg: "some alg" + hash: "some hash" + exclusions { start: 1 length: 10 } + pad: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, PopulateCollectionDataHashBasedHardBinding) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { + collection_data_hash { + alg: "some alg" + uris { uri: "some uri" hash: "some hash" type: "some type" } + zip_central_directory_hash: "some hash" + } + } + actions { created {} } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.collection.data" + collection_data_hash { + uris { uri: "some uri" hash: "some hash" type: "some type" } + alg: "some alg" + zip_central_directory_hash: "some hash" + } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, UnspecifiedInceptionActionInvalid) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("missing inception action"))); +} + +TEST_F(ManifestAssemblerTest, EmptyAction) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions {} + } + ingredients { metadata { title: "ingredient0" } } + )pb"); + EXPECT_THAT( + manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("empty action"))); +} + +TEST_F(ManifestAssemblerTest, PlacedAction) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions { + placed { + action_metadata { description: "test placed description" } + ingredient_indexes: 1 + ingredient_indexes: 2 + } + } + } + ingredients { metadata { title: "ingredient0" } } + ingredients { metadata { title: "ingredient1" } } + ingredients { metadata { title: "ingredient2" } } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + actions { + action: "c2pa.placed" + description: "test placed description" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2" + } + } + } + } + )pb")); + EXPECT_THAT( + manifest_box.ingredient_assertions, + ElementsAre( + EqualsProto(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { title: "ingredient0" relationship: "parentOf" } + )pb"), + EqualsProto(R"pb( + label: "c2pa.ingredient.v3__1" + ingredient_v3 { title: "ingredient1" relationship: "componentOf" } + )pb"), + EqualsProto(R"pb( + label: "c2pa.ingredient.v3__2" + ingredient_v3 { title: "ingredient2" relationship: "componentOf" } + )pb"))); +} + +TEST_F(ManifestAssemblerTest, LegacyManifestInIngredients) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions { + placed { + action_metadata { description: "test placed description" } + ingredient_indexes: 1 + ingredient_indexes: 2 + } + } + } + ingredients { + metadata { title: "ingredient0" } + ingredient_validation_results { + manifest_store_bytes: "" + ingredient_validation_results { + active_manifest { successes { code: "test" } } + } + legacy_ingredient_metadata { + is_legacy_ingredient: true + unsupported_manifests: "unsupported manifest" + } + } + } + ingredients { + metadata { title: "ingredient1" } + ingredient_validation_results { + manifest_store_bytes: "" + ingredient_validation_results { + active_manifest { successes { code: "test" } } + } + legacy_ingredient_metadata { + is_legacy_ingredient: true + unsupported_manifests: "unsupported manifest" + } + } + } + ingredients { + metadata { title: "ingredient2" } + ingredient_validation_results { + manifest_store_bytes: "" + ingredient_validation_results { + active_manifest { successes { code: "test" } } + } + } + } + )pb"); + std::string ingredient0_manifest_store_bytes = + CreateLegacyManifestStoreBytes(); + std::string ingredient1_manifest_store_bytes = + CreateLegacyManifestStoreBytes(); + std::string ingredient2_manifest_store_bytes = + CreateSimpleManifestStoreBytes(); + request.mutable_ingredients(0) + ->mutable_ingredient_validation_results() + ->set_manifest_store_bytes(ingredient0_manifest_store_bytes); + request.mutable_ingredients(1) + ->mutable_ingredient_validation_results() + ->set_manifest_store_bytes(ingredient1_manifest_store_bytes); + request.mutable_ingredients(2) + ->mutable_ingredient_validation_results() + ->set_manifest_store_bytes(ingredient2_manifest_store_bytes); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + actions { + action: "c2pa.placed" + description: "test placed description" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2" + } + } + } + } + )pb")); + EXPECT_THAT( + manifest_box.ingredient_assertions, + ElementsAre( + EqualsProto(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { title: "ingredient0" relationship: "parentOf" } + )pb"), + EqualsProto(R"pb( + label: "c2pa.ingredient.v3__1" + ingredient_v3 { title: "ingredient1" relationship: "componentOf" } + )pb"), + EqualsProto(R"pb( + label: "c2pa.ingredient.v3__2" + ingredient_v3 { + title: "ingredient2" + relationship: "componentOf" + validation_results { + active_manifest { successes { code: "test" } } + } + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:11111111-1111-1111-1111-111111111111" + hash: "7#\034)n*\250\307>)(\263\336K\024\334R\002\'Q\252^\347\361\000\021T\334\256\320pf" + } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:11111111-1111-1111-1111-111111111111/c2pa.signature" + hash: "\017b6\027\253\306\235Q\350\246\311y\203sr@\256VE\267S\0137>\027#\377+\311U!l" + } + } + )pb"))); +} + +TEST_F(ManifestAssemblerTest, DigitalSourceTypeSetForUnknownProvenance) { + SetSpecVersion(SpecVersion::kC2pa_2_4); + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { opened { ingredient_index: 0 } } + ingredients { + metadata { title: "ingredient0" } + ingredient_validation_results { + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + } + } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT( + manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "ingredient0" + relationship: "parentOf" + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + } + )pb"))); +} + +TEST_F(ManifestAssemblerTest, DigitalSourceTypeIgnoredBeforeSpec24) { + SetSpecVersion(SpecVersion::kC2pa_2_2); + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { opened { ingredient_index: 0 } } + ingredients { + metadata { title: "ingredient0" } + ingredient_validation_results { + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + } + } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT(manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { title: "ingredient0" relationship: "parentOf" } + )pb"))); +} + +TEST_F(ManifestAssemblerTest, + DigitalSourceTypeNotSetForIngredientWithoutDigitalSourceType) { + SetSpecVersion(SpecVersion::kC2pa_2_4); + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { opened { ingredient_index: 0 } } + ingredients { + metadata { title: "ingredient0" } + ingredient_validation_results {} + } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT(manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { title: "ingredient0" relationship: "parentOf" } + )pb"))); + ASSERT_EQ(manifest_box.ingredient_assertions.size(), 1); + EXPECT_FALSE(manifest_box.ingredient_assertions[0] + .ingredient_v3() + .has_digital_source_type()); +} + +TEST_F(ManifestAssemblerTest, + DigitalSourceTypeIgnoredIfManifestStoreBytesPresent) { + SetSpecVersion(SpecVersion::kC2pa_2_4); + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { opened { ingredient_index: 0 } } + ingredients { + metadata { title: "ingredient0" } + ingredient_validation_results { + manifest_store_bytes: "" + ingredient_validation_results { + active_manifest { successes { code: "test" } } + } + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + } + } + )pb"); + std::string ingredient0_manifest_store_bytes = + CreateSimpleManifestStoreBytes(); + request.mutable_ingredients(0) + ->mutable_ingredient_validation_results() + ->set_manifest_store_bytes(ingredient0_manifest_store_bytes); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT( + manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "ingredient0" + relationship: "parentOf" + validation_results { active_manifest { successes { code: "test" } } } + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:11111111-1111-1111-1111-111111111111" + hash: "7#\034)n*\250\307>)(\263\336K\024\334R\002\'Q\252^\347\361\000\021T\334\256\320pf" + } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:11111111-1111-1111-1111-111111111111/c2pa.signature" + hash: "\017b6\027\253\306\235Q\350\246\311y\203sr@\256VE\267S\0137>\027#\377+\311U!l" + } + } + )pb"))); +} + +TEST_F(ManifestAssemblerTest, PlacedActionWithSharedIngredients) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions { + placed { + action_metadata { multiple_instances: true } + ingredient_indexes: 1 + ingredient_indexes: 2 + ingredient_indexes: 1 + } + } + actions { placed { ingredient_indexes: 1 } } + } + ingredients { metadata { title: "ingredient0" } } + ingredients { metadata { title: "ingredient1" } } + ingredients { metadata { title: "ingredient2" } } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + actions { + action: "c2pa.placed" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + multiple_instances: true + } + } + actions { + action: "c2pa.placed" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + } + } + } + )pb")); + EXPECT_THAT( + manifest_box.ingredient_assertions, + ElementsAre( + EqualsProto(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { title: "ingredient0" relationship: "parentOf" } + )pb"), + EqualsProto(R"pb( + label: "c2pa.ingredient.v3__1" + ingredient_v3 { title: "ingredient1" relationship: "componentOf" } + )pb"), + EqualsProto(R"pb( + label: "c2pa.ingredient.v3__2" + ingredient_v3 { title: "ingredient2" relationship: "componentOf" } + )pb"))); +} + +TEST_F(ManifestAssemblerTest, + PlacedActionWithSharedIngredientsWithSharedRedactions) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions { + placed { + action_metadata { multiple_instances: true } + ingredient_indexes: 1 + ingredient_indexes: 2 + ingredient_indexes: 1 + } + } + actions { placed { ingredient_indexes: 1 } } + } + ingredients { + metadata { title: "ingredient0" } + ingredient_validation_results { + redactions { uri: "uri1" reason: "reason1" } + redactions { uri: "uri2" reason: "reason2" } + } + } + ingredients { + metadata { title: "ingredient1" } + ingredient_validation_results { + redactions { uri: "uri2" reason: "reason2" } + } + } + ingredients { + metadata { title: "ingredient2" } + ingredient_validation_results { + redactions { uri: "uri2" reason: "reason2" } + } + } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + actions { + action: "c2pa.placed" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + multiple_instances: true + } + } + actions { + action: "c2pa.placed" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + } + } + actions { + action: "c2pa.redacted" + reason: "reason1" + parameters { redacted_uri: "uri1" } + } + actions { + action: "c2pa.redacted" + reason: "reason2" + parameters { redacted_uri: "uri2" } + } + } + )pb")); + EXPECT_THAT( + manifest_box.ingredient_assertions, + ElementsAre( + EqualsProto(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { title: "ingredient0" relationship: "parentOf" } + )pb"), + EqualsProto(R"pb( + label: "c2pa.ingredient.v3__1" + ingredient_v3 { title: "ingredient1" relationship: "componentOf" } + )pb"), + EqualsProto(R"pb( + label: "c2pa.ingredient.v3__2" + ingredient_v3 { title: "ingredient2" relationship: "componentOf" } + )pb"))); +} + +TEST_F(ManifestAssemblerTest, PlacedActionWithoutIngredients) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions { + placed { action_metadata { description: "test placed description" } } + } + } + ingredients { metadata { title: "ingredient0" } } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("missing ingredients in placed action"))); +} + +TEST_F(ManifestAssemblerTest, PlacedActionWithInvalidIngredientIndex) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions { + placed { + action_metadata { description: "test placed description" } + ingredient_indexes: 1 + } + } + } + ingredients { metadata { title: "ingredient0" } } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("ingredient index out of range"))); +} + +TEST_F(ManifestAssemblerTest, RepackagedActionWithoutOpenedIngredient) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + created {} + actions { + repackaged { + action_metadata { description: "test repackaged description" } + } + } + } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("requires an `opened` action"))); +} + +TEST_F(ManifestAssemblerTest, TranscodedActionWithoutOpenedIngredient) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + created {} + actions { + transcoded { + action_metadata { description: "test transcoded description" } + } + } + } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb( + label: "c2pa.actions.v2" + actions { + actions { action: "c2pa.created" } + actions { + action: "c2pa.transcoded" + description: "test transcoded description" + } + } + )pb")); +} + +TEST_F(ManifestAssemblerTest, RepackagedAction) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions { + repackaged { + action_metadata { description: "test repackaged description" } + } + } + } + ingredients { metadata { title: "ingredient0" } } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + actions { + action: "c2pa.repackaged" + description: "test repackaged description" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + )pb")); + EXPECT_THAT(manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { title: "ingredient0" relationship: "parentOf" } + )pb"))); +} + +TEST_F(ManifestAssemblerTest, TranscodedAction) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions { + transcoded { + action_metadata { description: "test transcoded description" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + } + ingredients { metadata { title: "ingredient0" } } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT( + manifest_box.actions_assertion, EqualsProto(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + actions { + action: "c2pa.transcoded" + description: "test transcoded description" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + )pb")); + EXPECT_THAT(manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { title: "ingredient0" relationship: "parentOf" } + )pb"))); +} + +// Tests simple actions with no ingredient references or other custom fields. +TEST_F(ManifestAssemblerTest, SimpleActions) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + created {} + actions { added_text { action_metadata { description: "added_text" } } } + actions { converted { action_metadata { description: "converted" } } } + actions { + edited_metadata { action_metadata { description: "edited_metadata" } } + } + actions { unknown { action_metadata { description: "unknown" } } } + } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb( + label: "c2pa.actions.v2" + actions { + actions { action: "c2pa.created" } + actions { action: "c2pa.addedText" description: "added_text" } + actions { action: "c2pa.converted" description: "converted" } + actions { + action: "c2pa.edited.metadata" + description: "edited_metadata" + } + actions { action: "c2pa.unknown" description: "unknown" } + } + )pb")); + EXPECT_THAT(manifest_box.ingredient_assertions, IsEmpty()); +} + +// Tests actions with digital source type. +TEST_F(ManifestAssemblerTest, DigitalSourceTypeActions) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + created {} + actions { + added_text { + action_metadata { description: "added_text" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + actions { + adjusted_color { + action_metadata { description: "adjusted_color" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + actions { + changed_speed { + action_metadata { description: "changed_speed" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + actions { + cropped { + action_metadata { description: "cropped" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + actions { + deleted { + action_metadata { description: "deleted" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + actions { + drawing { + action_metadata { description: "drawing" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + actions { + dubbed { + action_metadata { description: "dubbed" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + actions { + edited { + action_metadata { description: "edited" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + actions { + enhanced { + action_metadata { description: "enhanced" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + actions { + filtered { + action_metadata { description: "filtered" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + actions { + orientation { + action_metadata { description: "orientation" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + actions { + resized { + action_metadata { description: "resized" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + actions { + trimmed { + action_metadata { description: "trimmed" } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT( + manifest_box.actions_assertion, EqualsProto(R"pb( + label: "c2pa.actions.v2" + actions { + actions { action: "c2pa.created" } + actions { + action: "c2pa.addedText" + description: "added_text" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + actions { + action: "c2pa.adjustedColor" + description: "adjusted_color" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + actions { + action: "c2pa.changedSpeed" + description: "changed_speed" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + actions { + action: "c2pa.cropped" + description: "cropped" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + actions { + action: "c2pa.deleted" + description: "deleted" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + actions { + action: "c2pa.drawing" + description: "drawing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + actions { + action: "c2pa.dubbed" + description: "dubbed" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + actions { + action: "c2pa.edited" + description: "edited" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + actions { + action: "c2pa.enhanced" + description: "enhanced" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + actions { + action: "c2pa.filtered" + description: "filtered" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + actions { + action: "c2pa.orientation" + description: "orientation" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + actions { + action: "c2pa.resized" + description: "resized" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + actions { + action: "c2pa.trimmed" + description: "trimmed" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + } + } + )pb")); + EXPECT_THAT(manifest_box.ingredient_assertions, IsEmpty()); +} + +TEST_F(ManifestAssemblerTest, ConflictingIngredientRelationships) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions { + placed { + action_metadata { description: "test placed description" } + ingredient_indexes: 0 + } + } + } + ingredients { metadata { title: "ingredient0" } } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Inconsistent use of ingredient"))); +} + +TEST_F(ManifestAssemblerTest, HasCreatedAction) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + created { + action_metadata { + software_agent {} + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash {} + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + software_agent {} + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, HasCreatedActionWithInputToIngredients) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + created { + action_metadata { + software_agent {} + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + input_ingredient_indexes: 0 + input_ingredient_indexes: 1 + } + } + ingredients { metadata { title: "test ingredient 1" } } + ingredients { metadata { title: "test ingredient 2" } } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash {} + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + software_agent {} + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + } + } + } + )pb"), + .ingredient_assertions = + { + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient 1" + relationship: "inputTo" + } + )pb"), + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3__1" + ingredient_v3 { + title: "test ingredient 2" + relationship: "inputTo" + } + )pb"), + }, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, EditedActionWithInputToIngredient) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions { + edited { + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + input_ingredient_indexes: 1 + } + } + } + ingredients { metadata { title: "test ingredient 0" } } + ingredients { metadata { title: "test ingredient 1" } } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash {} + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + actions { + action: "c2pa.edited" + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + } + } + } + )pb"), + .ingredient_assertions = + { + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient 0" + relationship: "parentOf" + } + )pb"), + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3__1" + ingredient_v3 { + title: "test ingredient 1" + relationship: "inputTo" + } + )pb"), + }, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, FilteredActionWithInputToIngredient) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions { + filtered { + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + input_ingredient_indexes: 1 + } + } + } + ingredients { metadata { title: "test ingredient 0" } } + ingredients { metadata { title: "test ingredient 1" } } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash {} + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + actions { + action: "c2pa.filtered" + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + } + } + } + )pb"), + .ingredient_assertions = + { + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient 0" + relationship: "parentOf" + } + )pb"), + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3__1" + ingredient_v3 { + title: "test ingredient 1" + relationship: "inputTo" + } + )pb"), + }, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, ParentIngredientReferencedAsInputTo) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { ingredient_index: 0 } + actions { + edited { + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + input_ingredient_indexes: 0 + } + } + } + ingredients { metadata { title: "test ingredient 0" } } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Inconsistent use of ingredient"))); +} + +TEST_F(ManifestAssemblerTest, OpenedActionWithoutIngredientIndex) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + } + } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("requires an ingredient index."))); +} + +TEST_F(ManifestAssemblerTest, OpenedActionWithInvalidIngredientIndex) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + ingredient_index: 1 + } + } + ingredients { metadata { title: "test ingredient" } } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("ingredient index out of range"))); +} + +TEST_F(ManifestAssemblerTest, OpenedActionWithUnreferencedIngredient) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + ingredient_index: 1 + } + } + ingredients { metadata { title: "test ingredient" } } + ingredients { metadata { title: "test ingredient at index 1" } } + )pb"); + EXPECT_THAT( + manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("ingredient is not referenced by any action"))); +} + +TEST_F(ManifestAssemblerTest, HasSimpleOpenedAction) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + ingredient_index: 0 + } + } + ingredients { metadata { title: "test ingredient" } } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash {} + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + software_agent {} + description: "opened for testing" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + )pb"), + .ingredient_assertions = {ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { title: "test ingredient" relationship: "parentOf" } + )pb")}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, HasOpenedAction) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + ingredient_index: 0 + } + } + ingredients { + metadata { + title: "test ingredient" + media_type: "video/mp4" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash {} + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + software_agent {} + description: "opened for testing" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + )pb"), + .ingredient_assertions = {ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb")}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, HasC2paOpenedAction) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + ingredient_index: 0 + } + } + ingredients { + metadata { + title: "test ingredient" + media_type: "video/mp4" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + ingredient_validation_results { + manifest_store_bytes: "" + ingredient_validation_results { + active_manifest { successes { code: "test" } } + } + } + } + )pb"); + std::string ingredient_manifest_store_bytes = + CreateSimpleManifestStoreBytes(); + request.mutable_ingredients(0) + ->mutable_ingredient_validation_results() + ->set_manifest_store_bytes(ingredient_manifest_store_bytes); + ManifestStoreBuilder manifest_store_builder(&hash_factory_); + ABSL_ASSERT_OK(manifest_store_builder.AddIngredientManifestStore( + request.ingredients(0) + .ingredient_validation_results() + .manifest_store_bytes())); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash {} + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + software_agent {} + description: "opened for testing" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + )pb"), + .ingredient_assertions = {ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:11111111-1111-1111-1111-111111111111" + hash: "7#\034)n*\250\307>)(\263\336K\024\334R\002\'Q\252^\347\361\000\021T\334\256\320pf" + } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:11111111-1111-1111-1111-111111111111/c2pa.signature" + hash: "\017b6\027\253\306\235Q\350\246\311y\203sr@\256VE\267S\0137>\027#\377+\311U!l" + } + validation_results { active_manifest { successes { code: "test" } } } + } + )pb")}, + .manifest_store_builder = manifest_store_builder, + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, HasC2paOpenedActionNoManifestStoreBytes) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + ingredient_index: 0 + } + } + ingredients { + metadata { + title: "test ingredient" + media_type: "video/mp4" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + ingredient_validation_results {} + } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash {} + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + software_agent {} + description: "opened for testing" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + )pb"), + .ingredient_assertions = {ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb")}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); +} + +TEST_F(ManifestAssemblerTest, HasC2paOpenedActionMissingValidationResults) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + ingredient_index: 0 + } + } + ingredients { + metadata { + title: "test ingredient" + media_type: "video/mp4" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + ingredient_validation_results { manifest_store_bytes: "" } + } + )pb"); + std::string ingredient_manifest_store_bytes = + CreateSimpleManifestStoreBytes(); + request.mutable_ingredients(0) + ->mutable_ingredient_validation_results() + ->set_manifest_store_bytes(ingredient_manifest_store_bytes); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("validation results cannot be empty"))); +} + +TEST_F(ManifestAssemblerTest, MediaTypeFromIngredientValidationResults) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + opened { + action_metadata { + software_agent {} + description: "opened for testing" + } + ingredient_index: 0 + } + } + ingredients { + metadata { title: "test ingredient" } + ingredient_validation_results { media_type: "video/mp4" } + } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + EXPECT_THAT(manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + } + )pb"))); +} + +TEST_F(ManifestAssemblerTest, CreatesWithMultiPartAssertion) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { + bmff_based_hash {} + part_bindings { + bmff_based_hash {} + location { byte_offset: 0 length: 10 } + } + part_bindings { + bmff_based_hash {} + location { byte_offset: 10 length: 10 } + is_optional: true + } + } + actions { + created { + action_metadata { + software_agent {} + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"); + ManifestBox expected = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash {} + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + software_agent {} + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"), + .part_hash_assertions = {ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3.part" + bmff_based_hash {} + )pb"), + ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3.part__1" + bmff_based_hash {} + )pb")}, + .multi_part_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.multi-asset" + multi_asset_hash { + parts { + location { byte_offset: 0 length: 10 } + hash_assertion { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part" + } + is_optional: false + } + parts { + location { byte_offset: 10 length: 10 } + hash_assertion { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part__1" + } + is_optional: true + } + pad: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + pad2: "" + } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hash_factory_), + }; + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + + EXPECT_EQ(manifest_box.label, expected.label); + EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString()); + EXPECT_THAT(manifest_box.hard_binding_assertion, + EqualsProto(expected.hard_binding_assertion)); + EXPECT_THAT(manifest_box.actions_assertion, + EqualsProto(expected.actions_assertion)); + EXPECT_THAT(manifest_box.ingredient_assertions, + Pointwise(EqualsProto(), expected.ingredient_assertions)); + EXPECT_THAT(manifest_box.part_hash_assertions, + Pointwise(EqualsProto(), expected.part_hash_assertions)); + EXPECT_THAT(manifest_box.multi_part_assertion, + Optional(EqualsProto(*expected.multi_part_assertion))); +} + +TEST_F(ManifestAssemblerTest, EmptyGatheredAssertionLabelInvalid) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + created { + action_metadata { + software_agent {} + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + gathered_assertions { label: "" data: "{}" content_type: CONTENT_TYPE_JSON } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("non-empty label"))); +} + +TEST_F(ManifestAssemblerTest, InvalidGatheredAssertionContentTypeInvalid) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + created { + action_metadata { + software_agent {} + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + gathered_assertions { + label: "com.example.gathered" + data: "{}" + content_type: CONTENT_TYPE_UNSPECIFIED + } + )pb"); + EXPECT_THAT(manifest_assembler_->Assemble(request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("content type must be specified"))); +} + +TEST_F(ManifestAssemblerTest, PopulateGatheredAssertions) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { bmff_based_hash {} } + actions { + created { + action_metadata { + software_agent {} + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + gathered_assertions { + label: "com.example.gathered.1" + data: "{\"foo\": 1}" + content_type: CONTENT_TYPE_JSON + } + gathered_assertions { + label: "com.example.gathered.2" + data: "{\"bar\": 2}" + content_type: CONTENT_TYPE_JSON + } + )pb"); + auto manifest_box_or = manifest_assembler_->Assemble(request); + ABSL_ASSERT_OK(manifest_box_or); + const ManifestBox& manifest_box = *manifest_box_or; + + ASSERT_EQ(manifest_box.gathered_assertions.size(), 2); + EXPECT_THAT(manifest_box.gathered_assertions, + Pointwise(EqualsProto(), request.gathered_assertions())); +} + +} // namespace +} // namespace credentio
diff --git a/claim/manifest_box_encoder.cc b/claim/manifest_box_encoder.cc new file mode 100644 index 0000000..d93c169 --- /dev/null +++ b/claim/manifest_box_encoder.cc
@@ -0,0 +1,272 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "claim/manifest_box_encoder.h" + +#include <string> +#include <utility> +#include <vector> + +#include "absl/container/flat_hash_map.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "assertion/assertion_referencer.h" +#include "async/async_context.h" +#include "claim/signer.h" +#include "constants/labels.h" +#include "jumbf/box_builder.h" +#include "jumbf/constants.h" +#include "proto/actions_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/manifest.pb.h" +#include "proto/multi_asset_hash_assertion.pb.h" +#include "uuid/uuid.h" + +namespace credentio { +namespace { + +absl::StatusOr<jumbf::BuiltSuperBox> SerializeAssertionStore( + std::vector<jumbf::BuiltSuperBox> assertions) { + jumbf::SuperBoxBuilder assertion_store_builder( + kAssertionStoreUuid, {.label = absl::Cord(kAssertionStoreLabel)}); + for (jumbf::BuiltSuperBox& assertion_box : assertions) { + ABSL_RETURN_IF_ERROR( + assertion_store_builder.AddChild(std::move(assertion_box))); + } + return std::move(assertion_store_builder).Finalize(); +} + +absl::StatusOr<jumbf::BuiltSuperBox> SerializeCborSuperBox( + const Uuid& uuid, absl::string_view label, absl::Cord cbor_content) { + jumbf::SuperBoxBuilder builder(uuid, {.label = absl::Cord(label)}); + ABSL_RETURN_IF_ERROR( + builder.AddContent(jumbf::kCborBoxType, std::move(cbor_content))); + return std::move(builder).Finalize(); +} + +struct SignatureBoxResult { + jumbf::BuiltSuperBox box; +}; + +void MakeSignatureBox( + absl::string_view claim_cbor, const ClaimSigner& claim_signer, + const ClaimSigner::Options& options, + absl::AnyInvocable<void(absl::StatusOr<SignatureBoxResult>) &&> cb) { + AsyncResolver<SignatureBoxResult> resolver(options.async_context, + std::move(cb)); + auto done_cb = + [resolver = std::move(resolver)]( + absl::StatusOr<ClaimSigner::SignResult> signature_status) mutable { + if (!signature_status.ok()) { + resolver.Return(signature_status.status()); + return; + } + absl::StatusOr<jumbf::BuiltSuperBox> signature_box = + SerializeCborSuperBox( + kClaimSignatureUuid, kClaimSignatureLabel, + absl::Cord(std::move(signature_status->signature))); + if (!signature_box.ok()) { + resolver.Return(signature_box.status()); + return; + } + resolver.Return(SignatureBoxResult{ + .box = std::move(*signature_box), + }); + }; + claim_signer.Sign(claim_cbor, options, std::move(done_cb)); +} + +} // namespace + +absl::Status ManifestBoxEncoder::PopulateMultiAssetPartHashes( + MultiAssetHashAssertion* assertion) const { + for (auto& part : *assertion->mutable_parts()) { + absl::string_view url = part.hash_assertion().url(); + auto it = assertion_url_to_index_.find(url); + if (it == assertion_url_to_index_.end()) { + return absl::InternalError( + absl::StrCat("Could not find hash for url: ", url)); + } + *part.mutable_hash_assertion() = assertion_hashed_uris_[it->second]; + } + return absl::OkStatus(); +} + +absl::Status ManifestBoxEncoder::AddAssertion(Assertion& assertion) { + if (assertion.has_multi_asset_hash()) { + ABSL_RETURN_IF_ERROR( + PopulateMultiAssetPartHashes(assertion.mutable_multi_asset_hash())); + } + + ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox assertion_box, + assertion_encoder_.ToJumbf(assertion)); + ABSL_ASSIGN_OR_RETURN(HashedUri assertion_hashed_uri, + assertion_reference_.Generate(assertion_box)); + assertion_url_to_index_[assertion_hashed_uri.url()] = + assertion_hashed_uris_.size(); + assertion_hashed_uris_.Add(std::move(assertion_hashed_uri)); + assertion_boxes_.push_back(std::move(assertion_box)); + return absl::OkStatus(); +} + +absl::Status ManifestBoxEncoder::AddActionsAssertion(Assertion assertion) { + for (ActionItem& action : *assertion.mutable_actions()->mutable_actions()) { + if (action.action() == "c2pa.opened" && + action.parameters().ingredients_size() != 1) { + return absl::InvalidArgumentError( + "Opened action must have exactly one ingredient."); + } + if (action.parameters().ingredients_size() != 0) { + for (auto& ingredient : + *action.mutable_parameters()->mutable_ingredients()) { + auto it = assertion_url_to_index_.find(ingredient.url()); + if (it == assertion_url_to_index_.end()) { + return absl::InternalError(absl::StrCat( + "Could not find hash for ingredient url: ", ingredient.url())); + } + ingredient = assertion_hashed_uris_[it->second]; + } + } + if (action.parameters().has_redacted_uri()) { + redacted_assertions_.emplace_back(action.parameters().redacted_uri()); + } + } + return AddAssertion(assertion); +} + +absl::Status ManifestBoxEncoder::AddGatheredAssertion( + const GatheredAssertion& gathered_assertion) { + if (gathered_assertion.content_type() != + GatheredAssertion::CONTENT_TYPE_JSON) { + return absl::InvalidArgumentError( + "Gathered assertion content type must be JSON."); + } + jumbf::SuperBoxBuilder builder( + jumbf::kJsonBoxTypeUuid, + {.label = absl::Cord(gathered_assertion.label())}); + ABSL_RETURN_IF_ERROR(builder.AddContent( + jumbf::kJsonBoxType, absl::Cord(gathered_assertion.data()))); + ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox assertion_box, + std::move(builder).Finalize()); + ABSL_ASSIGN_OR_RETURN(HashedUri assertion_hashed_uri, + assertion_reference_.Generate(assertion_box)); + gathered_assertion_hashed_uris_.Add(std::move(assertion_hashed_uri)); + assertion_boxes_.push_back(std::move(assertion_box)); + return absl::OkStatus(); +} + +absl::Status ManifestBoxEncoder::AddAssertions() { + // Note that we must add an assertion before adding any other assertion that + // references it, so the referenced assertion's hash is available. So, for + // example, ingredient assertions must be added before actions assertions + // since some actions assertions reference ingredient assertions. + for (Assertion& assertion : manifest_.ingredient_assertions) { + ABSL_RETURN_IF_ERROR(AddAssertion(assertion)); + } + ABSL_RETURN_IF_ERROR(AddActionsAssertion(manifest_.actions_assertion)); + ABSL_RETURN_IF_ERROR(AddAssertion(manifest_.hard_binding_assertion)); + for (Assertion& assertion : manifest_.part_hash_assertions) { + ABSL_RETURN_IF_ERROR(AddAssertion(assertion)); + } + if (manifest_.multi_part_assertion.has_value()) { + ABSL_RETURN_IF_ERROR(AddAssertion(*manifest_.multi_part_assertion)); + } + for (const auto& gathered_assertion : manifest_.gathered_assertions) { + ABSL_RETURN_IF_ERROR(AddGatheredAssertion(gathered_assertion)); + } + return absl::OkStatus(); +} + +void ManifestBoxEncoder::EncodeClaimAndSignature( + const ClaimSigner::Options& options, + absl::AnyInvocable<void(absl::StatusOr<ManifestEncoderResult>) &&> cb) { + AsyncResolver<ManifestEncoderResult> resolver(options.async_context, + std::move(cb)); + Claim claim; + claim.set_instance_id(manifest_.instance_id); + *claim.mutable_claim_generator_info() = generator_info_; + claim.mutable_created_assertions()->Swap(&assertion_hashed_uris_); + claim.mutable_gathered_assertions()->Swap(&gathered_assertion_hashed_uris_); + for (const std::string& redacted_assertion : redacted_assertions_) { + *claim.add_redacted_assertions() = redacted_assertion; + } + claim.set_signature(kClaimSignatureUrl); + claim.set_default_algorithm(absl::StrCat(hash_algorithm_)); + + auto prepare_claim = [&]() -> absl::StatusOr<std::string> { + ABSL_ASSIGN_OR_RETURN(std::string claim_cbor, + claim_cbor_encoder_.ToCbor(claim)); + ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox claim_box, + SerializeCborSuperBox(kClaimUuid, kClaimV2Label, + absl::Cord(claim_cbor))); + ABSL_RETURN_IF_ERROR(manifest_builder_.AddChild(std::move(claim_box))); + return claim_cbor; + }; + absl::StatusOr<std::string> claim_cbor = prepare_claim(); + if (!claim_cbor.ok()) { + resolver.Return(claim_cbor.status()); + return; + } + auto done_cb = + [this, resolver = std::move(resolver)]( + absl::StatusOr<SignatureBoxResult> claim_signature) mutable { + auto finalize_manifest = + [&]() -> absl::StatusOr<ManifestEncoderResult> { + ABSL_RETURN_IF_ERROR(claim_signature.status()); + ABSL_RETURN_IF_ERROR( + manifest_builder_.AddChild(std::move(claim_signature->box))); + ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox active_manifest, + std::move(manifest_builder_).Finalize()); + ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox finalized_manifest_store, + std::move(manifest_.manifest_store_builder) + .Finalize(std::move(active_manifest))); + return ManifestEncoderResult{ + .box = std::move(finalized_manifest_store), + }; + }; + resolver.Return(finalize_manifest()); + }; + MakeSignatureBox(*claim_cbor, claim_signer_, options, std::move(done_cb)); +} + +absl::Status ManifestBoxEncoder::EncodeAssertionStore() { + ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox assertion_store, + SerializeAssertionStore(std::move(assertion_boxes_))); + return manifest_builder_.AddChild(std::move(assertion_store)); +} + +void ManifestBoxEncoder::Encode( + const ClaimSigner::Options& options, + absl::AnyInvocable<void(absl::StatusOr<ManifestEncoderResult>) &&> cb) { + auto add_assertions_status = AddAssertions(); + if (!add_assertions_status.ok()) { + std::move(cb)(add_assertions_status); + return; + } + auto encode_assertion_store_status = EncodeAssertionStore(); + if (!encode_assertion_store_status.ok()) { + std::move(cb)(encode_assertion_store_status); + return; + } + EncodeClaimAndSignature(options, std::move(cb)); +} +} // namespace credentio
diff --git a/claim/manifest_box_encoder.h b/claim/manifest_box_encoder.h new file mode 100644 index 0000000..b78550d --- /dev/null +++ b/claim/manifest_box_encoder.h
@@ -0,0 +1,134 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_ENCODER_H_ +#define THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_ENCODER_H_ + +#include <cstddef> +#include <string> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/container/flat_hash_map.h" +#include "absl/functional/any_invocable.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "assertion/assertion_encoder.h" +#include "assertion/assertion_referencer.h" +#include "claim/claim_cbor_encoder.h" +#include "claim/manifest_box.h" +#include "claim/signer.h" +#include "constants/labels.h" +#include "crypto/algorithms.h" +#include "google/protobuf/repeated_ptr_field.h" +#include "jumbf/box_builder.h" +#include "proto/assertion.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/multi_asset_hash_assertion.pb.h" + +namespace credentio { + +struct ManifestEncoderResult { + // Generated JUMBF manifest store superbox. + jumbf::BuiltSuperBox box; +}; + +class ManifestBoxEncoder { + public: + ManifestBoxEncoder( + const AssertionReferencer* absl_nonnull assertion_reference, + const GeneratorInfo* absl_nonnull generator_info, + const ClaimSigner* absl_nonnull claim_signer, + const AssertionEncoder* absl_nonnull assertion_cbor_encoder, + const ClaimCborEncoder* absl_nonnull claim_cbor_encoder, + ManifestBox* absl_nonnull manifest, HashAlgorithm hash_algorithm) + : assertion_reference_(*ABSL_DIE_IF_NULL(assertion_reference)), + generator_info_(*ABSL_DIE_IF_NULL(generator_info)), + claim_signer_(*ABSL_DIE_IF_NULL(claim_signer)), + manifest_builder_(kStandardManifestUuid, + {.label = absl::Cord(manifest->label)}), + manifest_(*ABSL_DIE_IF_NULL(manifest)), + hash_algorithm_(hash_algorithm), + assertion_encoder_(*ABSL_DIE_IF_NULL(assertion_cbor_encoder)), + claim_cbor_encoder_(*ABSL_DIE_IF_NULL(claim_cbor_encoder)) {} + + // Encodes the manifest to a JUMBF-type box. + // Consumes the provided ManifestBox. + // Encodings are done in the following order: + // 1. Build all assertions, resolving references between them. + // 2. Encode the claim and signature. + // 3. Encode the assertion store. + void Encode( + const ClaimSigner::Options& options, + absl::AnyInvocable<void(absl::StatusOr<ManifestEncoderResult>) &&> cb); + + private: + // Encodes the given assertion and adds it to assertion_boxes_, + // assertion_hashed_uris_, and assertion_url_to_index_. + absl::Status AddAssertion(Assertion& assertion); + + // Populates hashes for hashed URIs in the given actions assertion, then adds + // it via AddAssertion. + absl::Status AddActionsAssertion(Assertion assertion); + + // Builds all assertions in the manifest and resolves references between + // them, populating assertion_boxes_, assertion_hashed_uris_, and + // assertion_url_to_index_. + absl::Status AddAssertions(); + + // Encodes and signs the claim, adding claim and claim signature boxes to + // manifest_builder_. + void EncodeClaimAndSignature( + const ClaimSigner::Options& options, + absl::AnyInvocable<void(absl::StatusOr<ManifestEncoderResult>) &&> cb); + + // Adds the assertion store box to manifest_builder_. + absl::Status EncodeAssertionStore(); + + // Populates hashed URIs in the given multi-asset hash assertion. + // Assumes that the referenced part hash assertions have already been added. + absl::Status PopulateMultiAssetPartHashes( + MultiAssetHashAssertion* assertion) const; + + // Encodes a gathered assertion as a JSON content superbox, generates its + // hashed URI, adds the box to assertion_boxes_, and adds the hashed URI to + // gathered_assertion_hashed_uris_. + absl::Status AddGatheredAssertion( + const GatheredAssertion& gathered_assertion); + + const AssertionReferencer& assertion_reference_; + const GeneratorInfo& generator_info_; + const ClaimSigner& claim_signer_; + jumbf::SuperBoxBuilder manifest_builder_; + ManifestBox& manifest_; + // Redacted assertions to be added to the claim. + std::vector<std::string> redacted_assertions_; + std::vector<jumbf::BuiltSuperBox> assertion_boxes_; + google::protobuf::RepeatedPtrField<HashedUri> assertion_hashed_uris_; + google::protobuf::RepeatedPtrField<HashedUri> gathered_assertion_hashed_uris_; + // Map from relative assertion URL (e.g. + // "self#jumbf=c2pa.assertions/c2pa.actions.v2") to index in assertion_uris_. + // Needed for resolving references between assertions. + absl::flat_hash_map<std::string, size_t> assertion_url_to_index_; + HashAlgorithm hash_algorithm_; + const AssertionEncoder& assertion_encoder_; + const ClaimCborEncoder& claim_cbor_encoder_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_ENCODER_H_
diff --git a/claim/manifest_box_encoder_test.cc b/claim/manifest_box_encoder_test.cc new file mode 100644 index 0000000..d03ed35 --- /dev/null +++ b/claim/manifest_box_encoder_test.cc
@@ -0,0 +1,1980 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "claim/manifest_box_encoder.h" + +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/absl_check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_join.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "assertion/assertion_encoder.h" +#include "assertion/assertion_referencer.h" +#include "async/async_context.h" +#include "claim/claim_cbor_encoder.h" +#include "claim/local_signer.h" +#include "claim/manifest_box.h" +#include "claim/manifest_store_builder.h" +#include "claim/signer.h" +#include "constants/labels.h" +#include "crypto/algorithms.h" +#include "crypto/crypto_read_handler.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/default/default_crypto_read_handler.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "gmock/gmock.h" +#include "google/protobuf/util/message_differencer.h" +#include "gtest/gtest.h" +#include "jumbf/box_builder.h" +#include "proto/actions_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "proto/generator.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "proto/signature_info.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "testing/create_test_crypto_write_handler.h" +#include "testing/local_key_crypto_write_handler.h" +#include "testing/manifest_store_test_utils.h" +#include "testing/proto_test_utils.h" +#include "uuid/uuid.h" + +namespace credentio { +namespace { +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::HasSubstr; +using ::testing::Pointee; + +constexpr auto kUuid = + Uuid::FromStringOrDie("12345678-1234-1234-1234-123456789012"); + +MATCHER_P(EqualsProtoIgnoringFieldsAndOrdering, expected, "") { + google::protobuf::util::MessageDifferencer differencer; + differencer.set_repeated_field_comparison( + google::protobuf::util::MessageDifferencer::AS_SET); + differencer.IgnoreField(HashedUri::descriptor()->FindFieldByName("hash")); + differencer.IgnoreField( + SignatureInfo::descriptor()->FindFieldByName("cert_serial_number")); + differencer.IgnoreField( + SignatureInfo::descriptor()->FindFieldByName("certificate_issuer")); + differencer.IgnoreField( + SignatureInfo::descriptor()->FindFieldByName("timestamping_time")); + std::string diff; + differencer.ReportDifferencesToString(&diff); + bool match = differencer.Compare(expected, arg); + if (!match) { + *result_listener << "\nDifference:\n" << diff; + } + return match; +} + +ClaimSigner::Options ToClaimSignerOptions( + const GenerateManifestStoreOptions& options, AsyncContext context) { + ClaimSigner::Options result; + result.async_context = std::move(context); + switch (options.time_stamping_mode()) { + case GenerateManifestStoreOptions::TIME_STAMPING_REQUIRED: + result.time_stamping_mode = TimeStampingMode::kRequired; + break; + case GenerateManifestStoreOptions::TIME_STAMPING_OMITTED_WITH_PADDING: + result.time_stamping_mode = TimeStampingMode::kOmittedWithPadding; + break; + case GenerateManifestStoreOptions::TIME_STAMPING_OPTIONAL: + case GenerateManifestStoreOptions::TIME_STAMPING_MODE_UNSPECIFIED: + default: + result.time_stamping_mode = TimeStampingMode::kOptional; + break; + } + return result; +} + +absl::StatusOr<jumbf::BuiltSuperBox> Encode( + ManifestBoxEncoder& manifest_box_encoder, + const GenerateManifestStoreOptions& options = {}) { + absl::Notification done; + absl::StatusOr<ManifestEncoderResult> result; + manifest_box_encoder.Encode( + ToClaimSignerOptions(options, AsyncContext()), + [&result, &done](absl::StatusOr<ManifestEncoderResult> res) { + result = std::move(res); + done.Notify(); + }); + done.WaitForNotification(); + if (!result.ok()) { + return result.status(); + } + return std::move(result->box); +} + +class FakeUuidGenerator : public UuidGenerator { + public: + FakeUuidGenerator() = default; + + Uuid Generate() const override { return kUuid; } +}; + +class FakeHasherFactory : public HasherFactory { + public: + absl::StatusOr<std::unique_ptr<Hasher>> Create() const override { + return CreateHasher("sha256"); + } + HashAlgorithm algorithm() const override { return HashAlgorithm::kSha256; } +}; + +class ManifestBoxEncoderTest : public testing::Test { + protected: + ManifestBoxEncoderTest() + : assertion_cbor_encoder_(), claim_cbor_encoder_(GetClaimCborEncoder()) { + generator_info_.set_name("Test generator"); + generator_info_.set_spec_version("2.2.0"); + auto crypto_writer = CreateTestCryptoWriteHandler({}); + ABSL_CHECK_OK(crypto_writer); + crypto_writer_ = std::move(*crypto_writer); + } + std::unique_ptr<CryptoWriteHandler> crypto_writer_; + GeneratorInfo generator_info_; + FakeHasherFactory hasher_factory_; + StandardAssertionEncoder assertion_cbor_encoder_; + const ClaimCborEncoder& claim_cbor_encoder_; + + ManifestBox CreateMinimalManifestBox() { + return { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "12345678-1234-1234-1234-123456789012", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + software_agent { name: "test generator" } + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_), + }; + } +}; + +TEST_F(ManifestBoxEncoderTest, GetEncodedManifest) { + ManifestBox manifest_box = CreateMinimalManifestBox(); + PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie( + R"pb( + active_manifest { + label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + claim { + claim_generator_info { + name: "Test generator" + spec_version: "2.2.0" + } + signature_info { + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3" + algorithm: "sha256" + hash: "D\242\244\274\235o\350\352\312\307\225|C\033\204\347\311\243\202\'\203c\362\331T1a\377P\271\255\366" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\331\271\373\3228\310\335>y\263\334\342\335\367\211Q\360\325\227\r\345$]\214\005\3047[\r#1\312" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "12345678-1234-1234-1234-123456789012" + } + assertions { + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + software_agent { name: "test generator" } + } + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + )pb"); + AssertionReferencer assertion_reference(&hasher_factory_); + LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box = + Encode(manifest_box_encoder); + ABSL_ASSERT_OK(built_manifest_store_box); + absl::Cord result_manifest_store_bytes = + std::move(*built_manifest_store_box).AsCord(); + EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result.active_manifest().claim()))); + EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result)))); +} + +TEST_F(ManifestBoxEncoderTest, TimeStampingModeUnspecified) { + ManifestBox manifest_box = CreateMinimalManifestBox(); + AssertionReferencer assertion_reference(&hasher_factory_); + auto crypto_writer = + CreateTestCryptoWriteHandler({.disable_timestamping = true}); + ABSL_ASSERT_OK(crypto_writer); + LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + GenerateManifestStoreOptions options; + options.set_time_stamping_mode( + GenerateManifestStoreOptions::TIME_STAMPING_MODE_UNSPECIFIED); + // Should default to OPTIONAL, so even if timestamping fails, encode succeeds. + EXPECT_THAT(Encode(manifest_box_encoder, options), IsOk()); +} + +TEST_F(ManifestBoxEncoderTest, TimeStampingModeOptional) { + ManifestBox manifest_box = CreateMinimalManifestBox(); + AssertionReferencer assertion_reference(&hasher_factory_); + auto crypto_writer = + CreateTestCryptoWriteHandler({.disable_timestamping = true}); + ABSL_ASSERT_OK(crypto_writer); + LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + GenerateManifestStoreOptions options; + options.set_time_stamping_mode( + GenerateManifestStoreOptions::TIME_STAMPING_OPTIONAL); + // Timestamping fails, but mode is OPTIONAL, so encode succeeds. + EXPECT_THAT(Encode(manifest_box_encoder, options), IsOk()); +} + +TEST_F(ManifestBoxEncoderTest, TimeStampingModeRequiredSuccess) { + ManifestBox manifest_box = CreateMinimalManifestBox(); + AssertionReferencer assertion_reference(&hasher_factory_); + auto crypto_writer = + CreateTestCryptoWriteHandler({.disable_timestamping = false}); + ABSL_ASSERT_OK(crypto_writer); + LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + GenerateManifestStoreOptions options; + options.set_time_stamping_mode( + GenerateManifestStoreOptions::TIME_STAMPING_REQUIRED); + EXPECT_THAT(Encode(manifest_box_encoder, options), IsOk()); +} + +TEST_F(ManifestBoxEncoderTest, TimeStampingModeRequiredFailure) { + ManifestBox manifest_box = CreateMinimalManifestBox(); + AssertionReferencer assertion_reference(&hasher_factory_); + auto crypto_writer = + CreateTestCryptoWriteHandler({.disable_timestamping = true}); + ABSL_ASSERT_OK(crypto_writer); + LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + GenerateManifestStoreOptions options; + options.set_time_stamping_mode( + GenerateManifestStoreOptions::TIME_STAMPING_REQUIRED); + // Timestamping fails, and mode is REQUIRED, so encode fails. + EXPECT_THAT( + Encode(manifest_box_encoder, options), + StatusIs(absl::StatusCode::kUnavailable, HasSubstr("TSA is disabled"))); +} + +TEST_F(ManifestBoxEncoderTest, TimeStampingModeOmittedWithPadding) { + ManifestBox manifest_box = CreateMinimalManifestBox(); + AssertionReferencer assertion_reference(&hasher_factory_); + // This should not cause a failure because timestamping should be skipped. + auto crypto_writer = + CreateTestCryptoWriteHandler({.disable_timestamping = true}); + ABSL_ASSERT_OK(crypto_writer); + LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + GenerateManifestStoreOptions options; + options.set_time_stamping_mode( + GenerateManifestStoreOptions::TIME_STAMPING_OMITTED_WITH_PADDING); + EXPECT_THAT(Encode(manifest_box_encoder, options), IsOk()); +} + +TEST_F(ManifestBoxEncoderTest, + EncodeManifestWithInvalidOpenedActionNoIngredients) { + ManifestBox manifest_box = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "12345678-1234-1234-1234-123456789012", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + software_agent { name: "test generator" } + description: "created for testing" + } + } + )pb"), + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_), + }; + AssertionReferencer assertion_reference(&hasher_factory_); + LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + EXPECT_THAT(Encode(manifest_box_encoder), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Opened action must have exactly one " + "ingredient."))); +} + +TEST_F(ManifestBoxEncoderTest, + EncodeManifestWithInvalidOpenedActionMultipleIngredients) { + ManifestBox manifest_box = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "12345678-1234-1234-1234-123456789012", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + software_agent { name: "test generator" } + description: "created for testing" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + } + } + } + )pb"), + .ingredient_assertions = + { + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb"), + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3__1" + ingredient_v3 { + title: "test ingredient 2" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb"), + }, + .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_), + }; + AssertionReferencer assertion_reference(&hasher_factory_); + LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + EXPECT_THAT(Encode(manifest_box_encoder), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Opened action must have exactly one " + "ingredient."))); +} + +TEST_F(ManifestBoxEncoderTest, EncodedManifestWithCreatedActionIngredient) { + ManifestBox manifest_box = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "12345678-1234-1234-1234-123456789012", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + software_agent { name: "test generator" } + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + } + } + } + )pb"), + .ingredient_assertions = + {ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient 1" + media_type: "video/mp4" + relationship: "inputTo" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb"), + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3__1" + ingredient_v3 { + title: "test ingredient 2" + media_type: "video/mp4" + relationship: "inputTo" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb")}, + .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_), + }; + PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie( + R"pb( + active_manifest { + label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + claim { + claim_generator_info { + name: "Test generator" + spec_version: "2.2.0" + } + signature_info { + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3" + algorithm: "sha256" + hash: "D\242\244\274\235o\350\352\312\307\225|C\033\204\347\311\243\202\'\203c\362\331T1a\377P\271\255\366" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\331\271\373\3228\310\335>y\263\334\342\335\367\211Q\360\325\227\r\345$]\214\005\3047[\r#1\312" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "12345678-1234-1234-1234-123456789012" + } + assertions { + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + software_agent { name: "test generator" } + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient 1" + media_type: "video/mp4" + relationship: "inputTo" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + } + assertions { + label: "c2pa.ingredient.v3__1" + ingredient_v3 { + title: "test ingredient 2" + media_type: "video/mp4" + relationship: "inputTo" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3__1" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + )pb"); + AssertionReferencer assertion_reference(&hasher_factory_); + LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box = + Encode(manifest_box_encoder); + ABSL_ASSERT_OK(built_manifest_store_box); + absl::Cord result_manifest_store_bytes = + std::move(*built_manifest_store_box).AsCord(); + EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result.active_manifest().claim()))); + EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result)))); +} + +TEST_F(ManifestBoxEncoderTest, EncodeManifestWithPlacedActionIngredients) { + ManifestBox manifest_box = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "12345678-1234-1234-1234-123456789012", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + software_agent { name: "test generator" } + description: "created for testing" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + actions { + action: "c2pa.placed" + software_agent { name: "test generator" } + description: "placed for testing" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2" + } + } + } + } + )pb"), + .ingredient_assertions = + { + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb"), + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3__1" + ingredient_v3 { + title: "test ingredient 2" + media_type: "video/mp4" + relationship: "componentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb"), + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3__2" + ingredient_v3 { + title: "test ingredient 3" + media_type: "video/mp4" + relationship: "componentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb"), + }, + .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_), + }; + PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie( + R"pb( + active_manifest { + label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + claim { + claim_generator_info { + name: "Test generator" + spec_version: "2.2.0" + } + signature_info { + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3" + algorithm: "sha256" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "12345678-1234-1234-1234-123456789012" + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + description: "created for testing" + software_agent { name: "test generator" } + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + actions { + action: "c2pa.placed" + description: "placed for testing" + software_agent { name: "test generator" } + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + } + assertions { + label: "c2pa.ingredient.v3__1" + ingredient_v3 { + title: "test ingredient 2" + media_type: "video/mp4" + relationship: "componentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + } + assertions { + label: "c2pa.ingredient.v3__2" + ingredient_v3 { + title: "test ingredient 3" + media_type: "video/mp4" + relationship: "componentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + } + assertions { + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3__1" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3__2" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + informationals { + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3" + } + informationals { + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3__1" + } + informationals { + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3__2" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + )pb"); + AssertionReferencer assertion_reference(&hasher_factory_); + LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box = + Encode(manifest_box_encoder); + ABSL_ASSERT_OK(built_manifest_store_box); + absl::Cord result_manifest_store_bytes = + std::move(*built_manifest_store_box).AsCord(); + EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result.active_manifest().claim()))); + EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result)))); +} + +TEST_F(ManifestBoxEncoderTest, EncodeManifestWithOpenedAction) { + ManifestBox manifest_box = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "12345678-1234-1234-1234-123456789012", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + software_agent { name: "test generator" } + description: "created for testing" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + )pb"), + .ingredient_assertions = + { + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb"), + }, + .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_), + }; + PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie( + R"pb( + active_manifest { + label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + claim { + claim_generator_info { + name: "Test generator" + spec_version: "2.2.0" + } + signature_info { + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\202\377\224T\003\374\022X)\214\244z\337\002\020Wd\351\261\236\324\205\345T\321\267u\216\227]\332\014" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "\300\273\\<\213\017\004\226\266\032\210!\274~\013A\274+\200\215\333[\270#\035\014\247\371\356i\006\214" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3" + algorithm: "sha256" + hash: "D\242\244\274\235o\350\352\312\307\225|C\033\204\347\311\243\202\'\203c\362\331T1a\377P\271\255\366" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "12345678-1234-1234-1234-123456789012" + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + description: "created for testing" + software_agent { name: "test generator" } + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + hash: "\300\273\\<\213\017\004\226\266\032\210!\274~\013A\274+\200\215\333[\270#\035\014\247\371\356i\006\214" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + } + assertions { + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + informationals { + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + )pb"); + AssertionReferencer assertion_reference(&hasher_factory_); + LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box = + Encode(manifest_box_encoder); + ABSL_ASSERT_OK(built_manifest_store_box); + absl::Cord result_manifest_store_bytes = + std::move(*built_manifest_store_box).AsCord(); + EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result.active_manifest().claim()))); + EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result)))); +} + +TEST_F(ManifestBoxEncoderTest, EncodeManifestWithC2paOpenedAction) { + FakeUuidGenerator uuid_generator; + SimpleManifestStoreOptions options = { + .uuid_generator = &uuid_generator, + }; + absl::StatusOr<std::string> ingredient_manifest_store_bytes = + CreateSimpleManifestStoreBytes(options); + ABSL_ASSERT_OK(ingredient_manifest_store_bytes); + + ManifestStoreBuilder manifest_store_builder(&hasher_factory_); + absl::StatusOr<IngredientManifestUris> ingredient_manifest_uris = + manifest_store_builder.AddIngredientManifestStore( + *ingredient_manifest_store_bytes); + ABSL_ASSERT_OK(ingredient_manifest_uris); + ManifestBox manifest_box = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + software_agent { name: "test generator" } + description: "created for testing" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + )pb"), + .ingredient_assertions = + { + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb"), + }, + .manifest_store_builder = manifest_store_builder, + }; + *manifest_box.ingredient_assertions[0] + .mutable_ingredient_v3() + ->mutable_active_manifest() = ingredient_manifest_uris->active_manifest; + manifest_box.ingredient_assertions[0] + .mutable_ingredient_v3() + ->mutable_validation_results() + ->mutable_active_manifest() + ->add_successes() + ->set_code("assertion.dataHash.match"); + *manifest_box.ingredient_assertions[0] + .mutable_ingredient_v3() + ->mutable_claim_signature() = ingredient_manifest_uris->claim_signature; + PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie( + R"pb( + active_manifest { + label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + claim { + claim_generator_info { + name: "Test generator" + spec_version: "2.2.0" + } + signature_info { + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "03020100-0504-0706-0908-0f0e0d0c0b0a" + } + assertions { + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012" + } + validation_results { + active_manifest { + successes { code: "assertion.dataHash.match" } + } + } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + description: "created for testing" + software_agent { name: "test generator" } + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2" + } + successes { + code: "ingredient.claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + } + } + ingredient_manifests { + label: "urn:c2pa:12345678-1234-1234-1234-123456789012" + claim { + claim_generator_info { name: "Test C2PA Generator" } + signature_info { + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.boxes" + algorithm: "sha256" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "12345678-1234-1234-1234-123456789012" + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + software_agent { name: "test generator" } + } + } + } + assertions { + label: "c2pa.hash.boxes" + boxes_hash { + boxes { names: "some name" names: "other name" hash: "some hash" } + alg: "some alg" + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.actions.v2" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.hash.boxes" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + )pb"); + AssertionReferencer assertion_reference(&hasher_factory_); + LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box = + Encode(manifest_box_encoder); + ABSL_ASSERT_OK(built_manifest_store_box); + absl::Cord result_manifest_store_bytes = + std::move(*built_manifest_store_box).AsCord(); + EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result.active_manifest().claim()))); + EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result)))); +} + +TEST_F(ManifestBoxEncoderTest, EncodeManifestWithRedactions) { + FakeUuidGenerator uuid_generator; + SimpleManifestStoreOptions options = { + .uuid_generator = &uuid_generator, + }; + absl::StatusOr<std::string> ingredient_manifest_store_bytes = + CreateSimpleManifestStoreBytes(options); + ABSL_ASSERT_OK(ingredient_manifest_store_bytes); + + ManifestStoreBuilder manifest_store_builder(&hasher_factory_); + absl::StatusOr<IngredientManifestUris> ingredient_manifest_uris = + manifest_store_builder.AddIngredientManifestStore( + *ingredient_manifest_store_bytes); + ABSL_ASSERT_OK(ingredient_manifest_uris); + ManifestBox manifest_box = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "03020100-0504-0706-0908-0f0e0d0c0b0a", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + software_agent { name: "test generator" } + description: "created for testing" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + actions { + action: "c2pa.redacted" + description: "redacted for testing" + } + } + )pb"), + .ingredient_assertions = + { + ParseTextProtoOrDie(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + )pb"), + }, + .manifest_store_builder = manifest_store_builder, + }; + std::string redacted_uri = + absl::StrJoin({ingredient_manifest_uris->active_manifest.url(), + kAssertionStoreLabel, kIngredientAssertionV3Label}, + kManifestLabelDelimiter); + manifest_box.actions_assertion.mutable_actions() + ->mutable_actions(1) + ->mutable_parameters() + ->set_redacted_uri(redacted_uri); + *manifest_box.ingredient_assertions[0] + .mutable_ingredient_v3() + ->mutable_active_manifest() = ingredient_manifest_uris->active_manifest; + manifest_box.ingredient_assertions[0] + .mutable_ingredient_v3() + ->mutable_validation_results() + ->mutable_active_manifest() + ->add_successes() + ->set_code("assertion.dataHash.match"); + *manifest_box.ingredient_assertions[0] + .mutable_ingredient_v3() + ->mutable_claim_signature() = ingredient_manifest_uris->claim_signature; + PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie( + R"pb( + active_manifest { + label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + claim { + claim_generator_info { + name: "Test generator" + spec_version: "2.2.0" + } + signature_info { + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "03020100-0504-0706-0908-0f0e0d0c0b0a" + } + assertions { + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + media_type: "video/mp4" + relationship: "parentOf" + instance_id: "test_ingredient" + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012" + } + validation_results { + active_manifest { + successes { code: "assertion.dataHash.match" } + } + } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + description: "test ingredient" + informational_uri: "https://test.com/ingredient" + } + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + description: "created for testing" + software_agent { name: "test generator" } + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + actions { + action: "c2pa.redacted" + description: "redacted for testing" + } + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2" + } + successes { + code: "ingredient.claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + } + } + ingredient_manifests { + label: "urn:c2pa:12345678-1234-1234-1234-123456789012" + claim { + claim_generator_info { name: "Test C2PA Generator" } + signature_info { + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.boxes" + algorithm: "sha256" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "12345678-1234-1234-1234-123456789012" + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + software_agent { name: "test generator" } + } + } + } + assertions { + label: "c2pa.hash.boxes" + boxes_hash { + boxes { names: "some name" names: "other name" hash: "some hash" } + alg: "some alg" + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.actions.v2" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.hash.boxes" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + )pb"); + expected_validated_result.mutable_active_manifest() + ->mutable_claim() + ->add_redacted_assertions(redacted_uri); + expected_validated_result.mutable_active_manifest() + ->mutable_assertions(2) + ->mutable_actions() + ->mutable_actions(1) + ->mutable_parameters() + ->set_redacted_uri(redacted_uri); + AssertionReferencer assertion_reference(&hasher_factory_); + LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box = + Encode(manifest_box_encoder); + ABSL_ASSERT_OK(built_manifest_store_box); + absl::Cord result_manifest_store_bytes = + std::move(*built_manifest_store_box).AsCord(); + EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result.active_manifest().claim()))); + EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result)))); +} + +TEST_F(ManifestBoxEncoderTest, EncodesPartHashAssertionsWithRelativePath) { + ManifestBox manifest_box = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "12345678-1234-1234-1234-123456789012", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + software_agent { name: "test generator" } + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"), + .part_hash_assertions = {ParseTextProtoOrDie( + R"pb( + label: "c2pa.hash.bmff.v3.part" + bmff_based_hash { + exclusions { xpath: "" } + } + )pb"), + ParseTextProtoOrDie( + R"pb( + label: "c2pa.hash.bmff.v3.part__1" + bmff_based_hash { + exclusions { xpath: "" } + } + )pb")}, + .multi_part_assertion = {ParseTextProtoOrDie( + R"pb( + label: "c2pa.hash.multi-asset" + multi_asset_hash { + parts { + location { byte_offset: 0 length: 10 } + hash_assertion { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part" + algorithm: "sha256" + hash: "some hash" + } + } + parts { + location { byte_offset: 10 length: 10 } + hash_assertion { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part__1" + algorithm: "sha256" + hash: "some hash" + } + } + pad: "\000\000" + pad2: "" + } + )pb")}, + .ingredient_assertions = {}, + .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_), + }; + PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie( + R"pb( + active_manifest { + label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + claim { + claim_generator_info { + name: "Test generator" + spec_version: "2.2.0" + } + signature_info { + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part__1" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.multi-asset" + algorithm: "sha256" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "12345678-1234-1234-1234-123456789012" + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + software_agent { name: "test generator" } + } + } + } + assertions { + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + } + assertions { + label: "c2pa.hash.bmff.v3.part" + bmff_based_hash { exclusions { xpath: "" } } + } + assertions { + label: "c2pa.hash.bmff.v3.part__1" + bmff_based_hash { exclusions { xpath: "" } } + } + assertions { + label: "c2pa.hash.multi-asset" + multi_asset_hash { + parts { + location { byte_offset: 0 length: 10 } + hash_assertion { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part" + } + } + parts { + location { byte_offset: 10 length: 10 } + hash_assertion { + url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part__1" + } + } + pad: "\000\000" + pad2: "" + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.multi-asset" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3.part" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3.part__1" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3" + multi_asset_hash_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.multi-asset" + )pb"); + AssertionReferencer assertion_reference(&hasher_factory_); + LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box = + Encode(manifest_box_encoder); + ABSL_ASSERT_OK(built_manifest_store_box); + absl::Cord result_manifest_store_bytes = + std::move(*built_manifest_store_box).AsCord(); + EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result.active_manifest().claim()))); + EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()), + IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering( + expected_validated_result)))); +} + +TEST_F(ManifestBoxEncoderTest, EncodeManifestWithGatheredAssertions) { + std::string label = "com.example.gathered"; + std::string json_data = "{\"test\": \"data\"}"; + GatheredAssertion gathered; + gathered.set_label(label); + gathered.set_data(json_data); + gathered.set_content_type(GatheredAssertion::CONTENT_TYPE_JSON); + + ManifestBox manifest_box = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "test_instance_id", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + software_agent { name: "test generator" } + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"), + .ingredient_assertions = {}, + .gathered_assertions = {gathered}, + .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_), + }; + + AssertionReferencer assertion_reference(&hasher_factory_); + LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + + absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box = + Encode(manifest_box_encoder); + ABSL_ASSERT_OK(built_manifest_store_box); + absl::Cord result_manifest_store_bytes = + std::move(*built_manifest_store_box).AsCord(); + absl::StatusOr<Claim> claim = + ValidateClaim(result_manifest_store_bytes.Flatten()); + ABSL_ASSERT_OK(claim); + + ASSERT_EQ(claim->gathered_assertions_size(), 1); + EXPECT_EQ(claim->gathered_assertions(0).url(), + "self#jumbf=c2pa.assertions/com.example.gathered"); + + absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> validated = + ValidateManifestStore(result_manifest_store_bytes.Flatten()); + ABSL_ASSERT_OK(validated); + ASSERT_TRUE((*validated)->has_active_manifest()); + + bool found_match = false; + for (const auto& success : + (*validated)->active_manifest().validation().successes()) { + if (success.code() == "assertion.hashedURI.match" && + success.url() == + "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/" + "c2pa.assertions/com.example.gathered") { + found_match = true; + break; + } + } + EXPECT_TRUE(found_match); +} + +TEST_F(ManifestBoxEncoderTest, + EncodeManifestWithInvalidGatheredAssertionContentTypeInvalid) { + GatheredAssertion gathered; + gathered.set_label("com.example.gathered"); + gathered.set_data("{}"); + gathered.set_content_type(GatheredAssertion::CONTENT_TYPE_UNSPECIFIED); + + ManifestBox manifest_box = { + .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a", + .instance_id = "test_instance_id", + .hard_binding_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { exclusions { xpath: "" } } + )pb"), + .actions_assertion = ParseTextProtoOrDie(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + software_agent { name: "test generator" } + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"), + .ingredient_assertions = {}, + .gathered_assertions = {gathered}, + .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_), + }; + + AssertionReferencer assertion_reference(&hasher_factory_); + LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_); + ManifestBoxEncoder manifest_box_encoder( + &assertion_reference, &generator_info_, &claim_signer, + &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box, + hasher_factory_.algorithm()); + + EXPECT_THAT(Encode(manifest_box_encoder), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("content type must be JSON"))); +} + +} // namespace +} // namespace credentio
diff --git a/claim/manifest_store_builder.cc b/claim/manifest_store_builder.cc new file mode 100644 index 0000000..629b1a6 --- /dev/null +++ b/claim/manifest_store_builder.cc
@@ -0,0 +1,140 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "claim/manifest_store_builder.h" + +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "claim/manifest_store_view.h" +#include "constants/labels.h" +#include "crypto/hash.h" +#include "jumbf/box.h" +#include "jumbf/box_builder.h" +#include "jumbf/encode_params.h" +#include "jumbf/utils.h" +#include "proto/hashed_uri.pb.h" + +namespace credentio { + +namespace { +bool IsManifestBox(const jumbf::SuperBox& super_box) { + return super_box.description.type_uuid == kStandardManifestUuid || + super_box.description.type_uuid == kCompressedManifestUuid || + super_box.description.type_uuid == kUpdateManifestUuid || + super_box.description.type_uuid == kTimestampManifestUuid; +} + +absl::StatusOr<HashedUri> CreateHashedUriForBox( + absl::string_view url, absl::string_view box_raw_bytes, + const HasherFactory& hasher_factory) { + ABSL_ASSIGN_OR_RETURN(absl::string_view content, + jumbf::StripBoxHeaders(box_raw_bytes)); + ABSL_ASSIGN_OR_RETURN(auto hasher, hasher_factory.Create()); + hasher->Update(content); + HashedUri hashed_uri; + hashed_uri.set_url(url); + hashed_uri.set_hash(hasher->Digest()); + return hashed_uri; +} + +} // namespace + +absl::StatusOr<IngredientManifestUris> +ManifestStoreBuilder::AddIngredientManifestStore( + absl::string_view manifest_store_bytes) { + ABSL_ASSIGN_OR_RETURN(auto manifest_store_view, + ManifestStoreView::Create(manifest_store_bytes)); + ABSL_ASSIGN_OR_RETURN(auto active_manifest_view, + manifest_store_view.GetActiveManifest()); + std::string active_manifest_url = + absl::StrCat(kManifestStoreUrlPrefix, active_manifest_view.label()); + + IngredientManifestUris ingredient_manifest_uris; + ABSL_ASSIGN_OR_RETURN( + ingredient_manifest_uris.active_manifest, + CreateHashedUriForBox(active_manifest_url, + active_manifest_view.superbox().raw_bytes, + hasher_factory_)); + + ABSL_ASSIGN_OR_RETURN(auto claim_signature_view, + active_manifest_view.GetClaimSignature()); + ABSL_ASSIGN_OR_RETURN( + ingredient_manifest_uris.claim_signature, + CreateHashedUriForBox( + absl::StrCat(active_manifest_url, kManifestLabelDelimiter, + kClaimSignatureLabel), + claim_signature_view.superbox().raw_bytes, hasher_factory_)); + + ingredient_manifest_stores_.push_back(manifest_store_view.superbox()); + return ingredient_manifest_uris; +} + +absl::StatusOr<jumbf::BuiltSuperBox> ManifestStoreBuilder::Finalize( + jumbf::BuiltSuperBox&& active_manifest) && { + std::vector<absl::Cord> ingredient_manifests; + for (const jumbf::SuperBox& manifest_store : ingredient_manifest_stores_) { + for (const jumbf::ContentBox& box : manifest_store.contents) { + if (!box.Holds<jumbf::SuperBox>()) { + continue; + } + const jumbf::SuperBox& super_box = box.Get<jumbf::SuperBox>(); + if (!IsManifestBox(super_box)) { + continue; + } + if (!super_box.description.label.has_value()) { + continue; + } + ManifestInfo& manifest_info = + manifest_info_[*super_box.description.label]; + if (manifest_info.copied_to_manifest_store) { + continue; + } + ingredient_manifests.emplace_back(super_box.raw_bytes); + manifest_info.copied_to_manifest_store = true; + } + } + if (!active_manifest.description_label().has_value()) { + return absl::InvalidArgumentError("active manifest label is missing"); + } + if (manifest_info_[*active_manifest.description_label()] + .copied_to_manifest_store) { + // Shouldn't happen since manifest labels are generated randomly and + // the active manifest can't be an ingredient of itself. + return absl::InternalError( + "active manifest label is also an ingredient manifest label"); + } + jumbf::SuperBoxBuilder manifest_store_builder( + kManifestStoreUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord(kManifestStoreLabel)}); + // SuperBoxBuilder reverses the order of boxes added via AddChild when + // finalized. We iterate in reverse order to preserve the order of the + // ingredient manifests in the new manifest store. + ABSL_RETURN_IF_ERROR( + manifest_store_builder.AddChild(std::move(active_manifest))); + for (int i = ingredient_manifests.size() - 1; i >= 0; --i) { + ABSL_RETURN_IF_ERROR( + manifest_store_builder.AddChild(std::move(ingredient_manifests[i]))); + } + return std::move(manifest_store_builder).Finalize(); +} +} // namespace credentio
diff --git a/claim/manifest_store_builder.h b/claim/manifest_store_builder.h new file mode 100644 index 0000000..b667d09 --- /dev/null +++ b/claim/manifest_store_builder.h
@@ -0,0 +1,69 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_STORE_BUILDER_H_ +#define THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_STORE_BUILDER_H_ + +#include <string> +#include <vector> + +#include "absl/container/flat_hash_map.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "crypto/hash.h" +#include "jumbf/box.h" +#include "jumbf/box_builder.h" +#include "proto/hashed_uri.pb.h" + +namespace credentio { + +// Struct holding the HashedUris for an ingredient's active manifest. +struct IngredientManifestUris { + HashedUri active_manifest; + HashedUri claim_signature; +}; + +// Builder for a manifest store. +class ManifestStoreBuilder { + public: + explicit ManifestStoreBuilder(const HasherFactory* hasher_factory) + : hasher_factory_(*ABSL_DIE_IF_NULL(hasher_factory)) {}; + + // Adds an ingredient manifest store via its serialized bytes. + // Returns the HashedUris computed on the ingredient's active manifest. + absl::StatusOr<IngredientManifestUris> AddIngredientManifestStore( + absl::string_view manifest_store_bytes); + + // Finish writing the manifest store. This consumes the builder, use + // `std::move(builder).Finalize()`; + absl::StatusOr<jumbf::BuiltSuperBox> Finalize( + jumbf::BuiltSuperBox&& active_manifest) &&; + + private: + struct ManifestInfo { + bool copied_to_manifest_store = false; // Already copied by Finalize? + }; + + absl::flat_hash_map<std::string, ManifestInfo> manifest_info_; + // Factory for creating hashers. Used to compute HashedUris. + const HasherFactory& hasher_factory_; + // Ingredient manifests added to the manifest store. + std::vector<jumbf::SuperBox> ingredient_manifest_stores_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_STORE_BUILDER_H_
diff --git a/claim/manifest_store_builder_test.cc b/claim/manifest_store_builder_test.cc new file mode 100644 index 0000000..bced268 --- /dev/null +++ b/claim/manifest_store_builder_test.cc
@@ -0,0 +1,378 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "claim/manifest_store_builder.h" + +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "assertion/assertion_encoder.h" +#include "constants/labels.h" +#include "crypto/algorithms.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/box_builder.h" +#include "jumbf/encode_params.h" +#include "jumbf/parse.h" +#include "jumbf/test_utils.h" +#include "proto/assertion.pb.h" +#include "proto/hashed_uri.pb.h" +#include "testing/proto_test_utils.h" +#include "uuid/uuid.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::ElementsAre; +using ::testing::HasSubstr; + +inline constexpr Uuid kUnknownUuid( + Uuid::FromStringOrDie("00000000-0000-0000-0000-000000000000")); + +class FakeHasherFactory : public HasherFactory { + public: + absl::StatusOr<std::unique_ptr<Hasher>> Create() const override { + return CreateHasher("sha256"); + } + HashAlgorithm algorithm() const override { return HashAlgorithm::kSha256; } +}; + +bool IsManifestStore(const jumbf::SuperBox& superbox) { + return superbox.description.label == kManifestStoreLabel && + superbox.description.type_uuid == kManifestStoreUuid; +} + +bool IsManifest(const jumbf::SuperBox& superbox) { + const auto& uuid = superbox.description.type_uuid; + return uuid == kStandardManifestUuid || uuid == kUpdateManifestUuid || + uuid == kCompressedManifestUuid || uuid == kTimestampManifestUuid; +} + +absl::StatusOr<std::vector<std::string>> ExtractManifestLabels( + absl::string_view manifest_store) { + std::vector<std::string> labels; + ABSL_ASSIGN_OR_RETURN( + auto ms_superbox, + jumbf::ConsumeSuperBox(&manifest_store, /*recursion_limit=*/9)); + if (!IsManifestStore(ms_superbox)) { + return absl::NotFoundError( + "JUMBF SuperBox does not contain a manifest store"); + } + for (const auto& content : ms_superbox.contents) { + if (content.Holds<jumbf::SuperBox>()) { + const auto& superbox = content.Get<jumbf::SuperBox>(); + if (IsManifest(superbox)) { + labels.push_back(std::string(superbox.description.label.value_or(""))); + } + } + } + return labels; +} + +class ManifestStoreBuilderTest : public ::testing::Test { + protected: + ManifestStoreBuilderTest() : hasher_factory_(), builder_(&hasher_factory_) {}; + FakeHasherFactory hasher_factory_; + ManifestStoreBuilder builder_; + StandardAssertionEncoder cbor_encoder_; +}; + +TEST_F(ManifestStoreBuilderTest, AddActiveManifest) { + jumbf::SuperBoxBuilder active_manifest_builder( + kStandardManifestUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord("manifest_1")}); + std::string cbor_box_1 = jumbf::EncodeCborBox("cbor_box_1"); + ABSL_EXPECT_OK(active_manifest_builder.AddChild(absl::Cord(cbor_box_1))); + auto active_manifest_or = std::move(active_manifest_builder).Finalize(); + ABSL_ASSERT_OK(active_manifest_or); + jumbf::BuiltSuperBox active_manifest = *std::move(active_manifest_or); + + jumbf::SuperBoxBuilder expected_manifest_builder( + kStandardManifestUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord("manifest_1")}); + std::string expected_cbor_box_1 = jumbf::EncodeCborBox("cbor_box_1"); + ABSL_EXPECT_OK(expected_manifest_builder.AddChild(absl::Cord(cbor_box_1))); + auto expected_manifest_or = std::move(expected_manifest_builder).Finalize(); + ABSL_ASSERT_OK(expected_manifest_or); + jumbf::BuiltSuperBox expected_manifest = *std::move(expected_manifest_or); + absl::Cord expected_manifest_cord = std::move(expected_manifest).AsCord(); + + auto manifest_store_or = + std::move(builder_).Finalize(std::move(active_manifest)); + ABSL_ASSERT_OK(manifest_store_or); + jumbf::BuiltSuperBox manifest_store = *std::move(manifest_store_or); + absl::Cord manifest_store_cord = std::move(manifest_store).AsCord(); + EXPECT_THAT(manifest_store_cord, HasSubstr(expected_manifest_cord)); +} + +TEST_F(ManifestStoreBuilderTest, AddIngredientManifestStoreNoManifests) { + std::string ingredient_cbor_box_1 = + jumbf::EncodeCborBox("ingredient_cbor_box_1"); + std::string ingredient_manifest_store_1 = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {ingredient_cbor_box_1}); + + EXPECT_THAT(builder_.AddIngredientManifestStore(ingredient_manifest_store_1), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("No active manifest found"))); +} + +TEST_F(ManifestStoreBuilderTest, AddIngredientManifestStoreNoClaimSignature) { + std::string ingredient_cbor_box_1 = + jumbf::EncodeCborBox("ingredient_cbor_box_1"); + std::string ingredient_manifest_1 = jumbf::EncodeSuperBox( + kStandardManifestUuid, "ingredient_manifest_1", {ingredient_cbor_box_1}); + std::string ingredient_manifest_store_1 = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {ingredient_manifest_1}); + + EXPECT_THAT(builder_.AddIngredientManifestStore(ingredient_manifest_store_1), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("No claim signature found in the manifest"))); +} + +TEST_F(ManifestStoreBuilderTest, AddIngredientManifestStore) { + jumbf::SuperBoxBuilder active_manifest_builder( + kStandardManifestUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord("active_manifest")}); + std::string active_cbor_box = jumbf::EncodeCborBox("active_cbor_box"); + ABSL_EXPECT_OK(active_manifest_builder.AddChild(absl::Cord(active_cbor_box))); + auto active_manifest_or = std::move(active_manifest_builder).Finalize(); + ABSL_ASSERT_OK(active_manifest_or); + jumbf::BuiltSuperBox active_manifest = *std::move(active_manifest_or); + std::string ingredient_cbor_box_1 = + jumbf::EncodeCborBox("ingredient_cbor_box_1"); + std::string ingredient_signature_box_1 = jumbf::EncodeSuperBox( + kClaimSignatureUuid, kClaimSignatureLabel, {ingredient_cbor_box_1}); + std::string ingredient_manifest_1 = + jumbf::EncodeSuperBox(kStandardManifestUuid, "ingredient_manifest_1", + {ingredient_signature_box_1}); + std::string ingredient_active_cbor_box_1 = + jumbf::EncodeCborBox("ingredient_cbor_box_1"); + std::string ingredient_active_signature_box_1 = + jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel, + {ingredient_active_cbor_box_1}); + std::string ingredient_active_manifest_1 = + jumbf::EncodeSuperBox(kUpdateManifestUuid, "ingredient_active_manifest_1", + {ingredient_active_signature_box_1}); + std::string ingredient_unknown_box_1 = jumbf::EncodeSuperBox( + kUnknownUuid, "ingredient_unknown_box_1", {ingredient_cbor_box_1}); + std::string ingredient_manifest_store_1 = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, + {ingredient_manifest_1, ingredient_active_manifest_1, + ingredient_cbor_box_1, ingredient_unknown_box_1}); + std::string ingredient_cbor_box_2 = + jumbf::EncodeCborBox("ingredient_cbor_box_2"); + std::string ingredient_signature_box_2 = jumbf::EncodeSuperBox( + kClaimSignatureUuid, kClaimSignatureLabel, {ingredient_cbor_box_2}); + std::string ingredient_manifest_2 = + jumbf::EncodeSuperBox(kCompressedManifestUuid, "ingredient_manifest_2", + {ingredient_signature_box_2}); + std::string ingredient_active_cbor_box_2 = + jumbf::EncodeCborBox("ingredient_cbor_box_2"); + std::string ingredient_active_signature_box_2 = + jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel, + {ingredient_active_cbor_box_2}); + std::string ingredient_active_manifest_2 = jumbf::EncodeSuperBox( + kTimestampManifestUuid, "ingredient_active_manifest_2", + {ingredient_active_signature_box_2}); + std::string ingredient_manifest_store_2 = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, + {ingredient_manifest_2, ingredient_active_manifest_2}); + + jumbf::SuperBoxBuilder expected_manifest_builder( + kStandardManifestUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord("active_manifest")}); + std::string expected_cbor_box = jumbf::EncodeCborBox("active_cbor_box"); + ABSL_EXPECT_OK( + expected_manifest_builder.AddChild(absl::Cord(expected_cbor_box))); + auto expected_manifest_or = std::move(expected_manifest_builder).Finalize(); + ABSL_ASSERT_OK(expected_manifest_or); + jumbf::BuiltSuperBox expected_manifest = *std::move(expected_manifest_or); + absl::Cord expected_manifest_cord = std::move(expected_manifest).AsCord(); + std::string expected_manifest_store = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, + {ingredient_manifest_1, ingredient_active_manifest_1, + ingredient_manifest_2, ingredient_active_manifest_2, + std::string(expected_manifest_cord)}, + /*requestable=*/true); + + auto uris_1_or = + builder_.AddIngredientManifestStore(ingredient_manifest_store_1); + ABSL_ASSERT_OK(uris_1_or); + IngredientManifestUris ingredient_manifest_uris_1 = *std::move(uris_1_or); + auto expected_active_manifest_uri_1 = ParseTextProtoOrDie<HashedUri>( + R"pb( + url: "self#jumbf=/c2pa/ingredient_active_manifest_1" + hash: "=s\030\315\353\317\241\271c\342&\233\315gV72\270\344\200\371\"\326\237p\302\033n\223a\357\023" + )pb"); + auto expected_claim_signature_uri_1 = ParseTextProtoOrDie<HashedUri>( + R"pb( + url: "self#jumbf=/c2pa/ingredient_active_manifest_1/c2pa.signature" + hash: "\370\347\000R\200\003\005\024\211ivc_(\243\013C\342\367\215\375\022v\206\257\023\363\251\221\270k\311" + )pb"); + EXPECT_THAT(ingredient_manifest_uris_1.active_manifest, + EqualsProto(expected_active_manifest_uri_1)); + EXPECT_THAT(ingredient_manifest_uris_1.claim_signature, + EqualsProto(expected_claim_signature_uri_1)); + auto uris_2_or = + builder_.AddIngredientManifestStore(ingredient_manifest_store_2); + ABSL_ASSERT_OK(uris_2_or); + IngredientManifestUris ingredient_manifest_uris_2 = *std::move(uris_2_or); + auto expected_active_manifest_uri_2 = ParseTextProtoOrDie<HashedUri>( + R"pb( + url: "self#jumbf=/c2pa/ingredient_active_manifest_2" + hash: "\231\t\034\211\312\271D\262\317\006\314\206EAv\243\276\2335\254\357]\302\217\20208\214\360A@\"" + )pb"); + auto expected_claim_signature_uri_2 = ParseTextProtoOrDie<HashedUri>( + R"pb( + url: "self#jumbf=/c2pa/ingredient_active_manifest_2/c2pa.signature" + hash: "Q\234\255\026\232\274\373\314X\317\271N=\260`\030\355\343\024\245h\326^\306\364\205\202\367\200\233f\371" + )pb"); + EXPECT_THAT(ingredient_manifest_uris_2.active_manifest, + EqualsProto(expected_active_manifest_uri_2)); + EXPECT_THAT(ingredient_manifest_uris_2.claim_signature, + EqualsProto(expected_claim_signature_uri_2)); + auto manifest_store_or = + std::move(builder_).Finalize(std::move(active_manifest)); + ABSL_ASSERT_OK(manifest_store_or); + jumbf::BuiltSuperBox manifest_store = *std::move(manifest_store_or); + absl::Cord manifest_store_cord = std::move(manifest_store).AsCord(); + EXPECT_THAT(std::string(manifest_store_cord), expected_manifest_store); +} + +TEST_F(ManifestStoreBuilderTest, AddIngredientManifestStore_Deduplicates) { + jumbf::SuperBoxBuilder active_manifest_builder( + kStandardManifestUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord("active_manifest")}); + ABSL_EXPECT_OK(active_manifest_builder.AddChild( + absl::Cord(jumbf::EncodeCborBox("active_cbor_box")))); + auto active_manifest_or = std::move(active_manifest_builder).Finalize(); + ABSL_ASSERT_OK(active_manifest_or); + jumbf::BuiltSuperBox active_manifest = *std::move(active_manifest_or); + + std::string signature_1 = + jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel, + {jumbf::EncodeCborBox("signature_1")}); + std::string signature_2 = + jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel, + {jumbf::EncodeCborBox("signature_2")}); + std::string signature_3 = + jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel, + {jumbf::EncodeCborBox("signature_3")}); + + std::string ingredient_manifest_1 = jumbf::EncodeSuperBox( + kStandardManifestUuid, "ingredient_manifest_1", {signature_1}); + std::string ingredient_manifest_2 = jumbf::EncodeSuperBox( + kStandardManifestUuid, "ingredient_manifest_2", {signature_2}); + std::string ingredient_manifest_3 = jumbf::EncodeSuperBox( + kStandardManifestUuid, "ingredient_manifest_3", {signature_3}); + + // These two ingredient manifest stores have ingredient_manifest_1 in common. + std::string ingredient_manifest_store_3 = + jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel, + {ingredient_manifest_1, ingredient_manifest_3}); + std::string ingredient_manifest_store_2 = + jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel, + {ingredient_manifest_1, ingredient_manifest_2}); + + auto uris_2_or = + builder_.AddIngredientManifestStore(ingredient_manifest_store_2); + ABSL_ASSERT_OK(uris_2_or); + auto uris_3a_or = + builder_.AddIngredientManifestStore(ingredient_manifest_store_3); + ABSL_ASSERT_OK(uris_3a_or); + // Add the same ingredient manifest store again. + auto uris_3b_or = + builder_.AddIngredientManifestStore(ingredient_manifest_store_3); + ABSL_ASSERT_OK(uris_3b_or); + + auto manifest_store_or = + std::move(builder_).Finalize(std::move(active_manifest)); + ABSL_ASSERT_OK(manifest_store_or); + jumbf::BuiltSuperBox manifest_store = *std::move(manifest_store_or); + absl::Cord manifest_store_cord = std::move(manifest_store).AsCord(); + EXPECT_THAT( + ExtractManifestLabels(manifest_store_cord.Flatten()), + IsOkAndHolds(ElementsAre("ingredient_manifest_1", "ingredient_manifest_2", + "ingredient_manifest_3", "active_manifest"))); +} + +TEST_F(ManifestStoreBuilderTest, FinalizeFailsWhenActiveManifestIsIngredient) { + jumbf::SuperBoxBuilder active_manifest_builder( + kStandardManifestUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord("active_manifest")}); + ABSL_EXPECT_OK(active_manifest_builder.AddChild( + absl::Cord(jumbf::EncodeCborBox("active_cbor_box")))); + auto active_manifest_or = std::move(active_manifest_builder).Finalize(); + ABSL_ASSERT_OK(active_manifest_or); + jumbf::BuiltSuperBox active_manifest = *std::move(active_manifest_or); + + std::string signature_1 = + jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel, + {jumbf::EncodeCborBox("signature_1")}); + std::string signature_2 = + jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel, + {jumbf::EncodeCborBox("signature_2")}); + std::string ingredient_manifest_1 = jumbf::EncodeSuperBox( + kStandardManifestUuid, "ingredient_manifest", {signature_1}); + std::string ingredient_manifest_with_active_manifest_label = + jumbf::EncodeSuperBox(kStandardManifestUuid, "active_manifest", + {signature_2}); + std::string ingredient_manifest_store = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, + {ingredient_manifest_1, ingredient_manifest_with_active_manifest_label}); + + ABSL_ASSERT_OK( + builder_.AddIngredientManifestStore(ingredient_manifest_store)); + + EXPECT_THAT( + std::move(builder_).Finalize(std::move(active_manifest)), + StatusIs( + absl::StatusCode::kInternal, + HasSubstr( + "active manifest label is also an ingredient manifest label"))); +} + +TEST_F(ManifestStoreBuilderTest, FinalizeFailsWhenActiveManifestHasNoLabel) { + jumbf::SuperBoxBuilder active_manifest_builder( + kStandardManifestUuid, jumbf::DescriptionBoxOptions{}); + ABSL_EXPECT_OK(active_manifest_builder.AddChild( + absl::Cord(jumbf::EncodeCborBox("active_cbor_box")))); + auto active_manifest_or = std::move(active_manifest_builder).Finalize(); + ABSL_ASSERT_OK(active_manifest_or); + jumbf::BuiltSuperBox active_manifest = *std::move(active_manifest_or); + EXPECT_THAT(std::move(builder_).Finalize(std::move(active_manifest)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("active manifest label is missing"))); +} + +} // namespace +} // namespace credentio
diff --git a/claim/manifest_store_view_test.cc b/claim/manifest_store_view_test.cc index c8e0b3d..63f2884 100644 --- a/claim/manifest_store_view_test.cc +++ b/claim/manifest_store_view_test.cc
@@ -31,7 +31,6 @@ namespace credentio { namespace { -using ::absl_testing::IsOk; using ::absl_testing::IsOkAndHolds; using ::absl_testing::StatusIs; using ::testing::HasSubstr; @@ -58,9 +57,9 @@ std::string manifest_store = jumbf::EncodeSuperBox( kManifestStoreUuid, kManifestStoreLabel, {manifest}); auto view = ManifestStoreView::Create(manifest_store); - ASSERT_THAT(view, IsOk()); + ABSL_ASSERT_OK(view); auto manifest_view = view->GetActiveManifest(); - ASSERT_THAT(manifest_view, IsOk()); + ABSL_ASSERT_OK(manifest_view); EXPECT_EQ(manifest_view->label(), "manifest1"); } @@ -75,9 +74,9 @@ jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel, {manifest1, manifest2, manifest3}); auto view = ManifestStoreView::Create(manifest_store); - ASSERT_THAT(view, IsOk()); + ABSL_ASSERT_OK(view); auto manifest_view = view->GetActiveManifest(); - ASSERT_THAT(manifest_view, IsOk()); + ABSL_ASSERT_OK(manifest_view); EXPECT_EQ(manifest_view->label(), "manifest3"); } @@ -96,9 +95,9 @@ jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel, {manifest1, other_box1, manifest2, other_box2}); auto view = ManifestStoreView::Create(manifest_store); - ASSERT_THAT(view, IsOk()); + ABSL_ASSERT_OK(view); auto manifest_view = view->GetActiveManifest(); - ASSERT_THAT(manifest_view, IsOk()); + ABSL_ASSERT_OK(manifest_view); EXPECT_EQ(manifest_view->label(), "manifest2"); } @@ -112,7 +111,7 @@ std::string manifest_store = jumbf::EncodeSuperBox( kManifestStoreUuid, kManifestStoreLabel, {other_box1, other_box2}); auto view = ManifestStoreView::Create(manifest_store); - ASSERT_THAT(view, IsOk()); + ABSL_ASSERT_OK(view); EXPECT_THAT(view->GetActiveManifest(), StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("No active manifest found"))); @@ -127,7 +126,7 @@ jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel, {standard_manifest, compressed_manifest}); auto view = ManifestStoreView::Create(manifest_store); - ASSERT_THAT(view, IsOk()); + ABSL_ASSERT_OK(view); EXPECT_THAT(view->GetActiveManifest(), StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("Compressed manifests are not supported"))); @@ -151,9 +150,9 @@ std::string manifest_store = jumbf::EncodeSuperBox( kManifestStoreUuid, kManifestStoreLabel, {manifest}); auto view = ManifestStoreView::Create(manifest_store); - ASSERT_THAT(view, IsOk()); + ABSL_ASSERT_OK(view); auto manifest_view = view->GetActiveManifest(); - ASSERT_THAT(manifest_view, IsOk()); + ABSL_ASSERT_OK(manifest_view); auto result = manifest_view->GetClaimSignature(); EXPECT_THAT(result.status(), StatusIs(absl::StatusCode::kInvalidArgument, @@ -168,9 +167,9 @@ std::string manifest_store = jumbf::EncodeSuperBox( kManifestStoreUuid, kManifestStoreLabel, {manifest}); auto view = ManifestStoreView::Create(manifest_store); - ASSERT_THAT(view, IsOk()); + ABSL_ASSERT_OK(view); auto manifest_view = view->GetActiveManifest(); - ASSERT_THAT(manifest_view, IsOk()); + ABSL_ASSERT_OK(manifest_view); auto result = manifest_view->GetClaimSignature(); EXPECT_THAT(result.status(), StatusIs(absl::StatusCode::kInvalidArgument, @@ -196,11 +195,11 @@ std::string manifest_store = jumbf::EncodeSuperBox( kManifestStoreUuid, kManifestStoreLabel, {manifest}); auto view = ManifestStoreView::Create(manifest_store); - ASSERT_THAT(view, IsOk()); + ABSL_ASSERT_OK(view); auto manifest_view = view->GetActiveManifest(); - ASSERT_THAT(manifest_view, IsOk()); + ABSL_ASSERT_OK(manifest_view); auto claim_signature_view = manifest_view->GetClaimSignature(); - ASSERT_THAT(claim_signature_view, IsOk()); + ABSL_ASSERT_OK(claim_signature_view); EXPECT_THAT(claim_signature_view->GetPayload(), StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("Invalid claim signature box"))); @@ -214,11 +213,11 @@ std::string manifest_store = jumbf::EncodeSuperBox( kManifestStoreUuid, kManifestStoreLabel, {manifest}); auto view = ManifestStoreView::Create(manifest_store); - ASSERT_THAT(view, IsOk()); + ABSL_ASSERT_OK(view); auto manifest_view = view->GetActiveManifest(); - ASSERT_THAT(manifest_view, IsOk()); + ABSL_ASSERT_OK(manifest_view); auto claim_signature_view = manifest_view->GetClaimSignature(); - ASSERT_THAT(claim_signature_view, IsOk()); + ABSL_ASSERT_OK(claim_signature_view); EXPECT_THAT(claim_signature_view->GetPayload(), IsOkAndHolds("abc")); } @@ -237,11 +236,11 @@ std::string manifest_store = jumbf::EncodeSuperBox( kManifestStoreUuid, kManifestStoreLabel, {manifest}); auto view = ManifestStoreView::Create(manifest_store); - ASSERT_THAT(view, IsOk()); + ABSL_ASSERT_OK(view); auto manifest_view = view->GetActiveManifest(); - ASSERT_THAT(manifest_view, IsOk()); + ABSL_ASSERT_OK(manifest_view); auto claim_signature_view = manifest_view->GetClaimSignature(); - ASSERT_THAT(claim_signature_view, IsOk()); + ABSL_ASSERT_OK(claim_signature_view); EXPECT_THAT(claim_signature_view->GetStruct(), IsOkAndHolds(cose_sign1)); }
diff --git a/claim/signer.h b/claim/signer.h new file mode 100644 index 0000000..158c2e6 --- /dev/null +++ b/claim/signer.h
@@ -0,0 +1,64 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_CLAIM_SIGNER_H_ +#define THIRD_PARTY_CREDENTIO_CLAIM_SIGNER_H_ + +#include <optional> +#include <string> + +#include "absl/functional/any_invocable.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "async/async_context.h" + +namespace credentio { + +enum class TimeStampingMode { + kOptional, + kRequired, + kOmittedWithPadding, +}; + +// Interface for a signer to sign a Claim according to +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_signing_a_claim. +class ClaimSigner { + public: + virtual ~ClaimSigner() = default; + struct Options { + TimeStampingMode time_stamping_mode = TimeStampingMode::kOptional; + AsyncContext async_context = AsyncContext(); + }; + + struct SignResult { + // Serialized COSE_Sign1_Tagged structure. + std::string signature; + }; + + // Signs the serialized CBOR of the claim and returns the serialized + // COSE_Sign1_Tagged structure that will be written into the C2PA Claim + // Signature box. For more details, see + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_signing_a_claim. + // It guarantees that the size of the result is fixed and is a function of + // `max_signature_bytes_size()` and `max_timestamp_byte_size()` of the + // CryptoWriteHandler rather than of the actual signature and timestamp sizes. + virtual void Sign( + absl::string_view claim, Options options, + absl::AnyInvocable<void(absl::StatusOr<SignResult>) &&> cb) const = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CLAIM_SIGNER_H_
diff --git a/claim/timestamp_updater.cc b/claim/timestamp_updater.cc new file mode 100644 index 0000000..dd71eef --- /dev/null +++ b/claim/timestamp_updater.cc
@@ -0,0 +1,98 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "claim/timestamp_updater.h" + +#include <cstddef> +#include <memory> +#include <string> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "async/async_context.h" +#include "claim/manifest_store_view.h" +#include "cose/sig_structure.h" +#include "cose/timestamp_updater.h" + +namespace credentio { + +void ClaimTimestampUpdater::InsertTimestamp( + std::string* absl_nonnull manifest_store, AsyncContext async_context, + absl::AnyInvocable<void(absl::Status) &&> cb) const { + auto manifest_store_view = ManifestStoreView::Create(*manifest_store); + if (!manifest_store_view.ok()) { + AsyncResolver<void>(async_context, std::move(cb)) + .Return(manifest_store_view.status()); + return; + } + auto manifest_view = manifest_store_view->GetActiveManifest(); + if (!manifest_view.ok()) { + AsyncResolver<void>(async_context, std::move(cb)) + .Return(manifest_view.status()); + return; + } + auto claim_signature_view = manifest_view->GetClaimSignature(); + if (!claim_signature_view.ok()) { + AsyncResolver<void>(async_context, std::move(cb)) + .Return(claim_signature_view.status()); + return; + } + auto cose_sign1 = claim_signature_view->GetStruct(); + if (!cose_sign1.ok()) { + AsyncResolver<void>(async_context, std::move(cb)) + .Return(cose_sign1.status()); + return; + } + if (cose_sign1->unprotected_header.sig_tst2.has_value()) { + AsyncResolver<void>(async_context, std::move(cb)).Return(absl::OkStatus()); + return; + } + auto signature = claim_signature_view->GetPayload(); + if (!signature.ok()) { + AsyncResolver<void>(async_context, std::move(cb)) + .Return(signature.status()); + return; + } + auto updated_signature = std::make_shared<std::string>(*signature); + absl::AnyInvocable<void(absl::Status) &&> done_cb = + [manifest_store, signature = *signature, updated_signature, async_context, + cb = std::move(cb)](absl::Status status) mutable { + AsyncResolver<void> resolver(async_context, std::move(cb)); + if (!status.ok()) { + resolver.Return(status); + return; + } + if (updated_signature->size() != signature.size()) { + resolver.Return( + absl::InternalError("updated signature size does not match the " + "original size.")); + return; + } + size_t signature_offset = + signature.begin() - absl::string_view(*manifest_store).begin(); + manifest_store->replace(signature_offset, signature.size(), + *updated_signature); + resolver.Return(absl::OkStatus()); + }; + + TimestampUpdater updater(timestamp_creator_.get()); + updater.UpdateTimestamp(updated_signature.get(), async_context, + std::move(done_cb)); +} + +} // namespace credentio
diff --git a/claim/timestamp_updater.h b/claim/timestamp_updater.h new file mode 100644 index 0000000..3a26593 --- /dev/null +++ b/claim/timestamp_updater.h
@@ -0,0 +1,52 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_CLAIM_TIMESTAMP_UPDATER_H_ +#define THIRD_PARTY_CREDENTIO_CLAIM_TIMESTAMP_UPDATER_H_ + +#include <memory> +#include <string> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "async/async_context.h" +#include "cose/timestamp_creator.h" + +namespace credentio { +// Updater of claim timestamps in C2PA manifest stores. +class ClaimTimestampUpdater { + public: + explicit ClaimTimestampUpdater( + std::unique_ptr<TimestampCreator> timestamp_creator) + : timestamp_creator_(std::move(timestamp_creator)) {} + + // Inserts a timestamp to the active manifest in the given manifest store, if + // a timestamp is not found; otherwise, makes no change. If a timestamp is + // inserted, it resizes the `pad` in the unprotected header to ensure the size + // of the result is the same as the input. + // + // Returns an error if space is insufficient for padding. + void InsertTimestamp(std::string* absl_nonnull manifest_store, + AsyncContext async_context, + absl::AnyInvocable<void(absl::Status) &&> cb) const; + + private: + std::unique_ptr<TimestampCreator> timestamp_creator_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CLAIM_TIMESTAMP_UPDATER_H_
diff --git a/claim/timestamp_updater_test.cc b/claim/timestamp_updater_test.cc new file mode 100644 index 0000000..0c6e0aa --- /dev/null +++ b/claim/timestamp_updater_test.cc
@@ -0,0 +1,173 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "claim/timestamp_updater.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "absl/time/clock_interface.h" +#include "absl/time/simulated_clock.h" +#include "absl/time/time.h" +#include "async/async_context.h" +#include "constants/labels.h" +#include "cose/sig_structure.h" +#include "cose/timestamp_creator.h" +#include "crypto/crypto_read_handler.h" +#include "crypto/default/default_crypto_read_handler.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "crypto/timestamp_write_handler.h" +#include "gmock/gmock.h" +#include "google/protobuf/timestamp.pb.h" +#include "gtest/gtest.h" +#include "jumbf/test_utils.h" +#include "proto/validation_result.pb.h" +#include "testing/create_test_crypto_write_handler.h" +#include "testing/manifest_store_test_utils.h" +#include "validator/manifest_store_validator_impl.h" +#include "validator/validator_options.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; + +constexpr absl::Time kTestTime = absl::FromUnixSeconds(1741403167); + +absl::StatusOr<absl::Time> ValidateAndGetTime( + absl::string_view manifest_store) { + ABSL_ASSIGN_OR_RETURN( + std::unique_ptr<CryptoReadHandler> crypto_read_handler, + CreateDefaultCryptoReadHandler({ + .tsa_trust_anchors_pem = DefaultTestPki().root_ca.cert_pem, + .skip_claim_signer_trust_checks_for_test = true, + })); + ManifestStoreValidatorImpl validator(ValidatorOptions{ + .crypto_read_handler = std::move(crypto_read_handler), + }); + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<PartialValidationResultProto> result, + validator.Validate(manifest_store)); + const google::protobuf::Timestamp& ts = + result->active_manifest().claim().signature_info().timestamping_time(); + return absl::FromUnixSeconds(ts.seconds()) + absl::Nanoseconds(ts.nanos()); +} + +absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> CreateTimestampHandler( + absl::Clock* clock) { + return CreateTestCryptoWriteHandler({.clock = clock}); +} + +absl::StatusOr<std::string> InsertTimestamp(ClaimTimestampUpdater& updater, + absl::string_view manifest_store) { + std::string updated_manifest_store(manifest_store); + absl::Notification done; + absl::Status status; + updater.InsertTimestamp(&updated_manifest_store, AsyncContext(), + [&](absl::Status s) { + status = std::move(s); + done.Notify(); + }); + done.WaitForNotification(); + if (!status.ok()) return status; + return updated_manifest_store; +} + +TEST(ClaimTimestampUpdater, InsertTimestampStandardManifest) { + absl::StatusOr<std::string> manifest_store = + CreateSimpleManifestStoreBytes({.disable_timestamping = true}); + ABSL_ASSERT_OK(manifest_store); + + absl::SimulatedClock clock; + clock.SetTime(kTestTime); + absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> timestamp_handler = + CreateTimestampHandler(&clock); + ABSL_ASSERT_OK(timestamp_handler); + auto timestamp_creator = std::make_unique<TimestampCreator>( + timestamp_handler->get(), &DefaultHasherFactory()); + ClaimTimestampUpdater updater(std::move(timestamp_creator)); + absl::StatusOr<std::string> updated_manifest_store = + InsertTimestamp(updater, *manifest_store); + ABSL_ASSERT_OK(updated_manifest_store); + EXPECT_THAT(ValidateAndGetTime(*updated_manifest_store), + IsOkAndHolds(kTestTime)); + + EXPECT_EQ(updated_manifest_store->size(), manifest_store->size()); +} + +TEST(ClaimTimestampUpdater, InsertTimestampInUpdateManifest) { + absl::SimulatedClock clock; + clock.SetTime(kTestTime); + absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> timestamp_handler = + CreateTimestampHandler(&clock); + ABSL_ASSERT_OK(timestamp_handler); + + std::string timestamp_placeholder( + (*timestamp_handler) + ->max_timestamp_byte_size(DefaultHasherFactory().algorithm()), + '\0'); + CoseSign1TaggedStructure cose_sign1{ + .unprotected_header = + {.sig_tst2 = + TstContainer{.tst_tokens = {{.val = timestamp_placeholder}}}}, + .signature = "abc", + }; + std::string claim_signature = jumbf::EncodeSuperBox( + kClaimSignatureUuid, kClaimSignatureLabel, + {jumbf::EncodeCborBox(EncodeCoseSign1TaggedStructure(cose_sign1))}); + std::string manifest = jumbf::EncodeSuperBox( + kUpdateManifestUuid, "update_manifest", {claim_signature}); + std::string manifest_store = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest}); + + auto timestamp_creator = std::make_unique<TimestampCreator>( + timestamp_handler->get(), &DefaultHasherFactory()); + ClaimTimestampUpdater updater(std::move(timestamp_creator)); + absl::StatusOr<std::string> updated_manifest_store = + InsertTimestamp(updater, manifest_store); + ABSL_ASSERT_OK(updated_manifest_store); + + EXPECT_EQ(updated_manifest_store->size(), manifest_store.size()); +} + +TEST(ClaimTimestampUpdater, TimestampExistsNoOp) { + absl::SimulatedClock clock; + clock.SetTime(kTestTime); + absl::StatusOr<std::string> manifest_store = CreateSimpleManifestStoreBytes( + {.clock = &clock, .disable_timestamping = false}); + ABSL_ASSERT_OK(manifest_store); + + clock.AdvanceTime(absl::Seconds(100)); + absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> timestamp_handler = + CreateTimestampHandler(&clock); + ABSL_ASSERT_OK(timestamp_handler); + auto timestamp_creator = std::make_unique<TimestampCreator>( + timestamp_handler->get(), &DefaultHasherFactory()); + ClaimTimestampUpdater updater(std::move(timestamp_creator)); + absl::StatusOr<std::string> updated_manifest_store = + InsertTimestamp(updater, *manifest_store); + ABSL_ASSERT_OK(updated_manifest_store); + EXPECT_EQ(*updated_manifest_store, *manifest_store); +} + +} // namespace +} // namespace credentio
diff --git a/claim/validator.cc b/claim/validator.cc index 777cd27..139a5dd 100644 --- a/claim/validator.cc +++ b/claim/validator.cc
@@ -19,7 +19,7 @@ #include <string> #include <vector> -#include "absl/log/log.h" +#include "absl/log/absl_log.h" #include "absl/status/status.h" #include "absl/status/statusor.h" #include "absl/strings/str_cat.h" @@ -30,7 +30,6 @@ #include "constants/labels.h" #include "constants/status_codes.h" #include "jumbf/box.h" -#include "proto/assertion.pb.h" #include "proto/cose_verification_result.pb.h" #include "proto/generator_info.cbor.h" #include "proto/generator_info.pb.h" @@ -193,20 +192,15 @@ return claim; } -bool IsClaimBox(absl::string_view label, const Uuid& uuid) { - if (label == kClaimV2Label && uuid == kClaimUuid) { - return true; - } - if (label == kClaimV1Label && uuid == kClaimUuid) { - return true; - } - return false; -} - bool IsLegacyClaimBox(absl::string_view label, const Uuid& uuid) { return label == kClaimV1Label && uuid == kClaimUuid; } +bool IsClaimBox(absl::string_view label, const Uuid& uuid) { + return (label == kClaimV2Label && uuid == kClaimUuid) || + IsLegacyClaimBox(label, uuid); +} + // Checks if the signature URI is a self#jumbf location in the current manifest // that is being processed (provided in the `manifest` parameter). bool IsValidSignatureUri(absl::string_view uri, @@ -232,30 +226,29 @@ // Parse manifest boxes to find the claim and claim signature boxes. for (const auto& box : manifest.contents) { - if (box.Holds<SuperBox>()) { - const auto& description = box.Get<SuperBox>().description; - absl::string_view label = description.label.value_or(""); - Uuid uid = description.type_uuid; - if (IsLegacyClaimBox(label, uid)) { - validation_tracker.RecordFailure( - FailureStatusCode::kGoogleUnsupportedSpecVersion, - {.url = label, .explanation = "Unsupported claim version"}); + if (!box.Holds<SuperBox>()) { + continue; + } + const SuperBox& super_box = box.Get<SuperBox>(); + const auto& description = super_box.description; + absl::string_view label = description.label.value_or(""); + Uuid uid = description.type_uuid; + if (IsLegacyClaimBox(label, uid)) { + validation_tracker.RecordFailure( + FailureStatusCode::kGoogleUnsupportedSpecVersion, + {.url = label, .explanation = "Unsupported claim version"}); + return std::nullopt; + } + if (IsClaimBox(label, uid)) { + if (claim_box.has_value()) { + validation_tracker.RecordFailure(FailureStatusCode::kClaimMultiple, + {.url = label}); return std::nullopt; } - if (IsClaimBox(label, uid)) { - if (claim_box.has_value()) { - validation_tracker.RecordFailure(FailureStatusCode::kClaimMultiple, - {.url = label}); - } - if (box.Holds<SuperBox>()) { - claim_box = &box.Get<SuperBox>(); - claim_box_label = label; - } - } else if (label == kClaimSignatureLabel && uid == kClaimSignatureUuid) { - if (box.Holds<SuperBox>()) { - claim_signature_box = &box.Get<SuperBox>(); - } - } + claim_box = &super_box; + claim_box_label = label; + } else if (label == kClaimSignatureLabel && uid == kClaimSignatureUuid) { + claim_signature_box = &super_box; } } @@ -267,6 +260,7 @@ if (!claim_box.has_value()) { validation_tracker.RecordFailure(FailureStatusCode::kClaimMissing, {.url = claim_box_label}); + return std::nullopt; } else { auto result = ExtractCbor(*claim_box); if (!result.ok()) { @@ -359,8 +353,8 @@ validation_tracker.MergeStatuses(verification_result.validation_status()); if (!verification_result.has_signature_info() && !validation_tracker.HasFailures()) { - LOG_EVERY_N_SEC(ERROR, 60) << "Signature verification " - "failed but no failure was recorded"; + ABSL_LOG_EVERY_N_SEC(ERROR, 60) << "Signature verification " + "failed but no failure was recorded"; validation_tracker.RecordFailure(FailureStatusCode::kGoogleInternalError, {.url = kClaimSignatureLabel}); } @@ -369,21 +363,15 @@ {.url = kClaimSignatureLabel}); } - ClaimValidatorImpl::ValidateAssertionResult validate_assertion_result; - if (claim_box.has_value()) { - validate_assertion_result = - ValidateAssertions(assertion_uris, validation_tracker); - } + ClaimValidatorImpl::ValidateAssertionResult validate_assertion_result = + ValidateAssertions(assertion_uris, validation_tracker); if (validation_tracker.HasFailures()) { return std::nullopt; } auto options = CreateClaimOptions{ - .label = - claim_box.has_value() - ? std::string(claim_box.value()->description.label.value_or("")) - : "", + .label = claim_box_label, .claim_map = *claim_map, .created_assertions = absl::MakeSpan(validate_assertion_result.created_assertions), @@ -401,26 +389,21 @@ ClaimValidatorImpl::ValidateAssertions( const AssertionUris& assertion_uris, ValidationTracker& validation_tracker) const { - // Parse CBOR maps in hashed URI arrays - std::vector<HashedUriInternal> created_assertions, gathered_assertions; ClaimValidatorImpl::ValidateAssertionResult result; if (assertion_uris.created_assertions.has_value()) { - created_assertions = ParseHashedUriArray(*assertion_uris.created_assertions, - validation_tracker); + for (const auto& a : ParseHashedUriArray(*assertion_uris.created_assertions, + validation_tracker)) { + result.created_assertions.push_back( + a.ToProto(assertion_uris.default_algorithm)); + } } if (assertion_uris.gathered_assertions.has_value()) { - gathered_assertions = ParseHashedUriArray( - *assertion_uris.gathered_assertions, validation_tracker); + for (const auto& a : ParseHashedUriArray( + *assertion_uris.gathered_assertions, validation_tracker)) { + result.gathered_assertions.push_back( + a.ToProto(assertion_uris.default_algorithm)); + } } - for (const auto& a : created_assertions) { - result.created_assertions.push_back( - a.ToProto(assertion_uris.default_algorithm)); - } - for (const auto& a : gathered_assertions) { - result.gathered_assertions.push_back( - a.ToProto(assertion_uris.default_algorithm)); - } - std::vector<std::string> redacted_assertions; if (assertion_uris.redacted_assertions.has_value()) { for (int i = 0; i < assertion_uris.redacted_assertions->size(); ++i) { auto uri = assertion_uris.redacted_assertions->GetString(i); @@ -428,11 +411,10 @@ validation_tracker.RecordFailure(FailureStatusCode::kHashedUriMissing, {}); } else { - redacted_assertions.push_back(std::string(*uri)); + result.redacted_assertions.emplace_back(*uri); } } } - result.redacted_assertions = redacted_assertions; return result; }
diff --git a/claim/validator.h b/claim/validator.h index fd5d45f..b3472e6 100644 --- a/claim/validator.h +++ b/claim/validator.h
@@ -24,7 +24,6 @@ #include "cbor/cbor.h" #include "cose/verifier.h" #include "jumbf/box.h" -#include "proto/assertion.pb.h" #include "proto/manifest.pb.h" #include "validator/tracker.h"
diff --git a/claim/validator_test.cc b/claim/validator_test.cc index cc948c1..4d34823 100644 --- a/claim/validator_test.cc +++ b/claim/validator_test.cc
@@ -46,7 +46,6 @@ namespace credentio { namespace { -using ::absl_testing::IsOk; using ::cbor::FromJson; using ::credentio_testing::EqualsProto; using ::credentio_testing::ParseTextProtoOrDie; @@ -170,7 +169,7 @@ TEST(ValidateClaimTest, ClaimMissing) { auto manifest = CreateManifestBuilder(); - ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimSignatureBox("some cbor"))); MockCoseVerifier mock_cose_verifier; TestValidationTracker t; ClaimValidatorImpl validator(&mock_cose_verifier); @@ -184,7 +183,7 @@ TEST(ValidateClaimTest, ClaimSignatureMissing) { auto manifest = CreateManifestBuilder(); - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromCbor("some cbor"))); MockCoseVerifier mock_cose_verifier; TestValidationTracker t; ClaimValidatorImpl validator(&mock_cose_verifier); @@ -198,7 +197,7 @@ TEST(ValidateClaimTest, BothClaimAndSignatureMissing) { auto manifest = CreateManifestBuilder(); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; TestValidationTracker t; ClaimValidatorImpl validator(&mock_cose_verifier); @@ -207,8 +206,6 @@ auto failures = t.GetFailures(); EXPECT_THAT(t.GetFailures(), ContainsFailure(FailureStatusCode::kClaimMissing)); - EXPECT_THAT(t.GetFailures(), - ContainsFailure(FailureStatusCode::kClaimSignatureMissing)); EXPECT_THAT(t.GetSuccesses(), IsEmpty()); EXPECT_THAT(result, Eq(std::nullopt)); } @@ -216,18 +213,17 @@ TEST(ValidateClaimTest, MultipleClaims) { auto manifest = CreateManifestBuilder(); MockCoseVerifier mock_cose_verifier; - EXPECT_CALL(mock_cose_verifier, Verify) - .WillOnce(Return(CoseVerificationResult())); - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")), IsOk()); - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some other cbor")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk()); + EXPECT_CALL(mock_cose_verifier, Verify).Times(0); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromCbor("some cbor"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromCbor("some other cbor"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimSignatureBox("some cbor"))); TestValidationTracker t; ClaimValidatorImpl validator(&mock_cose_verifier); auto result = ValidateWithBuilder(validator, std::move(manifest), t.tracker()); EXPECT_THAT(t.GetFailures(), ContainsFailure(FailureStatusCode::kClaimMultiple)); + EXPECT_THAT(t.GetFailures(), SizeIs(1)); EXPECT_THAT(t.GetSuccesses(), IsEmpty()); EXPECT_THAT(result, Eq(std::nullopt)); } @@ -236,8 +232,8 @@ auto manifest = CreateManifestBuilder(); auto claim_box = CreateClaimBoxWithMultipleContents("some cbor", "some other cbor"); - ASSERT_THAT(manifest.AddChild(std::move(claim_box)), IsOk()); - ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(std::move(claim_box))); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimSignatureBox("some cbor"))); MockCoseVerifier mock_cose_verifier; TestValidationTracker t; ClaimValidatorImpl validator(&mock_cose_verifier); @@ -255,11 +251,10 @@ kClaimUuid, jumbf::DescriptionBoxOptions{.label = absl::Cord(kClaimV2Label)}); SuperBoxBuilder child_box(kClaimSignatureUuid, {}); - ASSERT_THAT(child_box.AddContent(jumbf::kCborBoxType, absl::Cord("foo")), - IsOk()); - ASSERT_THAT(claim_box.AddChild(std::move(child_box)), IsOk()); - ASSERT_THAT(manifest.AddChild(std::move(claim_box)), IsOk()); - ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk()); + ABSL_ASSERT_OK(child_box.AddContent(jumbf::kCborBoxType, absl::Cord("foo"))); + ABSL_ASSERT_OK(claim_box.AddChild(std::move(child_box))); + ABSL_ASSERT_OK(manifest.AddChild(std::move(claim_box))); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimSignatureBox("some cbor"))); MockCoseVerifier mock_cose_verifier; TestValidationTracker t; ClaimValidatorImpl validator(&mock_cose_verifier); @@ -273,10 +268,10 @@ TEST(ValidateClaimTest, ClaimSignatureHasMultipleContents) { auto manifest = CreateManifestBuilder(); - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromCbor("some cbor"))); auto claim_signature_box = CreateClaimSignatureBoxWithMultipleContents( "some cbor", "some other cbor"); - ASSERT_THAT(manifest.AddChild(std::move(claim_signature_box)), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(std::move(claim_signature_box))); MockCoseVerifier mock_cose_verifier; TestValidationTracker t; ClaimValidatorImpl validator(&mock_cose_verifier); @@ -290,15 +285,14 @@ TEST(ValidateClaimTest, ClaimSignatureIsNotCborBox) { auto manifest = CreateManifestBuilder(); - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some_cbor")), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromCbor("some_cbor"))); SuperBoxBuilder claim_signature_box( kClaimSignatureUuid, jumbf::DescriptionBoxOptions{.label = absl::Cord(kClaimSignatureLabel)}); SuperBoxBuilder child_box(kClaimUuid, {}); - ASSERT_THAT(child_box.AddContent(jumbf::kCborBoxType, absl::Cord("foo")), - IsOk()); - ASSERT_THAT(claim_signature_box.AddChild(std::move(child_box)), IsOk()); - ASSERT_THAT(manifest.AddChild(std::move(claim_signature_box)), IsOk()); + ABSL_ASSERT_OK(child_box.AddContent(jumbf::kCborBoxType, absl::Cord("foo"))); + ABSL_ASSERT_OK(claim_signature_box.AddChild(std::move(child_box))); + ABSL_ASSERT_OK(manifest.AddChild(std::move(claim_signature_box))); MockCoseVerifier mock_cose_verifier; TestValidationTracker t; ClaimValidatorImpl validator(&mock_cose_verifier); @@ -312,11 +306,10 @@ TEST(ValidateClaimTest, ClaimCborIsNotAMap) { auto manifest = CreateManifestBuilder(); - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(R"json([1, 2, 3])json")), - IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimBoxFromJson(R"json([1, 2, 3])json"))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); MockCoseVerifier mock_cose_verifier; EXPECT_CALL(mock_cose_verifier, Verify) .WillOnce(Return(CoseVerificationResult())); @@ -332,11 +325,9 @@ TEST(ValidateClaimTest, ClaimHasIncorrectSignature) { auto manifest = CreateManifestBuilder(); - ASSERT_THAT(manifest.AddChild(CreateClaimBox({.signature = "not_valid"})), - IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBox({.signature = "not_valid"}))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); MockCoseVerifier mock_cose_verifier; EXPECT_CALL(mock_cose_verifier, Verify) .WillOnce(Return(CoseVerificationResult())); @@ -352,12 +343,10 @@ TEST(ValidateClaimTest, CoseVerificationFails) { auto manifest = CreateManifestBuilder(); - ASSERT_THAT(manifest.AddChild( - CreateClaimBox({.signature = "self#jumbf=c2pa.signature"})), - IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); + ABSL_ASSERT_OK(manifest.AddChild( + CreateClaimBox({.signature = "self#jumbf=c2pa.signature"}))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); MockCoseVerifier mock_cose_verifier; EXPECT_CALL(mock_cose_verifier, Verify) .WillOnce([](absl::string_view cose_certificate, @@ -379,12 +368,10 @@ TEST(ValidateClaimTest, CoseVerifierFailsWithoutRecordingStatus) { auto manifest = CreateManifestBuilder(); - ASSERT_THAT(manifest.AddChild( - CreateClaimBox({.signature = "self#jumbf=c2pa.signature"})), - IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); + ABSL_ASSERT_OK(manifest.AddChild( + CreateClaimBox({.signature = "self#jumbf=c2pa.signature"}))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); MockCoseVerifier mock_cose_verifier; EXPECT_CALL(mock_cose_verifier, Verify) .WillOnce(Return(CoseVerificationResult())); @@ -406,11 +393,10 @@ "created_assertions": [{"url":"self#jumbf=c2pa.some_assertion"}], "redacted_assertions": ["self#jumbf=c2pa.some_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; EXPECT_CALL(mock_cose_verifier, Verify) .WillOnce(Return(CoseVerificationResult())); @@ -434,11 +420,10 @@ }], "redacted_assertions": ["self#jumbf=c2pa.some_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; EXPECT_CALL(mock_cose_verifier, Verify) .WillOnce(Return(CoseVerificationResult())); @@ -461,11 +446,10 @@ }], "redacted_assertions": ["self#jumbf=c2pa.some_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; EXPECT_CALL(mock_cose_verifier, Verify) .WillOnce(Return(CoseVerificationResult())); @@ -492,11 +476,10 @@ }], "redacted_assertions": ["self#jumbf=c2pa.some_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; EXPECT_CALL(mock_cose_verifier, Verify) .WillOnce(Return(CoseVerificationResult())); @@ -520,11 +503,10 @@ }], "redacted_assertions": [12] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; EXPECT_CALL(mock_cose_verifier, Verify) .WillOnce(Return(CoseVerificationResult())); @@ -563,12 +545,11 @@ {"url": "self#jumbf=c2pa.some_gathered_assertion", "hash": "b64'aGVsbG8'"} ] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), - IsOk()); // Uses c2pa.claim.v2 label. - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild( + CreateClaimBoxFromJson(claim_json))); // Uses c2pa.claim.v2 label. + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; SignatureInfo signature_info; @@ -611,11 +592,10 @@ ], "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateOldClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateOldClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; TestValidationTracker t; ClaimValidatorImpl validator(&mock_cose_verifier); @@ -652,11 +632,10 @@ }], "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; EXPECT_CALL(mock_cose_verifier, Verify) .WillOnce(Return(CoseVerificationResult())); @@ -698,11 +677,10 @@ }], "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; SignatureInfo signature_info; signature_info.set_issuer("fake_issuer"); @@ -760,11 +738,10 @@ }], "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; SignatureInfo signature_info; signature_info.set_issuer("fake_issuer"); @@ -826,11 +803,10 @@ }], "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateOldClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateOldClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; TestValidationTracker t; ClaimValidatorImpl validator(&mock_cose_verifier); @@ -870,11 +846,10 @@ }], "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; SignatureInfo signature_info; signature_info.set_issuer("fake_issuer"); @@ -922,11 +897,10 @@ }], "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; SignatureInfo signature_info; signature_info.set_issuer("fake_issuer"); @@ -968,11 +942,10 @@ }], "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; SignatureInfo signature_info; signature_info.set_issuer("fake_issuer"); @@ -1012,11 +985,10 @@ }], "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; SignatureInfo signature_info; signature_info.set_issuer("fake_issuer"); @@ -1060,11 +1032,10 @@ }], "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] })json"; - ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); - ASSERT_THAT( - manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), - IsOk()); - ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json))); + ABSL_ASSERT_OK( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate"))); + ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox())); MockCoseVerifier mock_cose_verifier; SignatureInfo signature_info; signature_info.set_issuer("fake_issuer");
diff --git a/constants/labels.h b/constants/labels.h index 748d9ae..e1f9320 100644 --- a/constants/labels.h +++ b/constants/labels.h
@@ -122,6 +122,8 @@ inline constexpr absl::string_view kThumbnailIngredientAssertionLabelPrefix = "c2pa.thumbnail.ingredient."; inline constexpr absl::string_view kMetadataAssertionLabelSuffix = ".metadata"; +inline constexpr absl::string_view kExifAssertionLabel = "stds.exif"; +inline constexpr absl::string_view kIptcAssertionLabel = "stds.iptc"; // The UUID of the C2PA BMFF box is D8FEC3D6-1B0E-483C-9297-5828877EC481. // See
diff --git a/constants/status_codes.h b/constants/status_codes.h index 1c70668..b2a49cc 100644 --- a/constants/status_codes.h +++ b/constants/status_codes.h
@@ -69,10 +69,7 @@ // Indicates that the timestamp's time is outside of the range that can be // handled by this validator. The timestamp may be considered valid by // other implementations. - kTimestampOutOfRange, // com.google.timeStamp.outOfRange - kTimestampV1FormatInV2Header, // com.google.timeStamp.V1FormatInV2Header - kTimestampV2MessageImprintInV1Format, - // com.google.timeStamp.V2MessageImprintInV1Format + kTimestampOutOfRange, // com.google.timeStamp.outOfRange // go/keep-sorted end // Standard informational codes @@ -260,12 +257,6 @@ case credentio::InformationalStatusCode::kTimestampOutOfRange: sink.Append("com.google.timeStamp.outOfRange"); return; - case InformationalStatusCode::kTimestampV1FormatInV2Header: - sink.Append("com.google.timeStamp.V1FormatInV2Header"); - return; - case InformationalStatusCode::kTimestampV2MessageImprintInV1Format: - sink.Append("com.google.timeStamp.V2MessageImprintInV1Format"); - return; case InformationalStatusCode::kIngredientUnknownProvenance: sink.Append("ingredient.unknownProvenance"); return;
diff --git a/cose/BUILD b/cose/BUILD index 1968d65..f811b95 100644 --- a/cose/BUILD +++ b/cose/BUILD
@@ -204,3 +204,91 @@ "@googletest//:gtest_main", ], ) + +cc_library( + name = "timestamp_creator", + srcs = ["timestamp_creator.cc"], + hdrs = ["timestamp_creator.h"], + deps = [ + ":sig_structure", + "//async:async_context", + "//crypto:hash", + "//crypto:timestamp_write_handler", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@libcppbor", + ], +) + +cc_test( + name = "timestamp_creator_test", + srcs = ["timestamp_creator_test.cc"], + deps = [ + ":sig_structure", + ":simple_cms_parser", + ":timestamp_creator", + "//crypto:timestamp_write_handler", + "//crypto/default:hasher", + "//testing:create_test_crypto_write_handler", + "//testing:local_key_crypto_write_handler", + "//tsp:parsed_timestamp_token", + "//tsp:timestamp_verifier", + "//tsp:verified_timestamp", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/time:clock_interface", + "@abseil-cpp//absl/time:simulated_clock", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "timestamp_updater", + srcs = ["timestamp_updater.cc"], + hdrs = ["timestamp_updater.h"], + deps = [ + ":sig_structure", + ":timestamp_creator", + "//async:async_context", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@libcppbor", + ], +) + +cc_test( + name = "timestamp_updater_test", + srcs = ["timestamp_updater_test.cc"], + deps = [ + ":sig_structure", + ":simple_cms_parser", + ":timestamp_creator", + ":timestamp_updater", + "//async:async_context", + "//crypto:timestamp_write_handler", + "//crypto/default:hasher", + "//testing:create_test_crypto_write_handler", + "//tsp:parsed_timestamp_token", + "//tsp:timestamp_verifier", + "//tsp:verified_timestamp", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/time:clock_interface", + "@abseil-cpp//absl/time:simulated_clock", + "@googletest//:gtest_main", + ], +)
diff --git a/cose/cose_sign1.cc b/cose/cose_sign1.cc index 5af913c..0ef7d4c 100644 --- a/cose/cose_sign1.cc +++ b/cose/cose_sign1.cc
@@ -154,37 +154,14 @@ absl::string_view& external_payload, const HashCheckerFactory& hash_checker_factory, ValidationStatusSet* status_set, absl::string_view protected_headers_bstr, - absl::string_view signature_bstr, absl::string_view signature, + absl::string_view signature_bstr, const VersionedVerifiedTimestamp& oldest_trusted_timestamp) { switch (oldest_trusted_timestamp.version) { - case TimestampVersion::kV1: { - ValidationStatusSet v1_status; - if (CheckMessageImprintHash( - hash_checker_factory, &v1_status, - oldest_trusted_timestamp.timestamp, - ConstructSigStructure(protected_headers_bstr, external_payload, - "CounterSignature"))) { - status_set->MergeFrom(v1_status); - return true; - } - - ValidationStatusSet v2_status; - if (CheckMessageImprintHash( - hash_checker_factory, &v2_status, - oldest_trusted_timestamp.timestamp, - ConstructSigStructure(protected_headers_bstr, signature, - "CounterSignature"))) { - status_set->MergeFrom(v2_status); - RecordStatus( - status_set, - InformationalStatusCode::kTimestampV2MessageImprintInV1Format, - {.url = kClaimSignatureLabel}); - return true; - } - - status_set->MergeFrom(v1_status); - return false; - } + case TimestampVersion::kV1: + return CheckMessageImprintHash( + hash_checker_factory, status_set, oldest_trusted_timestamp.timestamp, + ConstructSigStructure(protected_headers_bstr, external_payload, + "CounterSignature")); case TimestampVersion::kV2: return CheckMessageImprintHash( hash_checker_factory, status_set, oldest_trusted_timestamp.timestamp, @@ -221,52 +198,38 @@ return status; } -absl::StatusOr<VersionedVerifiedTimestamp> VerifyV1TimestampInternal( - absl::string_view timestamp_resp, - const CryptoReadHandler& crypto_read_handler) { - ABSL_ASSIGN_OR_RETURN(auto timestamp_token, - ParseTimestampResp(timestamp_resp)); - ABSL_ASSIGN_OR_RETURN(auto verified_timestamp, - crypto_read_handler.VerifyTimestamp(timestamp_token)); - return VersionedVerifiedTimestamp{.timestamp = std::move(verified_timestamp), - .version = TimestampVersion::kV1}; -} - absl::StatusOr<VersionedVerifiedTimestamp> VerifyV1Timestamp( absl::string_view timestamp_resp, const CryptoReadHandler& crypto_read_handler, ValidationStatusSet* status_set) { - auto result = VerifyV1TimestampInternal(timestamp_resp, crypto_read_handler); - if (!result.ok()) { - return RecordInformationalAndReturnStatus(result.status(), status_set); + auto timestamp_token = ParseTimestampResp(timestamp_resp); + if (!timestamp_token.ok()) { + return RecordInformationalAndReturnStatus(timestamp_token.status(), + status_set); } - return result; + auto verified_timestamp = + crypto_read_handler.VerifyTimestamp(*timestamp_token); + if (!verified_timestamp.ok()) { + return RecordInformationalAndReturnStatus(verified_timestamp.status(), + status_set); + } + return VersionedVerifiedTimestamp{.timestamp = *std::move(verified_timestamp), + .version = TimestampVersion::kV1}; } absl::StatusOr<VersionedVerifiedTimestamp> VerifyV2Timestamp( absl::string_view timestamp_token, const CryptoReadHandler& crypto_read_handler, ValidationStatusSet* status_set) { - absl::StatusOr<VerifiedTimestamp> default_attempt = + absl::StatusOr<VerifiedTimestamp> attempt = crypto_read_handler.VerifyTimestamp(timestamp_token); - if (default_attempt.ok()) { - return VersionedVerifiedTimestamp{.timestamp = *std::move(default_attempt), + if (attempt.ok()) { + return VersionedVerifiedTimestamp{.timestamp = *std::move(attempt), .version = TimestampVersion::kV2}; } - absl::StatusOr<VersionedVerifiedTimestamp> fallback_attempt = - VerifyV1TimestampInternal(timestamp_token, crypto_read_handler); - - if (fallback_attempt.ok()) { - RecordStatus(status_set, - InformationalStatusCode::kTimestampV1FormatInV2Header, - {.url = kClaimSignatureLabel}); - return fallback_attempt; - } - - return RecordInformationalAndReturnStatus(default_attempt.status(), - status_set); + return RecordInformationalAndReturnStatus(attempt.status(), status_set); } // Returns the oldest valid timestamp found in the `sigTst2` or `sigTst` @@ -379,7 +342,7 @@ if (check_message_imprint && !MessageImprintMatch(external_payload, hash_checker_factory_, status_set, cose_sign1.protected_header, - cose_sign1.signature_bstr(), cose_sign1.signature, + cose_sign1.signature_bstr(), *oldest_trusted_timestamp)) { oldest_trusted_timestamp = std::nullopt; } else { @@ -482,6 +445,11 @@ {.url = kClaimSignatureLabel}); } } + absl::StatusOr<std::string> serial_number = + parsed_certs.GetSerialNumberHex(0); + if (serial_number.ok()) { + signature_info.set_cert_serial_number(*serial_number); + } absl::StatusOr<std::string> subject = parsed_certs.GetSubject(0); if (subject.ok()) { signature_info.set_issuer(*subject);
diff --git a/cose/timestamp_creator.cc b/cose/timestamp_creator.cc new file mode 100644 index 0000000..1423823 --- /dev/null +++ b/cose/timestamp_creator.cc
@@ -0,0 +1,71 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "cose/timestamp_creator.h" + +#include <memory> +#include <optional> +#include <string> +#include <utility> + +#include "absl/functional/any_invocable.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "async/async_context.h" +#include "cose/sig_structure.h" +#include "cppbor/cppbor.h" +#include "crypto/hash.h" +#include "crypto/timestamp_write_handler.h" + +namespace credentio { +namespace { + +absl::StatusOr<std::string> MessageImprintForTimestamp( + const HasherFactory& hasher_factory, absl::string_view body_protected, + absl::string_view message) { + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_obtaining_the_time_stamp + std::string encoded_message = EncodeSig1Structure({ + .context = "CounterSignature", + .body_protected = std::string(body_protected), + .external_aad = "", + .payload = std::string(message), + }); + ABSL_ASSIGN_OR_RETURN(auto hasher, hasher_factory.Create()); + hasher->Update(encoded_message); + return hasher->Digest(); +} +} // namespace + +void TimestampCreator::CreateTimestamp( + const CoseSign1TaggedStructure& cose_sign1, Options options, + absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> cb) const { + auto signature = cppbor::Bstr(cose_sign1.signature).toString(); + auto hashed_message = MessageImprintForTimestamp( + hasher_factory_, cose_sign1.protected_header, std::move(signature)); + if (!hashed_message.ok()) { + AsyncResolver<std::string>(options.async_context, std::move(cb)) + .Return(hashed_message.status()); + return; + } + TimestampWriteHandler::Options timestamp_handler_options; + timestamp_handler_options.async_context = std::move(options.async_context); + + timestamp_handler_.CreateTimestamp(hasher_factory_.algorithm(), + std::move(*hashed_message), + timestamp_handler_options, std::move(cb)); +} + +} // namespace credentio
diff --git a/cose/timestamp_creator.h b/cose/timestamp_creator.h new file mode 100644 index 0000000..817ad23 --- /dev/null +++ b/cose/timestamp_creator.h
@@ -0,0 +1,57 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_COSE_TIMESTAMP_CREATOR_H_ +#define THIRD_PARTY_CREDENTIO_COSE_TIMESTAMP_CREATOR_H_ + +#include <optional> +#include <string> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/log/die_if_null.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "async/async_context.h" +#include "cose/sig_structure.h" +#include "crypto/hash.h" +#include "crypto/timestamp_write_handler.h" + +namespace credentio { +// Creator of timestamps for COSE C2PA claim signatures. +class TimestampCreator { + public: + struct Options { + AsyncContext async_context = AsyncContext(); + }; + explicit TimestampCreator( + const TimestampWriteHandler* absl_nonnull timestamp_creator, + const HasherFactory* absl_nonnull hasher_factory) + : timestamp_handler_(*ABSL_DIE_IF_NULL(timestamp_creator)), + hasher_factory_(*ABSL_DIE_IF_NULL(hasher_factory)) {} + + // Creates a TimeStampToken that countersigns the given + // CoseSign1TaggedStructure. + void CreateTimestamp( + const CoseSign1TaggedStructure& cose_sign1, Options options, + absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> cb) const; + + private: + const TimestampWriteHandler& timestamp_handler_; + const HasherFactory& hasher_factory_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_COSE_TIMESTAMP_CREATOR_H_
diff --git a/cose/timestamp_creator_test.cc b/cose/timestamp_creator_test.cc new file mode 100644 index 0000000..fe2abab --- /dev/null +++ b/cose/timestamp_creator_test.cc
@@ -0,0 +1,94 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "cose/timestamp_creator.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/time/clock_interface.h" +#include "absl/time/simulated_clock.h" +#include "absl/time/time.h" +#include "cose/sig_structure.h" +#include "cose/simple_cms_parser.h" +#include "crypto/default/hasher.h" +#include "crypto/timestamp_write_handler.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "testing/create_test_crypto_write_handler.h" +#include "testing/local_key_crypto_write_handler.h" +#include "tsp/parsed_timestamp_token.h" +#include "tsp/timestamp_verifier.h" +#include "tsp/verified_timestamp.h" + +namespace credentio { +namespace { + +constexpr absl::Time kTestTime = absl::FromUnixSeconds(1741403167); + +absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> CreateTimestampHandler( + absl::Clock* clock) { + return CreateTestCryptoWriteHandler({.clock = clock}); +} + +absl::StatusOr<std::string> CreateTimestamp( + TimestampCreator& creator, TimestampCreator::Options options, + CoseSign1TaggedStructure cose_sign1) { + absl::StatusOr<std::string> result = + absl::InternalError("Callback not called"); + // We pass a ref to this temporary struct to catch bugs where the + // provided ref might be accessed after CreateTimestamp returns. + CoseSign1TaggedStructure cose_sign1_copy = cose_sign1; + creator.CreateTimestamp(cose_sign1_copy, std::move(options), + [&](absl::StatusOr<std::string> cb_result) { + result = std::move(cb_result); + }); + return result; +} + +TEST(TimestampCreator, CreateTimestamp) { + // Create a timestamp creator. + absl::SimulatedClock clock; + clock.SetTime(kTestTime); + absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> timestamp_handler = + CreateTimestampHandler(&clock); + ABSL_ASSERT_OK(timestamp_handler); + TimestampCreator timestamp_creator(timestamp_handler->get(), + &DefaultHasherFactory()); + // Create a timestamp. + CoseSign1TaggedStructure cose_sign1{.protected_header = "def", + .signature = "abc"}; + absl::StatusOr<std::string> timestamp = + CreateTimestamp(timestamp_creator, /*options=*/{}, cose_sign1); + ABSL_ASSERT_OK(timestamp); + // Verify the timestamp. + TimestampVerifier verifier(/*tsa_roots=*/nullptr); + SimpleCmsParser parser; + absl::StatusOr<std::unique_ptr<ParsedTimestampToken>> parsed_token = + parser.ParseTimestampToken(*timestamp); + ABSL_ASSERT_OK(parsed_token); + absl::StatusOr<VerifiedTimestamp> verified_timestamp = + verifier.VerifyTimestampToken(**parsed_token); + ABSL_ASSERT_OK(verified_timestamp); + EXPECT_EQ(verified_timestamp->asserted_time(), kTestTime); +} + +} // namespace +} // namespace credentio
diff --git a/cose/timestamp_updater.cc b/cose/timestamp_updater.cc new file mode 100644 index 0000000..d8b49a1 --- /dev/null +++ b/cose/timestamp_updater.cc
@@ -0,0 +1,84 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "cose/timestamp_updater.h" + +#include <cstdint> +#include <string> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "async/async_context.h" +#include "cose/sig_structure.h" +#include "cose/timestamp_creator.h" +#include "cppbor/cppbor.h" + +namespace credentio { + +void TimestampUpdater::UpdateTimestamp( + std::string* absl_nonnull encoded_cose_sign1, AsyncContext async_context, + absl::AnyInvocable<void(absl::Status) &&> cb) const { + int64_t original_size = encoded_cose_sign1->size(); + auto cose_sign1_or = DecodeCoseSign1TaggedStructure(*encoded_cose_sign1); + if (!cose_sign1_or.ok()) { + AsyncResolver<void>(async_context, std::move(cb)) + .Return(cose_sign1_or.status()); + return; + } + CoseSign1TaggedStructure cose_sign1 = *std::move(cose_sign1_or); + + absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> done_cb = + [cose_sign1 = cose_sign1, original_size, encoded_cose_sign1, + async_context, cb = std::move(cb)]( + absl::StatusOr<std::string> status_or_timestamp) mutable { + AsyncResolver<void> resolver(async_context, std::move(cb)); + if (!status_or_timestamp.ok()) { + resolver.Return(status_or_timestamp.status()); + return; + } + cose_sign1.unprotected_header = { + .sig_tst2 = + TstContainer{ + .tst_tokens = {{.val = std::move(*status_or_timestamp)}}}, + .pad = "", + }; + int64_t delta = + original_size - EncodeCoseSign1TaggedStructure(cose_sign1).size(); + if (delta < 0) { + resolver.Return( + absl::InvalidArgumentError("Not enough space for timestamp.")); + return; + } + // Adjust padding to account for the encoded size of the `pad` field + // (size zero is encoded with 1 byte). + int64_t pad_size = delta - cppbor::Uint(delta).encodedSize() + 1; + cose_sign1.unprotected_header.pad = std::string(pad_size, '\0'); + *encoded_cose_sign1 = EncodeCoseSign1TaggedStructure(cose_sign1); + if (encoded_cose_sign1->size() != original_size) { + resolver.Return( + absl::InternalError("wrong size after updating timestamp")); + return; + } + resolver.Return(absl::OkStatus()); + }; + timestamp_creator_.CreateTimestamp( + cose_sign1, TimestampCreator::Options{.async_context = async_context}, + std::move(done_cb)); +} + +} // namespace credentio
diff --git a/cose/timestamp_updater.h b/cose/timestamp_updater.h new file mode 100644 index 0000000..f7200bc --- /dev/null +++ b/cose/timestamp_updater.h
@@ -0,0 +1,50 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_COSE_TIMESTAMP_UPDATER_H_ +#define THIRD_PARTY_CREDENTIO_COSE_TIMESTAMP_UPDATER_H_ + +#include <string> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "async/async_context.h" +#include "cose/timestamp_creator.h" + +namespace credentio { + +// Updates the timestamp in a COSE C2PA claim signature. +class TimestampUpdater { + public: + explicit TimestampUpdater( + const TimestampCreator* absl_nonnull timestamp_creator) + : timestamp_creator_(*ABSL_DIE_IF_NULL(timestamp_creator)) {} + + // Creates a timestamp, and if successful, updates the timestamp in the + // provided serialized COSE_Sign1_Tagged structure. It guarantees that the + // size of the serialized structure does not change. + void UpdateTimestamp(std::string* absl_nonnull encoded_cose_sign1, + AsyncContext async_context, + absl::AnyInvocable<void(absl::Status) &&> cb) const; + + private: + const TimestampCreator& timestamp_creator_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_COSE_TIMESTAMP_UPDATER_H_
diff --git a/cose/timestamp_updater_test.cc b/cose/timestamp_updater_test.cc new file mode 100644 index 0000000..50511f2 --- /dev/null +++ b/cose/timestamp_updater_test.cc
@@ -0,0 +1,142 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "cose/timestamp_updater.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "absl/time/clock_interface.h" +#include "absl/time/simulated_clock.h" +#include "absl/time/time.h" +#include "async/async_context.h" +#include "cose/sig_structure.h" +#include "cose/simple_cms_parser.h" +#include "cose/timestamp_creator.h" +#include "crypto/default/hasher.h" +#include "crypto/timestamp_write_handler.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "testing/create_test_crypto_write_handler.h" +#include "tsp/parsed_timestamp_token.h" +#include "tsp/timestamp_verifier.h" +#include "tsp/verified_timestamp.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::Not; +using ::testing::SizeIs; + +constexpr absl::Time kTestTime = absl::FromUnixSeconds(1741403167); + +absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> CreateTimestampHandler( + absl::Clock* clock) { + return CreateTestCryptoWriteHandler({.clock = clock}); +} + +absl::StatusOr<std::string> UpdateTimestamp( + TimestampUpdater& updater, absl::string_view encoded_cose_sign1) { + std::string updated_encoded_cose_sign1(encoded_cose_sign1); + absl::Notification done; + absl::Status status; + updater.UpdateTimestamp(&updated_encoded_cose_sign1, AsyncContext(), + [&](absl::Status s) { + status = std::move(s); + done.Notify(); + }); + done.WaitForNotification(); + if (!status.ok()) return status; + return updated_encoded_cose_sign1; +} + +TEST(TimestampUpdater, UpdateTimestamp) { + // Create a timestamp creator. + absl::SimulatedClock clock; + clock.SetTime(kTestTime); + absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> timestamp_handler = + CreateTimestampHandler(&clock); + ABSL_ASSERT_OK(timestamp_handler); + TimestampCreator timestamp_creator(timestamp_handler->get(), + &DefaultHasherFactory()); + TimestampUpdater timestamp_updater(×tamp_creator); + + // Create a timestamp placeholder. + CoseSign1TaggedStructure to_be_updated{ + .protected_header = "def", + .unprotected_header = + { + .sig_tst2 = TstContainer{.tst_tokens = {{.val = std::string( + 1200, '\0')}}}, + .pad = std::string(25, '\0'), + }, + .signature = "abc", + }; + std::string encoded_cose_sign1 = + EncodeCoseSign1TaggedStructure(to_be_updated); + // Update the timestamp. + absl::StatusOr<std::string> updated_cose_sign1 = + UpdateTimestamp(timestamp_updater, encoded_cose_sign1); + ABSL_ASSERT_OK(updated_cose_sign1); + EXPECT_EQ(updated_cose_sign1->size(), encoded_cose_sign1.size()); + + // Verify the updated timestamp. + TimestampVerifier verifier(/*tsa_roots=*/nullptr); + SimpleCmsParser parser; + absl::StatusOr<CoseSign1TaggedStructure> decoded = + DecodeCoseSign1TaggedStructure(*updated_cose_sign1); + ABSL_ASSERT_OK(decoded); + ASSERT_THAT(decoded->unprotected_header.sig_tst2->tst_tokens, SizeIs(1)); + ASSERT_THAT(decoded->unprotected_header.sig_tst2->tst_tokens[0].val, + Not(IsEmpty())); + absl::StatusOr<std::unique_ptr<ParsedTimestampToken>> parsed_token = + parser.ParseTimestampToken( + decoded->unprotected_header.sig_tst2->tst_tokens[0].val); + ABSL_ASSERT_OK(parsed_token); + absl::StatusOr<VerifiedTimestamp> updated_verified_timestamp = + verifier.VerifyTimestampToken(**parsed_token); + ABSL_ASSERT_OK(updated_verified_timestamp); + EXPECT_EQ(updated_verified_timestamp->asserted_time(), kTestTime); +} + +TEST(TimestampUpdater, UpdateTimestampNotEnoughSpace) { + // Create a timestamp creator. + absl::SimulatedClock clock; + clock.SetTime(kTestTime); + absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> timestamp_handler = + CreateTimestampHandler(&clock); + ABSL_ASSERT_OK(timestamp_handler); + TimestampCreator timestamp_creator(timestamp_handler->get(), + &DefaultHasherFactory()); + TimestampUpdater timestamp_updater(×tamp_creator); + // Create a timestamp. + CoseSign1TaggedStructure cose_sign1; + EXPECT_THAT(UpdateTimestamp(timestamp_updater, + EncodeCoseSign1TaggedStructure(cose_sign1)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Not enough space"))); +} + +} // namespace +} // namespace credentio
diff --git a/crypto/BUILD b/crypto/BUILD index 37ee2ca..260fa40 100644 --- a/crypto/BUILD +++ b/crypto/BUILD
@@ -111,3 +111,61 @@ "@googletest//:gtest_main", ], ) + +cc_library( + name = "timestamp_write_handler", + hdrs = ["timestamp_write_handler.h"], + deps = [ + ":algorithms", + "//async:async_context", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status:statusor", + ], +) + +cc_library( + name = "crypto_write_handler", + hdrs = ["crypto_write_handler.h"], + deps = [ + ":algorithms", + ":timestamp_write_handler", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "templated_crypto_write_handler", + hdrs = ["templated_crypto_write_handler.h"], + deps = [ + ":algorithms", + ":crypto_write_handler", + ":timestamp_write_handler", + "//async:async_context", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "templated_crypto_write_handler_test", + srcs = ["templated_crypto_write_handler_test.cc"], + deps = [ + ":algorithms", + ":crypto_write_handler", + ":hash", + ":templated_crypto_write_handler", + "//crypto/default:hasher", + "//testing:create_test_crypto_write_handler", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/time:simulated_clock", + "@googletest//:gtest_main", + ], +)
diff --git a/crypto/crypto_read_handler.h b/crypto/crypto_read_handler.h index d2ab5ea..fdeb26c 100644 --- a/crypto/crypto_read_handler.h +++ b/crypto/crypto_read_handler.h
@@ -136,12 +136,14 @@ // Verifies the signature and validity of the DER-encoded OCSP response // against the provided issuer certificate (`issuer_certificate_der`) or // an authorized responder, and returns the revocation status of the target - // certificate (`certificate_der`) at the specified verification time - // (`verify_time`). + // certificate (`certificate_der`) at the specified `asserted_time`. // // The verification process ensures that the OCSP response is correctly signed - // by the issuer or a delegated responder, conforms to RFC 6960 requirements, - // matches the target certificate, and is valid at `verify_time`. + // by the issuer or a delegated responder, conforms to RFC 6960 and C2PA + // requirements, matches the target certificate, and is valid for + // `asserted_time` and the current time (specifically, the current time is not + // earlier than `thisUpdate` and either `asserted_time < thisUpdate` or + // `asserted_time` is within the `[thisUpdate, nextUpdate)` interval). // // Arguments: // ocsp_response_der: The DER-encoded OCSP response. @@ -149,22 +151,24 @@ // status is being checked. // issuer_certificate_der: The DER-encoded X.509 certificate of the issuer // that signed `certificate_der`. - // verify_time: The time at which the OCSP response validity and revocation - // status are evaluated. + // asserted_time: The time at which the certificate's revocation status is + // evaluated. This is the time from the trusted timestamp, if any, and + // otherwise the current time. // // Returns: // - INVALID_ARGUMENT if `certificate_der`, `issuer_certificate_der`, or the // OCSP response is malformed or invalid. // - UNAUTHENTICATED if the OCSP response signature fails verification or // if no matching response is found for `certificate_der`. - // - OUT_OF_RANGE if `verify_time` is outside the valid time window of the - // OCSP response (e.g., based on `thisUpdate` and `nextUpdate`). + // - OUT_OF_RANGE if `asserted_time` or the current time is outside the + // valid time window of the OCSP response (e.g., based on `thisUpdate` and + // `nextUpdate`). // - The verified `OCSPRevocationStatus` (kGood, kRevoked, or kUnknown) if // verification is successful. virtual absl::StatusOr<OCSPRevocationStatus> VerifyOcspResponse( absl::string_view ocsp_response_der, absl::string_view certificate_der, absl::string_view issuer_certificate_der, - absl::Time verify_time) const = 0; + absl::Time asserted_time) const = 0; // Returns the trust environment of this handler. virtual TrustEnvironment trust_environment() const = 0;
diff --git a/crypto/crypto_write_handler.h b/crypto/crypto_write_handler.h new file mode 100644 index 0000000..8837324 --- /dev/null +++ b/crypto/crypto_write_handler.h
@@ -0,0 +1,64 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_CRYPTO_WRITE_HANDLER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_CRYPTO_WRITE_HANDLER_H_ + +#include <cstdint> +#include <string> +#include <vector> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "crypto/timestamp_write_handler.h" + +namespace credentio { + +struct SignResponse { + // For COSE usage, this is the `signature` field of the COSE_Sign1[_Tagged] + // structure. + std::string signature; +}; + +class FixedKeySigner { + public: + virtual ~FixedKeySigner() = default; + // Returns the signing algorithm. + virtual SigningAlgorithm algorithm() const = 0; + // Returns the expected maximum size of signatures produced by Sign(). + virtual uint64_t max_signature_byte_size() const = 0; + // Returns the DER-encoded certificate chain. + virtual const std::vector<std::string>& certificate_chain() const = 0; + // Returns OCSP responses to staple, for certificates in the certificate + // chain. + virtual const std::vector<std::string>& ocsp_responses() const = 0; + // For COSE usage, `to_be_signed` is the serialized Sig_Structure, as + // specified in RFC 8152, Section 14. + virtual absl::StatusOr<SignResponse> Sign( + absl::string_view to_be_signed) const = 0; +}; + +// A handler that performs cryptographic operations required for producing +// C2PA claim signatures (specifically, signing and timestamping). +class CryptoWriteHandler : public TimestampWriteHandler { + public: + ~CryptoWriteHandler() override = default; + virtual const FixedKeySigner& GetSigner() = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_CRYPTO_WRITE_HANDLER_H_
diff --git a/crypto/default/BUILD b/crypto/default/BUILD index 85a1f98..f98ef6d 100644 --- a/crypto/default/BUILD +++ b/crypto/default/BUILD
@@ -110,6 +110,7 @@ ":compliance_checker", "//crypto:algorithms", "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/functional:overload", "@abseil-cpp//absl/status", "@abseil-cpp//absl/status:status_macros", "@abseil-cpp//absl/status:statusor", @@ -118,10 +119,19 @@ "@abseil-cpp//absl/time", "@boringssl//:crypto", "@tink_cc//proto:common_cc_proto", - "@tink_cc//tink:cleartext_keyset_handle", + "@tink_cc//tink:key_status", + "@tink_cc//tink:keyset_handle", + "@tink_cc//tink:partial_key_access", "@tink_cc//tink:public_key_verify", + "@tink_cc//tink/pem:signature_key_parser", "@tink_cc//tink/signature:config_v0", - "@tink_cc//tink/signature:signature_pem_keyset_reader", + "@tink_cc//tink/signature:ecdsa_parameters", + "@tink_cc//tink/signature:ecdsa_public_key", + "@tink_cc//tink/signature:ed25519_parameters", + "@tink_cc//tink/signature:ed25519_public_key", + "@tink_cc//tink/signature:rsa_ssa_pss_parameters", + "@tink_cc//tink/signature:rsa_ssa_pss_public_key", + "@tink_cc//tink/signature:signature_config", ], ) @@ -131,6 +141,7 @@ deps = [ ":x509_certificate", "//crypto:algorithms", + "@abseil-cpp//absl/log:absl_check", "@abseil-cpp//absl/log:check", "@abseil-cpp//absl/status", "@abseil-cpp//absl/status:status_matchers", @@ -140,7 +151,9 @@ "@boringssl//:crypto", "@googletest//:gtest_main", "@tink_cc//proto:common_cc_proto", - "@tink_cc//tink/signature:signature_pem_keyset_reader", + "@tink_cc//tink/signature:ecdsa_parameters", + "@tink_cc//tink/signature:ed25519_parameters", + "@tink_cc//tink/signature:rsa_ssa_pss_parameters", ], ) @@ -278,6 +291,7 @@ "@abseil-cpp//absl/strings", "@abseil-cpp//absl/strings:string_view", "@abseil-cpp//absl/time", + "@abseil-cpp//absl/time:clock_interface", "@abseil-cpp//absl/types:span", "@boringssl//:crypto", "@boringssl//:pki",
diff --git a/crypto/default/cms/BUILD b/crypto/default/cms/BUILD index 3ab85a1..a94c25f 100644 --- a/crypto/default/cms/BUILD +++ b/crypto/default/cms/BUILD
@@ -61,7 +61,7 @@ deps = [ ":cms_parser", "//testing:test_file_utils", - "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/log:absl_check", "@abseil-cpp//absl/strings", "@boringssl//:crypto", ],
diff --git a/crypto/default/cms/cms_test_helper.cc b/crypto/default/cms/cms_test_helper.cc index d224183..2e4ab48 100644 --- a/crypto/default/cms/cms_test_helper.cc +++ b/crypto/default/cms/cms_test_helper.cc
@@ -20,7 +20,7 @@ #include <memory> #include <string> -#include "absl/log/check.h" +#include "absl/log/absl_check.h" #include "absl/strings/escaping.h" #include "absl/strings/str_cat.h" #include "absl/strings/string_view.h" @@ -35,14 +35,14 @@ namespace credentio_cms { -constexpr absl::string_view kTestRepoDir = "c2pa/"; +constexpr absl::string_view kTestRepoDir = "credentio/"; std::string GetRawFileContents(const std::string& filename) { std::string path = absl::StrCat(kTestRepoDir, "crypto/default/cms/testdata/", filename); auto contents = credentio_testing::GetContents(path); - CHECK(contents.ok()) << "Failed to load: " << path << ", " - << contents.status(); + ABSL_CHECK(contents.ok()) + << "Failed to load: " << path << ", " << contents.status(); return *contents; } @@ -81,9 +81,9 @@ if (pos == std::string::npos) { pos = message.find("name=smime.p7c"); } - CHECK(pos != std::string::npos); + ABSL_CHECK(pos != std::string::npos); auto start = message.find("\x0d\x0a\x0d\x0a", pos); - CHECK(start != std::string::npos); + ABSL_CHECK(start != std::string::npos); start += 2; auto end = message.find('-', start); if (end == std::string::npos) { @@ -93,7 +93,8 @@ end = message.size(); } std::string decoded; - CHECK(absl::Base64Unescape(message.substr(start, end - start), &decoded)); + ABSL_CHECK( + absl::Base64Unescape(message.substr(start, end - start), &decoded)); return decoded; } @@ -104,25 +105,25 @@ int boundary_end; if (boundary_start == std::string::npos) { boundary_start = contents.find(kUnquotedBoundary); - CHECK_NE(std::string::npos, boundary_start); + ABSL_CHECK_NE(std::string::npos, boundary_start); boundary_start += sizeof(kUnquotedBoundary) - 1; boundary_end = contents.find_first_of(";\r", boundary_start); } else { boundary_start += sizeof(kBoundary) - 1; boundary_end = contents.find('\"', boundary_start); } - CHECK_NE(std::string::npos, boundary_end); + ABSL_CHECK_NE(std::string::npos, boundary_end); std::string boundary = "--" + contents.substr(boundary_start, boundary_end - boundary_start); int part_start = contents.find(boundary, boundary_end); - CHECK_NE(std::string::npos, part_start); + ABSL_CHECK_NE(std::string::npos, part_start); part_start = contents.find("\r\n", part_start + boundary.size()); - CHECK_NE(std::string::npos, part_start); + ABSL_CHECK_NE(std::string::npos, part_start); part_start += 2; int part_end = contents.find(boundary, part_start); - CHECK_NE(std::string::npos, part_end); - CHECK_GT(part_end, part_start + 2); + ABSL_CHECK_NE(std::string::npos, part_end); + ABSL_CHECK_GT(part_end, part_start + 2); // String the CRLF that is right before the boundary end. return contents.substr(part_start, part_end - part_start - 2); } @@ -137,7 +138,7 @@ X509_NAME_print_ex(bio, name, 0 /* indent */, 0 /* flags */); int size = BIO_pending(bio); std::unique_ptr<char[]> buffer(new char[size]); - CHECK_EQ(size, BIO_read(bio, buffer.get(), size)); + ABSL_CHECK_EQ(size, BIO_read(bio, buffer.get(), size)); line.assign(&buffer[0], size); X509_NAME_free(name); BIO_free_all(bio); @@ -163,17 +164,17 @@ std::string NidToDerString(int nid) { // Encode the OID as an ASN.1 OBJECT. CBB cbb; - CHECK(CBB_init(&cbb, 0)); - CHECK(OBJ_nid2cbb(&cbb, nid)); + ABSL_CHECK(CBB_init(&cbb, 0)); + ABSL_CHECK(OBJ_nid2cbb(&cbb, nid)); CBS asn1_cbs; CBS_init(&asn1_cbs, CBB_data(&cbb), CBB_len(&cbb)); // Copy the raw OID into the return value and discard the ASN.1 headers. CBS raw_oid_cbs; - CHECK(CBS_get_asn1(&asn1_cbs, &raw_oid_cbs, CBS_ASN1_OBJECT)); + ABSL_CHECK(CBS_get_asn1(&asn1_cbs, &raw_oid_cbs, CBS_ASN1_OBJECT)); size_t raw_oid_len = CBS_len(&raw_oid_cbs); std::string ret(raw_oid_len, '\0'); - CHECK(CBS_copy_bytes(&raw_oid_cbs, reinterpret_cast<uint8_t*>(ret.data()), - raw_oid_len)); + ABSL_CHECK(CBS_copy_bytes( + &raw_oid_cbs, reinterpret_cast<uint8_t*>(ret.data()), raw_oid_len)); CBB_cleanup(&cbb); return ret; }
diff --git a/crypto/default/default_crypto_read_handler.cc b/crypto/default/default_crypto_read_handler.cc index b92dd8d..bbd4ff3 100644 --- a/crypto/default/default_crypto_read_handler.cc +++ b/crypto/default/default_crypto_read_handler.cc
@@ -15,7 +15,10 @@ #include "crypto/default/default_crypto_read_handler.h" +#include <algorithm> #include <cstddef> +#include <cstdint> +#include <ctime> #include <memory> #include <optional> #include <string> @@ -32,6 +35,7 @@ #include "absl/status/statusor.h" #include "absl/strings/str_cat.h" #include "absl/strings/string_view.h" +#include "absl/time/clock_interface.h" #include "absl/time/time.h" #include "absl/types/span.h" #include "cose/simple_cms_parser.h" @@ -40,15 +44,73 @@ #include "crypto/default/eku_verifier.h" #include "crypto/default/trust_store.h" #include "crypto/default/x509_certificate.h" +#include "openssl/bytestring.h" #include "openssl/pki/ocsp.h" #include "openssl/pki/verify.h" #include "openssl/pki/verify_error.h" +#include "openssl/posix_time.h" #include "tsp/timestamp_verifier.h" #include "tsp/verified_timestamp.h" namespace credentio { namespace { +// Maximum clock skew tolerated when checking `current_time >= thisUpdate`. +constexpr absl::Duration kOcspClockSkewTolerance = absl::Minutes(10); + +// Extracts the `thisUpdate` time from the first `SingleResponse` in a +// DER-encoded `OCSPResponse` (RFC 6960 Section 4.2.1), or returns +// `std::nullopt` if the response cannot be parsed. +std::optional<absl::Time> ParseOcspThisUpdate( + absl::string_view ocsp_response_der) { + CBS cbs; + CBS_init(&cbs, reinterpret_cast<const uint8_t*>(ocsp_response_der.data()), + ocsp_response_der.size()); + CBS response_seq, response_bytes_wrapper, response_bytes, + basic_response_octets, basic_response, tbs_response_data; + if (!CBS_get_asn1(&cbs, &response_seq, CBS_ASN1_SEQUENCE) || + !CBS_get_asn1(&response_seq, /*out=*/nullptr, CBS_ASN1_ENUMERATED) || + !CBS_get_asn1(&response_seq, &response_bytes_wrapper, + CBS_ASN1_CONTEXT_SPECIFIC | CBS_ASN1_CONSTRUCTED | 0) || + !CBS_get_asn1(&response_bytes_wrapper, &response_bytes, + CBS_ASN1_SEQUENCE) || + !CBS_get_asn1(&response_bytes, /*out=*/nullptr, CBS_ASN1_OBJECT) || + !CBS_get_asn1(&response_bytes, &basic_response_octets, + CBS_ASN1_OCTETSTRING) || + !CBS_get_asn1(&basic_response_octets, &basic_response, + CBS_ASN1_SEQUENCE) || + !CBS_get_asn1(&basic_response, &tbs_response_data, CBS_ASN1_SEQUENCE)) { + return std::nullopt; + } + + CBS responses, single_response, this_update_cbs; + if (!CBS_get_optional_asn1( + &tbs_response_data, /*out=*/nullptr, /*out_present=*/nullptr, + CBS_ASN1_CONTEXT_SPECIFIC | CBS_ASN1_CONSTRUCTED | 0) || + !CBS_get_any_asn1(&tbs_response_data, /*out=*/nullptr, + /*out_tag=*/nullptr) || + !CBS_get_asn1(&tbs_response_data, /*out=*/nullptr, + CBS_ASN1_GENERALIZEDTIME) || + !CBS_get_asn1(&tbs_response_data, &responses, CBS_ASN1_SEQUENCE) || + !CBS_get_asn1(&responses, &single_response, CBS_ASN1_SEQUENCE) || + !CBS_get_asn1(&single_response, /*out=*/nullptr, CBS_ASN1_SEQUENCE) || + !CBS_get_any_asn1(&single_response, /*out=*/nullptr, + /*out_tag=*/nullptr) || + !CBS_get_asn1(&single_response, &this_update_cbs, + CBS_ASN1_GENERALIZEDTIME)) { + return std::nullopt; + } + + struct tm tm; + int64_t this_update_posix; + if (!CBS_parse_generalized_time(&this_update_cbs, &tm, + /*allow_timezone_offset=*/0) || + !OPENSSL_tm_to_posix(&tm, &this_update_posix)) { + return std::nullopt; + } + return absl::FromUnixSeconds(this_update_posix); +} + class DefaultParsedCertificates : public ParsedCertificates { public: DefaultParsedCertificates() = default; @@ -227,14 +289,15 @@ absl_nullable std::unique_ptr<bssl::VerifyTrustStore> legacy_claim_signer_roots, absl_nullable std::unique_ptr<bssl::VerifyTrustStore> tsa_roots, - TrustEnvironment trust_environment) + TrustEnvironment trust_environment, absl::Clock* absl_nonnull clock) : all_claim_signer_roots_(std::move(all_claim_signer_roots)), legacy_claim_signer_roots_(std::move(legacy_claim_signer_roots)), tsa_roots_(std::move(tsa_roots)), timestamp_verifier_( std::make_unique<TimestampVerifier>(tsa_roots_.get())), cms_parser_(std::make_unique<SimpleCmsParser>()), - trust_environment_(trust_environment) {} + trust_environment_(trust_environment), + clock_(*clock) {} ~DefaultCryptoReadHandler() override = default; absl::StatusOr<std::unique_ptr<ParsedCertificates>> ParseCertificatesDer( @@ -243,7 +306,26 @@ absl::StatusOr<OCSPRevocationStatus> VerifyOcspResponse( absl::string_view ocsp_response_der, absl::string_view certificate_der, absl::string_view issuer_certificate_der, - absl::Time verify_time) const override { + absl::Time asserted_time) const override { + absl::Time verify_time = asserted_time; + if (std::optional<absl::Time> this_update = + ParseOcspThisUpdate(ocsp_response_der); + this_update.has_value()) { + // Per C2PA 2.4 Section 15.9.1, an OCSP response is valid if: + // 1. `current_time >= thisUpdate` (with `kOcspClockSkewTolerance` + // tolerance for clock skew), and + // 2. `asserted_time < thisUpdate` OR `asserted_time` falls within + // `[thisUpdate, nextUpdate)`. + if (clock_.TimeNow() + kOcspClockSkewTolerance < *this_update) { + return absl::OutOfRangeError( + "current_time is earlier than the OCSP response's thisUpdate."); + } + // `bssl::CheckOCSP` checks `thisUpdate <= verify_time < nextUpdate`, so + // clamping `verify_time` to at least `thisUpdate` allows + // `asserted_time < thisUpdate` while still checking + // `asserted_time < nextUpdate`. + verify_time = std::max(asserted_time, *this_update); + } bssl::OCSPVerifyResult::ResponseStatus response_status; bssl::OCSPRevocationStatus revocation_status = bssl::CheckOCSP( ocsp_response_der, certificate_der, issuer_certificate_der, @@ -295,6 +377,7 @@ std::unique_ptr<TimestampVerifier> timestamp_verifier_; std::unique_ptr<SimpleCmsParser> cms_parser_; TrustEnvironment trust_environment_; + absl::Clock& clock_; }; } // namespace @@ -331,9 +414,13 @@ absl::StatusOr<std::unique_ptr<CryptoReadHandler>> CreateDefaultCryptoReadHandler(const DefaultCryptoReadHandlerOptions& options) { + if (options.clock == nullptr) { + return absl::InvalidArgumentError("clock must not be null"); + } std::unique_ptr<bssl::VerifyTrustStore> all_claim_signer_roots; std::unique_ptr<bssl::VerifyTrustStore> legacy_claim_signer_roots; - if (!options.skip_claim_signer_trust_checks_for_test) { + if (!options.skip_claim_signer_trust_checks_for_test && + options.trust_environment != CryptoReadHandler::TrustEnvironment::kAny) { if (!options.legacy_claim_signer_trust_anchors_pem.empty()) { // If the string is nonempty, it must contain at least one cert. ABSL_ASSIGN_OR_RETURN( @@ -346,14 +433,15 @@ options.legacy_claim_signer_trust_anchors_pem))); } std::unique_ptr<bssl::VerifyTrustStore> tsa_roots; - if (!options.skip_tsa_trust_checks_for_test) { + if (!options.skip_tsa_trust_checks_for_test && + options.trust_environment != CryptoReadHandler::TrustEnvironment::kAny) { ABSL_ASSIGN_OR_RETURN(tsa_roots, LoadTrustStore(options.tsa_trust_anchors_pem)); } return std::make_unique<DefaultCryptoReadHandler>( /*all_claim_signer_roots=*/std::move(all_claim_signer_roots), /*legacy_claim_signer_roots=*/std::move(legacy_claim_signer_roots), - std::move(tsa_roots), options.trust_environment); + std::move(tsa_roots), options.trust_environment, options.clock); } } // namespace credentio
diff --git a/crypto/default/default_crypto_read_handler.h b/crypto/default/default_crypto_read_handler.h index 005872a..63edcf6 100644 --- a/crypto/default/default_crypto_read_handler.h +++ b/crypto/default/default_crypto_read_handler.h
@@ -20,6 +20,7 @@ #include "absl/status/statusor.h" #include "absl/strings/string_view.h" +#include "absl/time/clock_interface.h" #include "crypto/crypto_read_handler.h" namespace credentio { @@ -30,7 +31,8 @@ // // At least one of `claim_signer_trust_anchors_pem` or // `legacy_claim_signer_trust_anchors_pem` must be nonempty unless - // `skip_claim_signer_trust_checks_for_test` is true. + // `skip_claim_signer_trust_checks_for_test` is true or `trust_environment` + // is `TrustEnvironment::kAny`. absl::string_view claim_signer_trust_anchors_pem; // The content of the PEM file containing legacy claim signer trust anchors, @@ -40,7 +42,8 @@ absl::string_view legacy_claim_signer_trust_anchors_pem; // The content of the PEM file containing TSA trust anchors. - // Required unless `skip_tsa_trust_checks` is true. + // Required unless `skip_tsa_trust_checks_for_test` is true or + // `trust_environment` is `TrustEnvironment::kAny`. absl::string_view tsa_trust_anchors_pem; // Skips claim signer trust checks. @@ -52,6 +55,9 @@ // The trust level of the handler. CryptoReadHandler::TrustEnvironment trust_environment = CryptoReadHandler::TrustEnvironment::kUnspecified; + + // Clock to use (e.g., for OCSP response validity checks). + absl::Clock* clock = &absl::Clock::GetRealClock(); }; // Creates an implementation of CryptoReadHandler intended for use in
diff --git a/crypto/default/trust_store_test.cc b/crypto/default/trust_store_test.cc index d5bafb5..e6a53da 100644 --- a/crypto/default/trust_store_test.cc +++ b/crypto/default/trust_store_test.cc
@@ -32,7 +32,7 @@ using ::testing::HasSubstr; constexpr absl::string_view kTrustAnchors = - "c2pa/testing/testdata/keys/trust_anchors.pem"; + "credentio/testing/testdata/keys/trust_anchors.pem"; TEST(LoadTrustStore, Success) { auto pem = credentio_testing::GetContents(kTrustAnchors);
diff --git a/crypto/default/x509_certificate.cc b/crypto/default/x509_certificate.cc index 7f42d85..d976b5c 100644 --- a/crypto/default/x509_certificate.cc +++ b/crypto/default/x509_certificate.cc
@@ -20,9 +20,11 @@ #include <memory> #include <string> #include <utility> +#include <variant> #include <vector> #include "absl/base/no_destructor.h" +#include "absl/functional/overload.h" #include "absl/status/status.h" #include "absl/status/status_macros.h" #include "absl/status/statusor.h" @@ -44,16 +46,29 @@ #include "openssl/rsa.h" #include "openssl/x509.h" #include "proto/common.pb.h" -#include "tink/cleartext_keyset_handle.h" +#include "tink/key_status.h" +#include "tink/keyset_handle.h" +#include "tink/partial_key_access.h" +#include "tink/pem/signature_key_parser.h" #include "tink/public_key_verify.h" #include "tink/signature/config_v0.h" -#include "tink/signature/signature_pem_keyset_reader.h" +#include "tink/signature/ecdsa_parameters.h" +#include "tink/signature/ecdsa_public_key.h" +#include "tink/signature/ed25519_parameters.h" +#include "tink/signature/ed25519_public_key.h" +#include "tink/signature/rsa_ssa_pss_parameters.h" +#include "tink/signature/rsa_ssa_pss_public_key.h" +#include "tink/signature/signature_config.h" namespace credentio { namespace { -using ::crypto::tink::PemKeyParams; -using ::crypto::tink::SignaturePemKeysetReaderBuilder; +using ::crypto::tink::BigInteger; +using ::crypto::tink::EcdsaParameters; +using ::crypto::tink::Ed25519Parameters; +using ::crypto::tink::GetPartialKeyAccess; +using ::crypto::tink::KeysetHandleBuilder; +using ::crypto::tink::RsaSsaPssParameters; // c2pa-al from // https://github.com/c2pa-org/conformance-public/blob/main/docs/current/schemas/mib/oid.txt @@ -194,6 +209,9 @@ absl::Status X509Certificate::VerifySignature( absl::string_view signature, absl::string_view data, SigningAlgorithm algorithm) const { + static const absl::NoDestructor<absl::Status> registration_status( + []() { return crypto::tink::SignatureConfig::Register(); }()); + ABSL_RETURN_IF_ERROR(*registration_status); bssl::UniquePtr<EVP_PKEY> pub_key(X509_get_pubkey(cert_.get())); if (pub_key == nullptr) { return absl::InvalidArgumentError( @@ -201,31 +219,58 @@ } pub_key = MaybeConvertPssToRsa(std::move(pub_key)); ABSL_ASSIGN_OR_RETURN(auto pem_str, PemEncode(pub_key.get())); - auto builder = SignaturePemKeysetReaderBuilder( - SignaturePemKeysetReaderBuilder::PemReaderType::PUBLIC_KEY_VERIFY); - auto params = GetPemKeyParams(algorithm); + absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params = + GetPemKeyParams(algorithm); if (!params.ok()) { - return absl::Status(params.status().code(), - absl::StrCat(params.status().message(), - "; Failed to get PEM key params")); - } - for (const auto& param : *params) { - builder.Add({.serialized_key = pem_str, .parameters = param}); - } - auto reader = builder.Build(); - if (!reader.ok()) { return absl::Status( - reader.status().code(), - absl::StrCat(reader.status().message(), - "; Failed to construct the PEM KeysetReader")); + params.status().code(), + absl::StrCat("Tink verification failed: ", params.status().message())); } - auto handle = crypto::tink::CleartextKeysetHandle::Read(*std::move(reader)); + crypto::tink::KeysetHandleBuilder builder; + bool is_primary = true; + + using TinkPublicKey = std::variant<crypto::tink::EcdsaPublicKey, + crypto::tink::RsaSsaPssPublicKey, + crypto::tink::Ed25519PublicKey>; + + for (const auto& param : *params) { + absl::StatusOr<TinkPublicKey> public_key = + std::visit(absl::Overload( + [&pem_str](const EcdsaParameters& params) + -> absl::StatusOr<TinkPublicKey> { + return tink_pem::PemToEcdsaPublicKey( + pem_str, params, GetPartialKeyAccess()); + }, + [&pem_str](const RsaSsaPssParameters& params) + -> absl::StatusOr<TinkPublicKey> { + return tink_pem::PemToRsaSsaPssPublicKey( + pem_str, params, GetPartialKeyAccess()); + }, + [&pem_str](const Ed25519Parameters& params) + -> absl::StatusOr<TinkPublicKey> { + return tink_pem::PemToEd25519PublicKey( + pem_str, params, GetPartialKeyAccess()); + }), + param); + if (!public_key.ok()) { + return public_key.status(); + } + std::visit( + [&builder, is_primary](const auto& key) { + builder.AddEntry(KeysetHandleBuilder::Entry::CreateFromCopyableKey( + key, crypto::tink::KeyStatus::kEnabled, is_primary)); + }, + *public_key); + is_primary = false; + } + + auto handle = builder.Build(); if (!handle.ok()) { return absl::Status(handle.status().code(), absl::StrCat(handle.status().message(), "; Failed to construct the KeysetHandle")); } - auto verifier = (*handle)->GetPrimitive<crypto::tink::PublicKeyVerify>( + auto verifier = handle->GetPrimitive<crypto::tink::PublicKeyVerify>( crypto::tink::ConfigSignatureV0()); if (!verifier.ok()) { return absl::Status( @@ -278,87 +323,139 @@ return absl::FromUnixSeconds(posix_time); } -absl::StatusOr<std::vector<PemKeyParams>> X509Certificate::GetPemKeyParams( - SigningAlgorithm algorithm) const { +namespace { + +absl::StatusOr<RsaSsaPssParameters> GetRsaSsaPssParameters( + size_t key_size_in_bits, SigningAlgorithm algorithm, + const BigInteger& public_exponent) { + crypto::tink::RsaSsaPssParameters::HashType hash_type; + int salt_length; + switch (algorithm) { + case SigningAlgorithm::kPs256: + hash_type = RsaSsaPssParameters::HashType::kSha256; + salt_length = 32; + break; + case SigningAlgorithm::kPs384: + hash_type = RsaSsaPssParameters::HashType::kSha384; + salt_length = 48; + break; + case SigningAlgorithm::kPs512: + hash_type = RsaSsaPssParameters::HashType::kSha512; + salt_length = 64; + break; + default: + return absl::InvalidArgumentError( + absl::StrCat("Invalid signing algorithm: ", algorithm)); + } + + return RsaSsaPssParameters::Builder() + .SetModulusSizeInBits(key_size_in_bits) + .SetPublicExponent(public_exponent) + .SetSigHashType(hash_type) + .SetMgf1HashType(hash_type) + .SetSaltLengthInBytes(salt_length) + .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix) + .Build(); +} + +absl::StatusOr<BigInteger> GetPublicExponent(EVP_PKEY& pub_key) { + RSA* rsa = EVP_PKEY_get0_RSA(&pub_key); + if (rsa == nullptr) { + return absl::InvalidArgumentError("Invalid RSA public key"); + } + const BIGNUM* e = nullptr; + RSA_get0_key(rsa, /*n=*/nullptr, &e, /*d=*/nullptr); + if (e == nullptr) { + return absl::InvalidArgumentError("Invalid RSA public key exponent"); + } + std::string e_bytes(BN_num_bytes(e), '\0'); + BN_bn2bin(e, reinterpret_cast<uint8_t*>(e_bytes.data())); + return BigInteger(e_bytes); +} + +absl::StatusOr<std::pair<EcdsaParameters, EcdsaParameters>> GetEcdsaParameters( + SigningAlgorithm algorithm) { + EcdsaParameters::HashType hash_type; + EcdsaParameters::CurveType curve_type; + switch (algorithm) { + case SigningAlgorithm::kEs256: + hash_type = EcdsaParameters::HashType::kSha256; + curve_type = EcdsaParameters::CurveType::kNistP256; + break; + case SigningAlgorithm::kEs384: + hash_type = EcdsaParameters::HashType::kSha384; + curve_type = EcdsaParameters::CurveType::kNistP384; + break; + case SigningAlgorithm::kEs512: + hash_type = EcdsaParameters::HashType::kSha512; + curve_type = EcdsaParameters::CurveType::kNistP521; + break; + default: + return absl::InvalidArgumentError( + absl::StrCat("Invalid signing algorithm: ", algorithm)); + } + ABSL_ASSIGN_OR_RETURN( + EcdsaParameters der_ecdsa_params, + EcdsaParameters::Builder() + .SetCurveType(curve_type) + .SetHashType(hash_type) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build()); + ABSL_ASSIGN_OR_RETURN( + EcdsaParameters ieee_ecdsa_params, + EcdsaParameters::Builder() + .SetCurveType(curve_type) + .SetHashType(hash_type) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build()); + return std::make_pair(ieee_ecdsa_params, der_ecdsa_params); +} + +} // namespace + +absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> +X509Certificate::GetPemKeyParams(SigningAlgorithm algorithm) const { bssl::UniquePtr<EVP_PKEY> pub_key(X509_get_pubkey(cert_.get())); if (pub_key == nullptr) { return absl::InvalidArgumentError( "Certificate does not have a valid public key"); } ABSL_RETURN_IF_ERROR(CheckCompatibleWithAlgorithm(pub_key.get(), algorithm)); - size_t key_size = EVP_PKEY_bits(pub_key.get()); switch (algorithm) { case SigningAlgorithm::kPs256: - return std::vector<PemKeyParams>{PemKeyParams{ - .key_type = crypto::tink::PemKeyType::PEM_RSA, - .algorithm = crypto::tink::PemAlgorithm::RSASSA_PSS, - .key_size_in_bits = key_size, - .hash_type = google::crypto::tink::HashType::SHA256, - }}; case SigningAlgorithm::kPs384: - return std::vector<PemKeyParams>{PemKeyParams{ - .key_type = crypto::tink::PemKeyType::PEM_RSA, - .algorithm = crypto::tink::PemAlgorithm::RSASSA_PSS, - .key_size_in_bits = key_size, - .hash_type = google::crypto::tink::HashType::SHA384, - }}; - case SigningAlgorithm::kPs512: - return std::vector<PemKeyParams>{PemKeyParams{ - .key_type = crypto::tink::PemKeyType::PEM_RSA, - .algorithm = crypto::tink::PemAlgorithm::RSASSA_PSS, - .key_size_in_bits = key_size, - .hash_type = google::crypto::tink::HashType::SHA512, - }}; + case SigningAlgorithm::kPs512: { + ABSL_ASSIGN_OR_RETURN(crypto::tink::BigInteger public_exponent, + GetPublicExponent(*pub_key)); + size_t key_size_in_bytes = EVP_PKEY_size(pub_key.get()); + absl::StatusOr<RsaSsaPssParameters> params = GetRsaSsaPssParameters( + key_size_in_bytes * 8, algorithm, public_exponent); + if (!params.ok()) { + return absl::Status(params.status().code(), + absl::StrCat(params.status().message(), + "; Failed to get RSA-PSS parameters")); + } + return std::vector<X509Certificate::TinkKeyParameters>{ + *std::move(params)}; + } case SigningAlgorithm::kEs256: - return std::vector<PemKeyParams>{ - PemKeyParams{ - .key_type = crypto::tink::PemKeyType::PEM_EC, - .algorithm = crypto::tink::PemAlgorithm::ECDSA_IEEE, - .key_size_in_bits = key_size, - .hash_type = google::crypto::tink::HashType::SHA256, - }, - PemKeyParams{ - .key_type = crypto::tink::PemKeyType::PEM_EC, - .algorithm = crypto::tink::PemAlgorithm::ECDSA_DER, - .key_size_in_bits = key_size, - .hash_type = google::crypto::tink::HashType::SHA256, - }}; case SigningAlgorithm::kEs384: - return std::vector<PemKeyParams>{ - PemKeyParams{ - .key_type = crypto::tink::PemKeyType::PEM_EC, - .algorithm = crypto::tink::PemAlgorithm::ECDSA_IEEE, - .key_size_in_bits = key_size, - .hash_type = google::crypto::tink::HashType::SHA384, - }, - PemKeyParams{ - .key_type = crypto::tink::PemKeyType::PEM_EC, - .algorithm = crypto::tink::PemAlgorithm::ECDSA_DER, - .key_size_in_bits = key_size, - .hash_type = google::crypto::tink::HashType::SHA384, - }}; - case SigningAlgorithm::kEs512: - return std::vector<PemKeyParams>{ - PemKeyParams{ - .key_type = crypto::tink::PemKeyType::PEM_EC, - .algorithm = crypto::tink::PemAlgorithm::ECDSA_IEEE, - .key_size_in_bits = key_size, - .hash_type = google::crypto::tink::HashType::SHA512, - }, - PemKeyParams{ - .key_type = crypto::tink::PemKeyType::PEM_EC, - .algorithm = crypto::tink::PemAlgorithm::ECDSA_DER, - .key_size_in_bits = key_size, - .hash_type = google::crypto::tink::HashType::SHA512, - }, - }; - case SigningAlgorithm::kEdDsa: - return std::vector<PemKeyParams>{PemKeyParams{ - .key_type = crypto::tink::PemKeyType::PEM_EC, - .algorithm = crypto::tink::PemAlgorithm::ED25519, - .key_size_in_bits = key_size, - .hash_type = google::crypto::tink::HashType::SHA512, - }}; + case SigningAlgorithm::kEs512: { + ABSL_ASSIGN_OR_RETURN(auto params, GetEcdsaParameters(algorithm)); + return std::vector<X509Certificate::TinkKeyParameters>{params.first, + params.second}; + } + case SigningAlgorithm::kEdDsa: { + ABSL_ASSIGN_OR_RETURN( + Ed25519Parameters params, + Ed25519Parameters::Create(Ed25519Parameters::Variant::kNoPrefix)); + return std::vector<X509Certificate::TinkKeyParameters>{params}; + } + default: + return absl::InvalidArgumentError( + absl::StrCat("Invalid signing algorithm: ", algorithm)); } }
diff --git a/crypto/default/x509_certificate.h b/crypto/default/x509_certificate.h index 13279cb..cda0521 100644 --- a/crypto/default/x509_certificate.h +++ b/crypto/default/x509_certificate.h
@@ -19,6 +19,7 @@ #include <memory> #include <string> #include <utility> +#include <variant> #include <vector> #include "absl/status/status.h" @@ -28,13 +29,19 @@ #include "crypto/algorithms.h" #include "openssl/base.h" #include "openssl/x509.h" -#include "tink/signature/signature_pem_keyset_reader.h" +#include "tink/signature/ecdsa_parameters.h" +#include "tink/signature/ed25519_parameters.h" +#include "tink/signature/rsa_ssa_pss_parameters.h" namespace credentio { // Class encapsulating the OpenSSL X.509 certificate. class X509Certificate { public: + using TinkKeyParameters = std::variant<crypto::tink::EcdsaParameters, + crypto::tink::RsaSsaPssParameters, + crypto::tink::Ed25519Parameters>; + explicit X509Certificate(bssl::UniquePtr<X509> cert) : cert_(std::move(cert)) {} std::string GetSubject() const; @@ -51,7 +58,7 @@ SigningAlgorithm algorithm) const; absl::StatusOr<absl::Time> StartTime() const; absl::StatusOr<absl::Time> EndTime() const; - absl::StatusOr<std::vector<crypto::tink::PemKeyParams>> GetPemKeyParams( + absl::StatusOr<std::vector<TinkKeyParameters>> GetPemKeyParams( SigningAlgorithm algorithm) const; absl::StatusOr<std::string> GetSerialNumberHex() const; absl::StatusOr<std::string> GetAssuranceLevel() const;
diff --git a/crypto/default/x509_certificate_test.cc b/crypto/default/x509_certificate_test.cc index 0cd976e..d970024 100644 --- a/crypto/default/x509_certificate_test.cc +++ b/crypto/default/x509_certificate_test.cc
@@ -19,6 +19,7 @@ #include <utility> #include <vector> +#include "absl/log/absl_check.h" #include "absl/log/check.h" #include "absl/status/status.h" #include "absl/status/status_matchers.h" @@ -34,7 +35,9 @@ #include "openssl/obj.h" #include "openssl/x509.h" #include "proto/common.pb.h" -#include "tink/signature/signature_pem_keyset_reader.h" +#include "tink/signature/ecdsa_parameters.h" +#include "tink/signature/ed25519_parameters.h" +#include "tink/signature/rsa_ssa_pss_parameters.h" namespace credentio { namespace { @@ -42,10 +45,13 @@ using ::absl_testing::IsOk; using ::absl_testing::IsOkAndHolds; using ::absl_testing::StatusIs; +using ::crypto::tink::EcdsaParameters; +using ::crypto::tink::Ed25519Parameters; +using ::crypto::tink::RsaSsaPssParameters; +using ::testing::ElementsAre; using ::testing::HasSubstr; using ::testing::IsEmpty; using ::testing::NotNull; -using ::testing::SizeIs; constexpr absl::string_view kDerRsaBase64 = "MIIGDDCCA/SgAwIBAgIQf/" @@ -140,41 +146,60 @@ std::string Base64Unescape(absl::string_view base64) { std::string bytes; - CHECK(absl::Base64Unescape(base64, &bytes)); + ABSL_CHECK(absl::Base64Unescape(base64, &bytes)); return bytes; } TEST(X509CertificateTest, GetPemKeyParamsForRsaKey) { auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); - ASSERT_THAT(x509.status(), IsOk()); - std::vector<crypto::tink::PemKeyParams> params; + ABSL_ASSERT_OK(x509.status()); - auto params_ps256 = (*x509)->GetPemKeyParams(SigningAlgorithm::kPs256); - ASSERT_THAT(params_ps256.status(), IsOk()); - params = *params_ps256; - ASSERT_THAT(params, SizeIs(1)); - EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_RSA); - EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::RSASSA_PSS); - EXPECT_EQ(params[0].key_size_in_bits, 2048); - EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA256); + const crypto::tink::BigInteger exponent(std::string("\001\000\005", 3)); - auto params_ps384 = (*x509)->GetPemKeyParams(SigningAlgorithm::kPs384); - ASSERT_THAT(params_ps384.status(), IsOk()); - params = *params_ps384; - ASSERT_THAT(params, SizeIs(1)); - EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_RSA); - EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::RSASSA_PSS); - EXPECT_EQ(params[0].key_size_in_bits, 2048); - EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA384); + absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_ps256 = + (*x509)->GetPemKeyParams(SigningAlgorithm::kPs256); + ABSL_ASSERT_OK(params_ps256.status()); + absl::StatusOr<RsaSsaPssParameters> want_ps256_params = + RsaSsaPssParameters::Builder() + .SetModulusSizeInBits(2048) + .SetPublicExponent(exponent) + .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha256) + .SetSigHashType(RsaSsaPssParameters::HashType::kSha256) + .SetSaltLengthInBytes(32) + .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(want_ps256_params.status()); + EXPECT_THAT(*params_ps256, ElementsAre(*want_ps256_params)); - auto params_ps512 = (*x509)->GetPemKeyParams(SigningAlgorithm::kPs512); - ASSERT_THAT(params_ps512.status(), IsOk()); - params = *params_ps512; - ASSERT_THAT(params, SizeIs(1)); - EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_RSA); - EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::RSASSA_PSS); - EXPECT_EQ(params[0].key_size_in_bits, 2048); - EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA512); + absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_ps384 = + (*x509)->GetPemKeyParams(SigningAlgorithm::kPs384); + ABSL_ASSERT_OK(params_ps384.status()); + absl::StatusOr<RsaSsaPssParameters> want_ps384_params = + RsaSsaPssParameters::Builder() + .SetModulusSizeInBits(2048) + .SetPublicExponent(exponent) + .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha384) + .SetSigHashType(RsaSsaPssParameters::HashType::kSha384) + .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix) + .SetSaltLengthInBytes(48) + .Build(); + ABSL_ASSERT_OK(want_ps384_params.status()); + EXPECT_THAT(*params_ps384, ElementsAre(*want_ps384_params)); + + absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_ps512 = + (*x509)->GetPemKeyParams(SigningAlgorithm::kPs512); + ABSL_ASSERT_OK(params_ps512.status()); + absl::StatusOr<RsaSsaPssParameters> want_ps512_params = + RsaSsaPssParameters::Builder() + .SetModulusSizeInBits(2048) + .SetPublicExponent(exponent) + .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha512) + .SetSigHashType(RsaSsaPssParameters::HashType::kSha512) + .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix) + .SetSaltLengthInBytes(64) + .Build(); + ABSL_ASSERT_OK(want_ps512_params.status()); + EXPECT_THAT(*params_ps512, ElementsAre(*want_ps512_params)); EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kEs256), StatusIs(absl::StatusCode::kInvalidArgument, @@ -186,47 +211,73 @@ TEST(X509CertificateTest, GetPemKeyParamsForEcdsaKey) { auto x509 = X509Certificate::Create(Base64Unescape(kDerEcdsaBase64)); - ASSERT_THAT(x509.status(), IsOk()); - std::vector<crypto::tink::PemKeyParams> params; + ABSL_ASSERT_OK(x509.status()); - auto params_es256 = (*x509)->GetPemKeyParams(SigningAlgorithm::kEs256); - ASSERT_THAT(params_es256.status(), IsOk()); - params = *params_es256; - ASSERT_THAT(params, SizeIs(2)); - EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_EC); - EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::ECDSA_IEEE); - EXPECT_EQ(params[0].key_size_in_bits, 256); - EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA256); - EXPECT_EQ(params[1].key_type, crypto::tink::PemKeyType::PEM_EC); - EXPECT_EQ(params[1].algorithm, crypto::tink::PemAlgorithm::ECDSA_DER); - EXPECT_EQ(params[1].key_size_in_bits, 256); - EXPECT_EQ(params[1].hash_type, google::crypto::tink::HashType::SHA256); + absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_es256 = + (*x509)->GetPemKeyParams(SigningAlgorithm::kEs256); + ABSL_ASSERT_OK(params_es256.status()); + absl::StatusOr<EcdsaParameters> want_p256_der_ecdsa_params = + EcdsaParameters::Builder() + .SetCurveType(EcdsaParameters::CurveType::kNistP256) + .SetHashType(EcdsaParameters::HashType::kSha256) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(want_p256_der_ecdsa_params.status()); + absl::StatusOr<EcdsaParameters> want_p256_ieee_ecdsa_params = + EcdsaParameters::Builder() + .SetCurveType(EcdsaParameters::CurveType::kNistP256) + .SetHashType(EcdsaParameters::HashType::kSha256) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(want_p256_ieee_ecdsa_params.status()); + EXPECT_THAT(*params_es256, ElementsAre(*want_p256_ieee_ecdsa_params, + *want_p256_der_ecdsa_params)); - auto params_es384 = (*x509)->GetPemKeyParams(SigningAlgorithm::kEs384); - ASSERT_THAT(params_es384.status(), IsOk()); - params = *params_es384; - ASSERT_THAT(params, SizeIs(2)); - EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_EC); - EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::ECDSA_IEEE); - EXPECT_EQ(params[0].key_size_in_bits, 256); - EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA384); - EXPECT_EQ(params[1].key_type, crypto::tink::PemKeyType::PEM_EC); - EXPECT_EQ(params[1].algorithm, crypto::tink::PemAlgorithm::ECDSA_DER); - EXPECT_EQ(params[1].key_size_in_bits, 256); - EXPECT_EQ(params[1].hash_type, google::crypto::tink::HashType::SHA384); + absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_es384 = + (*x509)->GetPemKeyParams(SigningAlgorithm::kEs384); + ABSL_ASSERT_OK(params_es384.status()); + absl::StatusOr<EcdsaParameters> want_p384_der_ecdsa_params = + EcdsaParameters::Builder() + .SetCurveType(EcdsaParameters::CurveType::kNistP384) + .SetHashType(EcdsaParameters::HashType::kSha384) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(want_p384_der_ecdsa_params.status()); + absl::StatusOr<EcdsaParameters> want_p384_ieee_ecdsa_params = + EcdsaParameters::Builder() + .SetCurveType(EcdsaParameters::CurveType::kNistP384) + .SetHashType(EcdsaParameters::HashType::kSha384) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(want_p384_ieee_ecdsa_params.status()); + EXPECT_THAT(*params_es384, ElementsAre(*want_p384_ieee_ecdsa_params, + *want_p384_der_ecdsa_params)); - auto params_es512 = (*x509)->GetPemKeyParams(SigningAlgorithm::kEs512); - ASSERT_THAT(params_es512.status(), IsOk()); - params = *params_es512; - ASSERT_THAT(params, SizeIs(2)); - EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_EC); - EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::ECDSA_IEEE); - EXPECT_EQ(params[0].key_size_in_bits, 256); - EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA512); - EXPECT_EQ(params[1].key_type, crypto::tink::PemKeyType::PEM_EC); - EXPECT_EQ(params[1].algorithm, crypto::tink::PemAlgorithm::ECDSA_DER); - EXPECT_EQ(params[1].key_size_in_bits, 256); - EXPECT_EQ(params[1].hash_type, google::crypto::tink::HashType::SHA512); + absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_es521 = + (*x509)->GetPemKeyParams(SigningAlgorithm::kEs512); + ABSL_ASSERT_OK(params_es521.status()); + absl::StatusOr<EcdsaParameters> want_p521_der_ecdsa_params = + EcdsaParameters::Builder() + .SetCurveType(EcdsaParameters::CurveType::kNistP521) + .SetHashType(EcdsaParameters::HashType::kSha512) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(want_p521_der_ecdsa_params.status()); + absl::StatusOr<EcdsaParameters> want_p521_ieee_ecdsa_params = + EcdsaParameters::Builder() + .SetCurveType(EcdsaParameters::CurveType::kNistP521) + .SetHashType(EcdsaParameters::HashType::kSha512) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(want_p521_ieee_ecdsa_params.status()); + EXPECT_THAT(*params_es521, ElementsAre(*want_p521_ieee_ecdsa_params, + *want_p521_der_ecdsa_params)); EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kPs256), StatusIs(absl::StatusCode::kInvalidArgument, @@ -238,19 +289,14 @@ TEST(X509CertificateTest, GetPemKeyParamsForEdKey) { auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64)); - ASSERT_THAT(x509.status(), IsOk()); - std::vector<crypto::tink::PemKeyParams> params; - - auto params_eddsa = (*x509)->GetPemKeyParams(SigningAlgorithm::kEdDsa); - ASSERT_THAT(params_eddsa.status(), IsOk()); - params = *params_eddsa; - ASSERT_THAT(params, SizeIs(1)); - EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_EC); - EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::ED25519); - // BoringSSL will return 253 bits here instead of 256 bits because the lower - // three bits of the private key scalar are cleared. - EXPECT_EQ(params[0].key_size_in_bits, 253); - EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA512); + ABSL_ASSERT_OK(x509.status()); + absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_eddsa = + (*x509)->GetPemKeyParams(SigningAlgorithm::kEdDsa); + ABSL_ASSERT_OK(params_eddsa.status()); + absl::StatusOr<Ed25519Parameters> want_params = + Ed25519Parameters::Create(Ed25519Parameters::Variant::kNoPrefix); + ABSL_ASSERT_OK(want_params.status()); + EXPECT_THAT(*params_eddsa, ElementsAre(*want_params)); EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kPs256), StatusIs(absl::StatusCode::kInvalidArgument, @@ -261,7 +307,7 @@ } TEST(X509CertificateTest, VerifyEdSignedData) { auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64)); - ASSERT_THAT(x509.status(), IsOk()); + ABSL_ASSERT_OK(x509.status()); EXPECT_THAT( (*x509)->VerifySignature(Base64Unescape(kEdSignatureBase64), "this is a test", SigningAlgorithm::kEdDsa), @@ -270,7 +316,7 @@ TEST(X509CertificateTest, VerifySignatureWithRsaKey) { auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); - ASSERT_THAT(x509.status(), IsOk()); + ABSL_ASSERT_OK(x509.status()); // The actual signature and data don't matter for covering the lines in // MaybeConvertPssToRsa, as the key conversion happens before signature // verification. We expect a failure because the signature is invalid. @@ -282,17 +328,17 @@ TEST(X509CertificateTest, GetStartTime) { auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); - ASSERT_THAT(x509.status(), IsOk()); + ABSL_ASSERT_OK(x509.status()); auto start_time = (*x509)->StartTime(); - ASSERT_THAT(start_time.status(), IsOk()); + ABSL_ASSERT_OK(start_time.status()); EXPECT_EQ(absl::ToUnixSeconds(*start_time), 1704931200); } TEST(X509CertificateTest, GetEndTime) { auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); - ASSERT_THAT(x509.status(), IsOk()); + ABSL_ASSERT_OK(x509.status()); auto end_time = (*x509)->EndTime(); - ASSERT_THAT(end_time.status(), IsOk()); + ABSL_ASSERT_OK(end_time.status()); EXPECT_EQ(absl::ToUnixSeconds(*end_time), 1736553599); } @@ -305,31 +351,31 @@ TEST(X509CertificateTest, GetSerialNumberHexRsa) { auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); - ASSERT_THAT(x509.status(), IsOk()); + ABSL_ASSERT_OK(x509.status()); auto serial_hex = (*x509)->GetSerialNumberHex(); - ASSERT_THAT(serial_hex.status(), IsOk()); + ABSL_ASSERT_OK(serial_hex.status()); EXPECT_EQ(*serial_hex, "7ff2741fd1c6de43b5947a8e65709e9e"); } TEST(X509CertificateTest, GetSerialNumberHexEcdsa) { auto x509 = X509Certificate::Create(Base64Unescape(kDerEcdsaBase64)); - ASSERT_THAT(x509.status(), IsOk()); + ABSL_ASSERT_OK(x509.status()); auto serial_hex = (*x509)->GetSerialNumberHex(); - ASSERT_THAT(serial_hex.status(), IsOk()); + ABSL_ASSERT_OK(serial_hex.status()); EXPECT_EQ(*serial_hex, "7024e6247605f1d65f1b477551d4fafcb5ed91e6"); } TEST(X509CertificateTest, GetSerialNumberHexEdDsa) { auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64)); - ASSERT_THAT(x509.status(), IsOk()); + ABSL_ASSERT_OK(x509.status()); auto serial_hex = (*x509)->GetSerialNumberHex(); - ASSERT_THAT(serial_hex.status(), IsOk()); + ABSL_ASSERT_OK(serial_hex.status()); EXPECT_EQ(*serial_hex, "667ad6b27de4f95c9931d3adda31f103867a40d2"); } TEST(X509CertificateTest, GetAssuranceLevelNotFound) { auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); - ASSERT_THAT(x509.status(), IsOk()); + ABSL_ASSERT_OK(x509.status()); EXPECT_THAT((*x509)->GetAssuranceLevel(), StatusIs(absl::StatusCode::kNotFound)); } @@ -369,7 +415,7 @@ TEST(X509CertificateTest, GetConformingProductIdNotFound) { auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); - ASSERT_THAT(x509.status(), IsOk()); + ABSL_ASSERT_OK(x509.status()); EXPECT_THAT((*x509)->GetConformingProductId(), StatusIs(absl::StatusCode::kNotFound)); }
diff --git a/crypto/templated_crypto_write_handler.h b/crypto/templated_crypto_write_handler.h new file mode 100644 index 0000000..666a5cb --- /dev/null +++ b/crypto/templated_crypto_write_handler.h
@@ -0,0 +1,91 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_TEMPLATED_CRYPTO_WRITE_HANDLER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_TEMPLATED_CRYPTO_WRITE_HANDLER_H_ + +#include <cstdint> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/log/die_if_null.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "async/async_context.h" +#include "crypto/algorithms.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/timestamp_write_handler.h" + +namespace credentio { + +// A FixedKeySigner that returns mock signatures for size estimation. +class TemplatedFixedKeySigner : public FixedKeySigner { + public: + explicit TemplatedFixedKeySigner( + CryptoWriteHandler* absl_nonnull real_handler) + : real_handler_(*ABSL_DIE_IF_NULL(real_handler)) {} + SigningAlgorithm algorithm() const override { + return real_handler_.GetSigner().algorithm(); + } + const std::vector<std::string>& certificate_chain() const override { + return real_handler_.GetSigner().certificate_chain(); + } + const std::vector<std::string>& ocsp_responses() const override { + return real_handler_.GetSigner().ocsp_responses(); + } + uint64_t max_signature_byte_size() const override { + return real_handler_.GetSigner().max_signature_byte_size(); + } + absl::StatusOr<SignResponse> Sign( + absl::string_view to_be_signed) const override { + return SignResponse{.signature = + std::string(max_signature_byte_size(), '\0')}; + } + + private: + CryptoWriteHandler& real_handler_; +}; + +// A CryptoWriteHandler that returns mock signatures and timestamps for size +// estimation. +class TemplatedCryptoWriteHandler : public CryptoWriteHandler { + public: + explicit TemplatedCryptoWriteHandler( + CryptoWriteHandler* absl_nonnull real_handler) + : real_handler_(*ABSL_DIE_IF_NULL(real_handler)), signer_(real_handler) {} + const FixedKeySigner& GetSigner() override { return signer_; } + void CreateTimestamp(HashAlgorithm hash_algorithm, std::string hashed_message, + TimestampWriteHandler::Options options, + absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> + cb) const override { + AsyncResolver<std::string>(options.async_context, std::move(cb)) + .Return(std::string(max_timestamp_byte_size(hash_algorithm), '\0')); + } + uint64_t max_timestamp_byte_size( + HashAlgorithm hash_algorithm) const override { + return real_handler_.max_timestamp_byte_size(hash_algorithm); + } + + private: + CryptoWriteHandler& real_handler_; + TemplatedFixedKeySigner signer_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_TEMPLATED_CRYPTO_WRITE_HANDLER_H_
diff --git a/crypto/templated_crypto_write_handler_test.cc b/crypto/templated_crypto_write_handler_test.cc new file mode 100644 index 0000000..dd1d604 --- /dev/null +++ b/crypto/templated_crypto_write_handler_test.cc
@@ -0,0 +1,101 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "crypto/templated_crypto_write_handler.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "absl/time/simulated_clock.h" +#include "absl/time/time.h" +#include "crypto/algorithms.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "testing/create_test_crypto_write_handler.h" + +namespace credentio { +namespace { + +using ::testing::ElementsAreArray; +using ::testing::IsEmpty; + +absl::StatusOr<std::string> GetTimestamp(CryptoWriteHandler& handler, + HashAlgorithm hash_algorithm, + absl::string_view hashed_message) { + absl::Notification done; + absl::StatusOr<std::string> result; + handler.CreateTimestamp(hash_algorithm, std::string(hashed_message), + /*options=*/{}, + [&result, &done](absl::StatusOr<std::string> res) { + result = std::move(res); + done.Notify(); + }); + done.WaitForNotification(); + return result; +} + +TEST(TemplatedCryptoWriteHandlerTest, Create) { + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> sign_handler = + CreateTestCryptoWriteHandler({}); + ABSL_ASSERT_OK(sign_handler); + TemplatedCryptoWriteHandler mock_handler(sign_handler->get()); + const FixedKeySigner& signer = (*sign_handler)->GetSigner(); + EXPECT_EQ(signer.algorithm(), mock_handler.GetSigner().algorithm()); + EXPECT_EQ(signer.max_signature_byte_size(), + mock_handler.GetSigner().max_signature_byte_size()); + EXPECT_THAT(signer.certificate_chain(), + ElementsAreArray(mock_handler.GetSigner().certificate_chain())); + EXPECT_THAT(signer.ocsp_responses(), + ElementsAreArray(mock_handler.GetSigner().ocsp_responses())); + absl::StatusOr<SignResponse> real_signature = signer.Sign("to_be_signed"); + ABSL_ASSERT_OK(real_signature); + absl::StatusOr<SignResponse> mock_signature = + mock_handler.GetSigner().Sign("to_be_signed"); + ABSL_ASSERT_OK(mock_signature); + EXPECT_EQ(mock_signature->signature.size(), signer.max_signature_byte_size()); + + absl::StatusOr<std::unique_ptr<Hasher>> hasher = + CreateHasher(HashAlgorithm::kSha384); + ABSL_ASSERT_OK(hasher); + (*hasher)->Update(real_signature->signature); + std::string hashed_message = (*hasher)->Digest(); + absl::StatusOr<std::string> mock_timestamp = + GetTimestamp(mock_handler, HashAlgorithm::kSha384, hashed_message); + ABSL_ASSERT_OK(mock_timestamp); + EXPECT_EQ(mock_timestamp->size(), + (*sign_handler)->max_timestamp_byte_size(HashAlgorithm::kSha384)); +} + +TEST(TemplatedCryptoWriteHandlerTest, OcspExpired) { + absl::SimulatedClock clock; + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> sign_handler = + CreateTestCryptoWriteHandler({.clock = &clock}); + ABSL_ASSERT_OK(sign_handler); + TemplatedCryptoWriteHandler mock_handler(sign_handler->get()); + + clock.AdvanceTime(absl::Hours(24 * 365)); + EXPECT_THAT(mock_handler.GetSigner().ocsp_responses(), IsEmpty()); +} + +} // namespace +} // namespace credentio
diff --git a/crypto/timestamp_write_handler.h b/crypto/timestamp_write_handler.h new file mode 100644 index 0000000..8a6a7a7 --- /dev/null +++ b/crypto/timestamp_write_handler.h
@@ -0,0 +1,54 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_TIMESTAMP_WRITE_HANDLER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_TIMESTAMP_WRITE_HANDLER_H_ + +#include <cstdint> +#include <optional> +#include <string> + +#include "absl/functional/any_invocable.h" +#include "absl/status/statusor.h" +#include "async/async_context.h" +#include "crypto/algorithms.h" + +namespace credentio { +// A producer of RFC3161 TimeStampTokens. +class TimestampWriteHandler { + public: + struct Options { + // If set, the async context to use for the timestamping request. + AsyncContext async_context = AsyncContext(); + }; + // Creates an RFC 3161 TimeStampToken. + // + // Returns: + // - UNAVAILABLE if timestamping is not available. + virtual void CreateTimestamp( + HashAlgorithm hash_algorithm, std::string hashed_message, + TimestampWriteHandler::Options options, + absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> cb) const = 0; + // Returns an upper bound on the size of the serialized TimeStampTokens + // produced by this instance's CreateTimestamp. Implementations can use + // the helpers in timestamp_size.h. + virtual uint64_t max_timestamp_byte_size( + HashAlgorithm hash_algorithm) const = 0; + virtual ~TimestampWriteHandler() = default; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_TIMESTAMP_WRITE_HANDLER_H_
diff --git a/formats/BUILD b/formats/BUILD index 20f7bd9..a9f0f01 100644 --- a/formats/BUILD +++ b/formats/BUILD
@@ -156,3 +156,19 @@ "@abseil-cpp//absl/strings:string_view", ], ) + +cc_library( + name = "hasher_data", + srcs = ["hasher_data.cc"], + hdrs = ["hasher_data.h"], + deps = [ + "//bindings:input_hasher", + "//crypto:algorithms", + "//proto:data_hash_assertion_cc_proto", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + ], +)
diff --git a/formats/bmff/BUILD b/formats/bmff/BUILD index 584fc68..4ab750b 100644 --- a/formats/bmff/BUILD +++ b/formats/bmff/BUILD
@@ -43,6 +43,28 @@ ], ) +cc_test( + name = "box_header_test", + srcs = ["box_header_test.cc"], + deps = [ + ":box_header", + ":constants", + ":test_utils", + "//constants:labels", + "//formats:byte_range", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/bytes:writer", + ], +) + cc_library( name = "constants", hdrs = ["constants.h"], @@ -192,3 +214,129 @@ "@abseil-cpp//absl/status:statusor", ], ) + +cc_library( + name = "offset_adjustment", + srcs = ["offset_adjustment.cc"], + hdrs = ["offset_adjustment.h"], + deps = [ + ":box_header", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_reading", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "offset_adjustment_test", + srcs = ["offset_adjustment_test.cc"], + deps = [ + ":box_header", + ":offset_adjustment", + "//testing:test_string_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +) + +cc_library( + name = "embedder", + srcs = ["embedder.cc"], + hdrs = ["embedder.h"], + deps = [ + ":box_header", + ":box_matcher", + ":offset_adjustment", + "//bindings:input_hasher", + "//constants:labels", + "//formats:byte_range", + "//proto:bmff_based_hash_assertion_cc_proto", + "//utils:byte_instruction", + "//utils:byte_writers", + "@abseil-cpp//absl/algorithm:container", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( + name = "hasher", + srcs = ["hasher.cc"], + hdrs = ["hasher.h"], + deps = [ + "//bindings:binding_hasher", + "//bindings:bmff_binding_hasher", + "//crypto:algorithms", + "//proto:bmff_based_hash_assertion_cc_proto", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "hasher_test", + srcs = ["hasher_test.cc"], + deps = [ + ":hasher", + ":test_utils", + "//constants:labels", + "//crypto:algorithms", + "//proto:bmff_based_hash_assertion_cc_proto", + "//testing:proto_test_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "writer", + srcs = ["writer.cc"], + hdrs = ["writer.h"], + deps = [ + ":box_header", + ":embedder", + ":hasher", + "//async:async_context", + "//crypto:algorithms", + "//formats:byte_range", + "//generator", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:generator_cc_proto", + "//utils:byte_instruction", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/bytes:writer", + ], +)
diff --git a/formats/bmff/box_header_test.cc b/formats/bmff/box_header_test.cc new file mode 100644 index 0000000..7f906e7 --- /dev/null +++ b/formats/bmff/box_header_test.cc
@@ -0,0 +1,786 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/bmff/box_header.h" + +#include <cstddef> +#include <cstdint> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "formats/bmff/constants.h" +#include "formats/bmff/test_utils.h" +#include "formats/byte_range.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio_testing::BadBox; +using ::credentio_testing::BigBox; +using ::credentio_testing::Box; +using ::credentio_testing::BoxFlags; +using ::credentio_testing::BoxVersion; +using ::credentio_testing::C2paUuidBox; +using ::credentio_testing::UuidBoxPayload; +using ::testing::AnyOf; +using ::testing::ElementsAre; +using ::testing::Eq; +using ::testing::HasSubstr; +using ::testing::IsEmpty; + +constexpr absl::string_view kZeroFlags("\x00\x00\x00", 3); + +absl::StatusOr<std::vector<BmffBoxHeader>> ReadAllBmffBoxHeaders( + absl::string_view contents) { + riegeli::StringReader<> reader(contents); + return ReadBmffBoxHeaders(reader); +} + +class BoxHeaderTest : public testing::Test { + public: + absl::StatusOr<int64_t> WriteBox(const BmffBox& box, absl::string_view data, + std::string& destination, + bool start_with_error = false) { + riegeli::StringWriter<> writer(&destination); + if (start_with_error) { + writer.Fail(absl::InternalError("failed to write box header")); + } + + ABSL_ASSIGN_OR_RETURN(int64_t written, WriteBmffBoxHeader(box, writer)); + if (!writer.Write(data)) { + return writer.status(); + } + + if (!writer.Close()) { + return writer.status(); + } + return written; + } + + absl::Status ReadBox(absl::string_view data, BmffBoxHeader& header) { + riegeli::StringReader<> reader(data); + ABSL_ASSIGN_OR_RETURN(header, ReadBmffBoxHeader(reader)); + return absl::OkStatus(); + } +}; + +TEST_F(BoxHeaderTest, ReadAllBoxesWithEmptyContent) { + EXPECT_THAT(ReadAllBmffBoxHeaders(""), IsOkAndHolds(IsEmpty())); +} + +TEST_F(BoxHeaderTest, ReadAllBoxesUUIDBox) { + std::string uuid_box = Box( + "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(1), BoxFlags(2), "")); + EXPECT_THAT(ReadAllBmffBoxHeaders(uuid_box), + IsOkAndHolds(ElementsAre(BmffBoxHeader{ + .start = 0, + .box_size = 28, // header_size + 4 (version+flags) + .header_size = 24, // 4 (size) + 4 (type) + 16 (UUID) + .version_and_flags_size = 4, + .type = "uuid", + .xpath = "/uuid[1]", + .user_type = std::string(kC2paBmffBoxUuid), + .version = 1, + .flags = BoxFlags(2)}))); +} + +TEST_F(BoxHeaderTest, ReadAllBoxesFullBox) { + std::string uuid_box = + Box("pdin", absl::StrCat(BoxVersion(0), BoxFlags(0), "")); + EXPECT_THAT(ReadAllBmffBoxHeaders(uuid_box), + IsOkAndHolds(ElementsAre(BmffBoxHeader{ + .start = 0, + .box_size = 12, // header_size + 4 (version+flags) + .header_size = 8, // 4 (size) + 4 (type) + .version_and_flags_size = 4, + .type = "pdin", + .xpath = "/pdin[1]", + .user_type = "", + .version = 0, + .flags = std::string(kZeroFlags)}))); +} + +TEST_F(BoxHeaderTest, ReadAllBoxesTwoBoxes) { + std::string uuid_box = Box( + "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), "")); + std::string other_box = Box("blah", "abcd"); + EXPECT_THAT(ReadAllBmffBoxHeaders(absl::StrCat(uuid_box, other_box)), + IsOkAndHolds(ElementsAre( + BmffBoxHeader{.start = 0, + .box_size = 28, + .header_size = 24, + .version_and_flags_size = 4, + .type = "uuid", + .xpath = "/uuid[1]", + .user_type = std::string(kC2paBmffBoxUuid), + .version = 0, + .flags = std::string(kZeroFlags)}, + BmffBoxHeader{.start = 28, + .box_size = 12, + .header_size = 8, + .type = "blah", + .xpath = "/blah[1]"}))); +} + +TEST_F(BoxHeaderTest, ReadAllBoxesWithSubBoxes) { + std::string uuid = Box( + "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), "")); + std::string trak_trak1_trak1 = Box("trak", "abcd"); // box_size = 4+4+4; + std::string trak_trak1 = Box("trak", trak_trak1_trak1); // box_size = 4+4+12; + std::string trak_trak2 = BigBox("trak", "efgh"); // box_size = (4+8)+4+4; + std::string moov = Box("moov", "moov"); // box_size = 4+4+4; + std::string trak1 = BigBox( + "trak", absl::StrCat(trak_trak1, moov, + trak_trak2)); // box_size = (4+8)+4+(20+20+12); + std::string trak2 = Box("trak", "ijkl"); // box_size = 4+4+4; + EXPECT_THAT(ReadAllBmffBoxHeaders(absl::StrCat(uuid, trak1, trak2)), + IsOkAndHolds(ElementsAre( + BmffBoxHeader{.start = 0, + .box_size = 28, + .header_size = 24, + .version_and_flags_size = 4, + .type = "uuid", + .xpath = "/uuid[1]", + .user_type = std::string(kC2paBmffBoxUuid), + .version = 0, + .flags = std::string(kZeroFlags)}, + BmffBoxHeader{.start = 28, + .box_size = 68, + .header_size = 16, + .type = "trak", + .xpath = "/trak[1]"}, + BmffBoxHeader{.start = 44, + .box_size = 20, + .header_size = 8, + .type = "trak", + .xpath = "/trak[1]/trak[1]"}, + BmffBoxHeader{.start = 52, + .box_size = 12, + .header_size = 8, + .type = "trak", + .xpath = "/trak[1]/trak[1]/trak[1]"}, + BmffBoxHeader{.start = 64, + .box_size = 12, + .header_size = 8, + .type = "moov", + .xpath = "/trak[1]/moov[1]"}, + BmffBoxHeader{.start = 76, + .box_size = 20, + .header_size = 16, + .type = "trak", + .xpath = "/trak[1]/trak[2]"}, + BmffBoxHeader{.start = 96, + .box_size = 12, + .header_size = 8, + .type = "trak", + .xpath = "/trak[2]"}))); +} + +TEST_F(BoxHeaderTest, ReadAllBoxesBoxExtendsBeyondParent) { + std::string input = Box("moov", BadBox("trak", "ijkl", /*data_size=*/100)); + EXPECT_THAT(ReadAllBmffBoxHeaders(input), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("sub-box extends beyond the parent"))); +} + +TEST_F(BoxHeaderTest, ReadAllBoxesBoxExtendsBeyondInput) { + std::string input = + absl::StrCat(BadBox("trak", "ijkl", /*data_size=*/100), Box("moov", "")); + EXPECT_THAT( + ReadAllBmffBoxHeaders(input), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("truncated BMFF box"))); +} + +TEST_F(BoxHeaderTest, ReadAllBoxesMissing64bitSize) { + std::string moov = + BadBox("moov", "ijkl", + /*data_size=*/1); // box size set to 1, indicating a 64-bit size, + // but does not have a 64-bit size. + EXPECT_THAT(ReadAllBmffBoxHeaders(moov), + StatusIs(absl::StatusCode::kDataLoss, + AnyOf(HasSubstr("kUnexpectedEof"), + HasSubstr("failed to read box size")))); +} + +TEST_F(BoxHeaderTest, ReadAllBoxesBoxDataSizeTooSmall) { + std::string moov = + BadBox("moov", "ijkl", /*data_size=*/2); // data size must be at least 8. + EXPECT_THAT(ReadAllBmffBoxHeaders(moov), + StatusIs(absl::StatusCode::kDataLoss, + AnyOf(HasSubstr("kInvalidData"), + HasSubstr("unsupported box size")))); +} + +TEST_F(BoxHeaderTest, ReadsQuickTimeMetaBox) { + std::string data = Box( + "meta", Box("hdlr", absl::StrCat(BoxVersion(0), BoxFlags(0), "abcd"))); + EXPECT_THAT(ReadAllBmffBoxHeaders(data), IsOkAndHolds(ElementsAre( + BmffBoxHeader{ + .start = 0, + .box_size = 24, + .header_size = 8, + .version_and_flags_size = 0, + .type = "meta", + .xpath = "/meta[1]", + .user_type = "", + .version = 0, + .flags = "", + }, + BmffBoxHeader{ + .start = 8, + .box_size = 16, + .header_size = 8, + .version_and_flags_size = 4, + .type = "hdlr", + .xpath = "/meta[1]/hdlr[1]", + .user_type = "", + .version = 0, + .flags = BoxFlags(0), + }))); +} + +TEST_F(BoxHeaderTest, ReadsIsoMetaBox) { + std::string data = + absl::StrCat(Box("meta", absl::StrCat(BoxVersion(0), BoxFlags(0))), + Box("free", "padding")); + EXPECT_THAT(ReadAllBmffBoxHeaders(data), IsOkAndHolds(ElementsAre( + BmffBoxHeader{ + .start = 0, + .box_size = 12, + .header_size = 8, + .version_and_flags_size = 4, + .type = "meta", + .xpath = "/meta[1]", + .user_type = "", + .version = 0, + .flags = BoxFlags(0), + }, + BmffBoxHeader{ + .start = 12, + .box_size = 15, + .header_size = 8, + .version_and_flags_size = 0, + .type = "free", + .xpath = "/free[1]", + .user_type = "", + .version = 0, + .flags = "", + }))); +} + +TEST_F(BoxHeaderTest, StopsEarly) { + std::string uuid_box = Box( + "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), "")); + std::string other_box = Box("blah", "abcd"); + std::string data = absl::StrCat(uuid_box, other_box); + + std::vector<BmffBoxHeader> result; + riegeli::StringReader<> reader(data); + + EXPECT_THAT(IterateOverBmffBoxes(reader, + [&result](const BmffBoxHeader& box) { + result.push_back(box); + return false; // Stop early! + }), + IsOk()); + + EXPECT_THAT(result, ElementsAre(BmffBoxHeader{ + .start = 0, + .box_size = 28, + .header_size = 24, + .version_and_flags_size = 4, + .type = "uuid", + .xpath = "/uuid[1]", + .user_type = std::string(kC2paBmffBoxUuid), + .version = 0, + .flags = std::string(kZeroFlags)})); +} + +TEST_F(BoxHeaderTest, PropagatesError) { + std::string data = Box("blah", "abcd"); + + riegeli::StringReader<> reader(data); + + auto status = IterateOverBmffBoxes(reader, [](const BmffBoxHeader& box) { + return absl::InternalError("custom error"); + }); + + EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal, "custom error")); +} + +TEST_F(BoxHeaderTest, IterateOverBmffBoxesStopsEarlyInSubBox) { + std::string sub_box = Box("blah", "abcd"); + std::string container_box = Box("moov", sub_box); + std::string data = container_box; + + std::vector<BmffBoxHeader> result; + riegeli::StringReader<> reader(data); + + EXPECT_THAT(IterateOverBmffBoxes(reader, + [&result](const BmffBoxHeader& box) { + result.push_back(box); + if (box.type == "blah") { + return false; // Stop early in sub-box! + } + return true; + }), + IsOk()); + + EXPECT_THAT(result, ElementsAre(BmffBoxHeader{.start = 0, + .box_size = 20, + .header_size = 8, + .type = "moov", + .xpath = "/moov[1]"}, + BmffBoxHeader{.start = 8, + .box_size = 12, + .header_size = 8, + .type = "blah", + .xpath = "/moov[1]/blah[1]"})); +} + +class NoSizeReader : public riegeli::Reader { + public: + bool SupportsSize() override { return false; } + bool PullSlow(size_t min_length, size_t recommended_length) override { + return false; + } +}; + +TEST_F(BoxHeaderTest, IterateOverBmffBoxesRequiresSize) { + NoSizeReader reader; + EXPECT_THAT( + IterateOverBmffBoxes(reader, [](const BmffBoxHeader&) { return true; }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("reader size cannot be determined"))); +} + +TEST_F(BoxHeaderTest, BoxSizeTooSmallForUuidExploit) { + std::string custom_uuid = absl::StrCat( + absl::string_view("\x00\x00\x00\x08", 4), "free", std::string(8, '\x00')); + ASSERT_EQ(custom_uuid.size(), 16); + + std::string payload = absl::StrCat(custom_uuid, BoxVersion(0), BoxFlags(0)); + std::string input = BadBox("uuid", payload, 8); + + riegeli::StringReader<> reader(input); + std::vector<std::string> parsed_types; + auto status = IterateOverBmffBoxes(reader, [&](const BmffBoxHeader& box) { + parsed_types.push_back(box.type); + return true; + }); + + // We expect it to fail and NOT process the "free" box. + EXPECT_FALSE(status.ok()); + EXPECT_THAT(parsed_types, IsEmpty()); +} + +TEST_F(BoxHeaderTest, BoxSizeTooSmallFor64BitHeader) { + std::string input = + absl::StrCat(absl::string_view("\x00\x00\x00\x01", 4), "blah", + absl::string_view("\x00\x00\x00\x00\x00\x00\x00\x08", 8)); + + riegeli::StringReader<> reader(input); + std::vector<std::string> parsed_types; + auto status = IterateOverBmffBoxes(reader, [&](const BmffBoxHeader& box) { + parsed_types.push_back(box.type); + return true; + }); + + EXPECT_FALSE(status.ok()); + EXPECT_THAT(parsed_types, IsEmpty()); +} + +TEST_F(BoxHeaderTest, GenericUuidBoxAtEndWithoutVersionFlags) { + std::string custom_uuid(16, '\x00'); + std::string input = BadBox("uuid", custom_uuid, 24); + + EXPECT_THAT( + ReadAllBmffBoxHeaders(input), + IsOkAndHolds(ElementsAre(BmffBoxHeader{.start = 0, + .box_size = 24, + .header_size = 24, + .version_and_flags_size = 0, + .type = "uuid", + .xpath = "/uuid[1]", + .user_type = custom_uuid, + .version = 0, + .flags = ""}))); +} + +std::string NestBoxes(absl::string_view type, int depth) { + std::string current_data = ""; + for (int i = 0; i < depth; ++i) { + current_data = Box(type, current_data); + } + return current_data; +} + +TEST_F(BoxHeaderTest, DeeplyNestedBoxes) { + std::string input = NestBoxes("moov", 100); + EXPECT_THAT(ReadAllBmffBoxHeaders(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Too many nested BMFF boxes"))); +} + +using BoxHeaderContainerTest = testing::TestWithParam<std::string>; + +INSTANTIATE_TEST_SUITE_P(, BoxHeaderContainerTest, + testing::Values("moov", "trak", "mdia", "minf", "stbl", + "moof", "traf", "edts", "udta", "dinf", + "tref", "treg", "mvex", "mfra", "meta", + "schi"), + [](const testing::TestParamInfo<std::string>& info) { + return info.param.data(); + }); + +TEST_P(BoxHeaderContainerTest, IsContainerBox) { + EXPECT_TRUE(BmffBoxHeader{.type = GetParam()}.IsContainerBox()); +} + +TEST(BoxHeader, IsRootBox) { + { + BmffBoxHeader header = {}; + EXPECT_TRUE(header.IsRootBox()); + } + { + BmffBoxHeader header = {.xpath = "/"}; + EXPECT_TRUE(header.IsRootBox()); + } + { + BmffBoxHeader header = {.xpath = "/uuid"}; + EXPECT_TRUE(header.IsRootBox()); + } + { + BmffBoxHeader header = {.xpath = "/trak[1]"}; + EXPECT_TRUE(header.IsRootBox()); + } + { + BmffBoxHeader header = {.xpath = "/trak/moov"}; + EXPECT_FALSE(header.IsRootBox()); + } + { + BmffBoxHeader header = {.xpath = "/trak[1]/moov"}; + EXPECT_FALSE(header.IsRootBox()); + } +} + +TEST_F(BoxHeaderTest, WriteUUIDBox) { + std::string data = "abcd"; + BmffBox box{.type = "uuid", + .user_type = std::string(kC2paBmffBoxUuid), + .version = 1, + .flags = BoxFlags(2), + .data_size = data.size()}; + std::string serialized; + + EXPECT_THAT(WriteBox(box, data, serialized), IsOkAndHolds(28)); + + EXPECT_EQ(serialized.size(), BmffBoxHeaderSize(box) + data.size()); + EXPECT_THAT( + ReadAllBmffBoxHeaders(serialized), + IsOkAndHolds(ElementsAre(BmffBoxHeader{ + .start = 0, + .box_size = 28 + data.size(), // header_size + 4 (version+flags) + .header_size = 24, // 4 (size) + 4 (type) + 16 (UUID) + .version_and_flags_size = 4, + .type = "uuid", + .xpath = "/uuid[1]", + .user_type = std::string(kC2paBmffBoxUuid), + .version = 1, + .flags = BoxFlags(2)}))); +} + +TEST_F(BoxHeaderTest, WriteFullBox) { + std::string data = "abcd"; + BmffBox box{.type = "pdin", + .user_type = "", + .version = 0, + .flags = std::string(kZeroFlags), + .data_size = data.size()}; + std::string serialized; + EXPECT_THAT(WriteBox(box, data, serialized), IsOkAndHolds(12)); + EXPECT_EQ(serialized.size(), BmffBoxHeaderSize(box) + data.size()); + EXPECT_THAT( + ReadAllBmffBoxHeaders(serialized), + IsOkAndHolds(ElementsAre(BmffBoxHeader{ + .start = 0, + .box_size = 12 + data.size(), // header_size + 4 (version+flags) + .header_size = 8, // 4 (size) + 4 (type) + .version_and_flags_size = 4, + .type = "pdin", + .xpath = "/pdin[1]", + .user_type = "", + .version = 0, + .flags = std::string(kZeroFlags)}))); +} + +TEST_F(BoxHeaderTest, WriteNotFullBox) { + std::string data = "abcd"; + BmffBox box{.type = "xxxx", .data_size = data.size()}; + std::string serialized; + EXPECT_THAT(WriteBox(box, data, serialized), IsOkAndHolds(8)); + EXPECT_EQ(serialized.size(), BmffBoxHeaderSize(box) + data.size()); + EXPECT_THAT(ReadAllBmffBoxHeaders(serialized), + IsOkAndHolds(ElementsAre( + BmffBoxHeader{.start = 0, + .box_size = 8 + data.size(), + .header_size = 8, // 4 (size) + 4 (type) + .type = "xxxx", + .xpath = "/xxxx[1]"}))); +} + +TEST_F(BoxHeaderTest, WriteTwoBoxes) { + std::string data = "abcd"; + BmffBox uuid_box{.type = "uuid", + .user_type = std::string(kC2paBmffBoxUuid), + .version = 1, + .flags = BoxFlags(2), + .data_size = data.size()}; + BmffBox other_box{.type = "blah", .data_size = data.size()}; + std::string uuid_serialized; + EXPECT_THAT(WriteBox(uuid_box, data, uuid_serialized), IsOkAndHolds(28)); + std::string other_serialized; + EXPECT_THAT(WriteBox(other_box, data, other_serialized), IsOkAndHolds(8)); + std::string serialized = absl::StrCat(uuid_serialized, other_serialized); + EXPECT_EQ(serialized.size(), BmffBoxHeaderSize(uuid_box) + data.size() + + BmffBoxHeaderSize(other_box) + data.size()); + EXPECT_THAT(ReadAllBmffBoxHeaders(serialized), + IsOkAndHolds(ElementsAre( + BmffBoxHeader{.start = 0, + .box_size = 28 + data.size(), + .header_size = 24, + .version_and_flags_size = 4, + .type = "uuid", + .xpath = "/uuid[1]", + .user_type = std::string(kC2paBmffBoxUuid), + .version = 1, + .flags = BoxFlags(2)}, + BmffBoxHeader{.start = 32, + .box_size = 8 + data.size(), + .header_size = 8, + .type = "blah", + .xpath = "/blah[1]"}))); +} + +TEST_F(BoxHeaderTest, WriteFailsWhenStatusNotOKBeforeWrite) { + std::string data = "abcd"; + BmffBox box{.type = "xxxx", .data_size = data.size()}; + std::string serialized; + EXPECT_THAT(WriteBox(box, data, serialized, /*start_with_error=*/true), + StatusIs(absl::StatusCode::kInternal)); + EXPECT_TRUE(serialized.empty()); +} + +TEST_F(BoxHeaderTest, WriteSize64bits) { + uint64_t data_size = 4294967296; // uint32_t max + 1 + BmffBox box{.type = "xxxx", .data_size = data_size}; + std::string serialized; + EXPECT_THAT(WriteBox(box, "", serialized), IsOkAndHolds(16)); + EXPECT_EQ(serialized.size(), BmffBoxHeaderSize(box)); + + BmffBoxHeader read; + ABSL_ASSERT_OK(ReadBox(serialized, read)); + BmffBoxHeader expected{ + .start = 0, + .box_size = 8 + 8 + data_size, + .header_size = 8 + 8, // 4 (size) + 4 (type) + 8 (64bits) + .type = "xxxx"}; + EXPECT_EQ(read, expected); +} + +struct LocateInsertionPointTestParam { + std::string test_name; + std::string contents; + + absl::StatusOr<ByteRange> expected; +}; + +using LocateInsertionPointTest = + testing::TestWithParam<LocateInsertionPointTestParam>; + +INSTANTIATE_TEST_SUITE_P( + LocateInsertionPointTests, LocateInsertionPointTest, + testing::ValuesIn<LocateInsertionPointTestParam>({ + { + .test_name = "BlankFile", + .contents = "", + .expected = absl::InvalidArgumentError("no mdat/moov box found"), + }, + { + .test_name = "NoMdatOrMoov", + .contents = Box("free", "00"), + .expected = absl::InvalidArgumentError("no mdat/moov box found"), + }, + { + .test_name = "OnlyMdat", + .contents = Box("mdat", "00"), + .expected = ByteRange{.offset = 0, .length = 0}, + }, + { + .test_name = "OnlyMoov", + .contents = Box("moov", "00"), + .expected = ByteRange{.offset = 0, .length = 0}, + }, + { + .test_name = "Mdat", + .contents = absl::StrCat(Box("ftyp", "00"), Box("mdat", "00")), + .expected = ByteRange{.offset = 10, .length = 0}, + }, + { + .test_name = "Moov", + .contents = absl::StrCat(Box("ftyp", "00"), Box("moov", "00")), + .expected = ByteRange{.offset = 10, .length = 0}, + }, + { + .test_name = "MdatAndMoov", + .contents = absl::StrCat(Box("ftyp", "00"), Box("mdat", "00"), + Box("moov", "00")), + .expected = ByteRange{.offset = 10, .length = 0}, + }, + { + .test_name = "FreeAfterMdat", + .contents = absl::StrCat(Box("ftyp", "00"), Box("mdat", "00"), + Box("free", "00")), + .expected = ByteRange{.offset = 10, .length = 0}, + }, + { + .test_name = "FreeAfterMoov", + .contents = absl::StrCat(Box("ftyp", "00"), Box("moov", "00"), + Box("free", "00")), + .expected = ByteRange{.offset = 10, .length = 0}, + }, + { + .test_name = "OneFreeBox", + .contents = absl::StrCat(Box("ftyp", "00"), Box("free", "00"), + Box("mdat", "00")), + .expected = ByteRange{.offset = 10, .length = 10}, + }, + { + .test_name = "TwoSeparateFreeBoxesFirstLarger", + .contents = absl::StrCat( + Box("ftyp", "00"), Box("free", "000000000000"), + Box("data", "00"), Box("free", "00"), Box("mdat", "00")), + .expected = ByteRange{.offset = 10, .length = 20}, + }, + { + .test_name = "TwoSeparateFreeBoxesSecondLarger", + .contents = absl::StrCat( + Box("ftyp", "00"), Box("free", "00"), Box("data", "00"), + Box("free", "000000000000"), Box("mdat", "00")), + .expected = ByteRange{.offset = 30, .length = 20}, + }, + { + .test_name = "AdjacentFreeBoxes", + .contents = absl::StrCat(Box("ftyp", "00"), Box("free", "00"), + Box("free", "00"), Box("mdat", "00")), + .expected = ByteRange{.offset = 10, .length = 20}, + }, + { + .test_name = "LargerFreeBoxAndSeparateC2paManifest", + .contents = absl::StrCat( + Box("ftyp", "00"), Box("free", std::string(102, '0')), + Box("data", "00"), C2paUuidBox("00000"), Box("mdat", "00")), + .expected = ByteRange{.offset = 130, .length = 50}, + }, + { + .test_name = "C2paManifestThenFreeBox", + .contents = absl::StrCat(Box("ftyp", "00"), C2paUuidBox("00000"), + Box("free", "00"), Box("mdat", "00")), + .expected = ByteRange{.offset = 10, .length = 60}, + }, + { + .test_name = "FreeBoxThenC2paManifest", + .contents = absl::StrCat(Box("ftyp", "00"), Box("free", "00"), + C2paUuidBox("00000"), Box("mdat", "00")), + .expected = ByteRange{.offset = 10, .length = 60}, + }, + { + .test_name = "C2paManifest", + .contents = absl::StrCat(Box("ftyp", "00"), C2paUuidBox("00000"), + Box("mdat", "00")), + .expected = ByteRange{.offset = 10, .length = 50}, + }, + { + .test_name = "C2paOriginal", + .contents = absl::StrCat(Box("ftyp", "00"), + C2paUuidBox("00000", + kBmffC2paBoxPurposeOriginal), + Box("mdat", "00")), + .expected = ByteRange{.offset = 10, .length = 50}, + }, + { + .test_name = "C2paMerkle", + .contents = absl::StrCat(Box("ftyp", "00"), + C2paUuidBox("0000000", + kBmffC2paBoxPurposeMerkle), + Box("mdat", "00")), + .expected = ByteRange{.offset = 60, .length = 0}, + }, + { + .test_name = "C2paUpdate", + .contents = absl::StrCat(Box("ftyp", "00"), + C2paUuidBox("0000000", + kBmffC2paBoxPurposeUpdate), + Box("mdat", "00")), + .expected = ByteRange{.offset = 60, .length = 0}, + }, + }), + [](const testing::TestParamInfo<LocateInsertionPointTest::ParamType>& + info) { return info.param.test_name; }); + +TEST_P(LocateInsertionPointTest, FindsInsertionPoint) { + riegeli::StringReader reader(GetParam().contents); + + if (GetParam().expected.ok()) { + EXPECT_THAT(LocateManifestInsertionPoint(reader), + IsOkAndHolds(Eq(GetParam().expected.value()))); + } else { + EXPECT_THAT(LocateManifestInsertionPoint(reader), + StatusIs(GetParam().expected.status().code(), + HasSubstr(GetParam().expected.status().message()))); + } +} + +TEST(IsBmffFullBoxTest, True) { + for (const absl::string_view type : credentio_internal::kFullBoxTypes) { + EXPECT_TRUE(IsBmffFullBox(type)); + } +} + +TEST(IsBmffFullBoxTest, False) { + EXPECT_FALSE(IsBmffFullBox("")); + EXPECT_FALSE(IsBmffFullBox("a")); + EXPECT_FALSE(IsBmffFullBox("uri")); + EXPECT_FALSE(IsBmffFullBox("aaaa")); + EXPECT_FALSE(IsBmffFullBox("aaaaaaaaaaaaaaaaaaaa")); +} + +} // namespace +} // namespace credentio
diff --git a/formats/bmff/embedder.cc b/formats/bmff/embedder.cc new file mode 100644 index 0000000..5c305a5 --- /dev/null +++ b/formats/bmff/embedder.cc
@@ -0,0 +1,361 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/bmff/embedder.h" + +#include <algorithm> +#include <cstddef> +#include <cstdint> +#include <limits> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/algorithm/container.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "bindings/input_hasher.h" +#include "constants/labels.h" +#include "formats/bmff/box_header.h" +#include "formats/bmff/box_matcher.h" +#include "formats/bmff/offset_adjustment.h" +#include "formats/byte_range.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_writing.h" +#include "utils/byte_instruction.h" +#include "utils/byte_writers.h" + +namespace credentio { + +namespace { + +std::string Uint64ToBytes(uint64_t value) { + std::vector<uint8_t> bytes; + WriteUint64NetworkOrder(value, &bytes); + return std::string(bytes.begin(), bytes.end()); +} + +constexpr absl::string_view kZeroFlags("\x00\x00\x00", 3); +constexpr absl::string_view kC2paBoxPurpose = "manifest"; +constexpr uint64_t kMaxChunkBytes = 1024 * 1024; // 1 MiB + +BmffBox C2paBmffBoxHeader(uint64_t manifest_store_size) { + return BmffBox{ + .type = "uuid", + .user_type = std::string(kC2paBmffBoxUuid), + .flags = std::string(kZeroFlags), + // Nul-terminated purpose + 8-byte merkle offset + manifest_store. + .data_size = kC2paBoxPurpose.size() + 1 + 8 + manifest_store_size, + }; +} + +bool BoxHasOffsets(absl::string_view type) { + return type == "stco" || type == "saio" || type == "co64" || type == "iloc"; +} + +// Adjusts the offsets in the given box by adding the size of the manifest +// store and writes it to the destination. +absl::Status FixOffsets(const BmffBoxHeader& box_header, + riegeli::Reader& source, uint64_t byte_adjustment, + riegeli::Writer& destination) { + ABSL_ASSIGN_OR_RETURN( + int64_t written_header_size, + WriteBmffBoxHeader( + BmffBox{ + .type = box_header.type, + .user_type = box_header.user_type, + .version = box_header.version, + .flags = box_header.flags, + // The box_size includes the header size, the write method expects + // only the data size. + .data_size = box_header.box_size - box_header.header_size - + box_header.version_and_flags_size, + }, + destination)); + // Since we didn't read the header from the source, we need to skip over those + // bytes. + if (!source.Seek(box_header.start + written_header_size)) { + return absl::DataLossError("failed to skip header size"); + } + + if (box_header.type == "iloc") { + ABSL_ASSIGN_OR_RETURN(IlocBox box, ParseIloc(source, box_header)); + AdjustOffsets(box, byte_adjustment); + return SerializeIloc(box, box_header, destination); + } + + if (box_header.type == "stco") { + ABSL_ASSIGN_OR_RETURN(StcoBox box, ParseStco(source)); + AdjustOffsets(box, byte_adjustment); + return SerializeStco(box, destination); + } + + if (box_header.type == "co64") { + ABSL_ASSIGN_OR_RETURN(Co64Box box, ParseCo64(source)); + AdjustOffsets(box, byte_adjustment); + return SerializeCo64(box, destination); + } + + if (box_header.type == "saio") { + ABSL_ASSIGN_OR_RETURN(SaioBox box, ParseSaio(source, box_header)); + AdjustOffsets(box, byte_adjustment); + return SerializeSaio(box, box_header, destination); + } + + return absl::InvalidArgumentError( + absl::StrCat("Unexpected box type for offset fixing: ", box_header.type)); +} + +absl::Status ProcessC2paAtom(const BmffBoxHeader& box, riegeli::Reader& source, + const ByteRange& insertion_point, + std::vector<ByteInstruction>& byte_instructions) { + if (box.start >= insertion_point.offset && + box.start + box.box_size <= + insertion_point.offset + insertion_point.length) { + // This box is completely within the insertion point, it will be handled by + // the instruction which inserts the manifest store. + return absl::OkStatus(); + } + + // Just convert any other C2PA atom outside of the insertion point to a free + // box. + std::string free_box; + riegeli::StringWriter<> free_box_writer(&free_box); + ABSL_RETURN_IF_ERROR(CreateFreeBox(box.box_size, free_box_writer)); + if (!free_box_writer.Close()) { + return absl::InternalError("failed to close string writer"); + } + + byte_instructions.push_back( + ByteInstruction{.operation = ByteInstruction::Operation::kReplace, + .offset = box.start, + .bytes = std::move(free_box)}); + + return absl::OkStatus(); +} + +absl::Status ProcessBox(const BmffBoxHeader& box, riegeli::Reader& source, + bool can_replace_manifest, + absl::Span<const BMFFBoxMatcher> exclusion_matchers, + const ByteRange& insertion_point, + uint64_t bytes_needed_to_add, + std::vector<ByteInstruction>& byte_instructions, + std::vector<std::string>& excluded_atoms, + InputHasher& hasher) { + if (box.type == "uuid" && box.user_type == kC2paBmffBoxUuid) { + if (!can_replace_manifest) { + return absl::AlreadyExistsError( + "C2PA chunk already exists, but no `opened` action was provided"); + } + + ABSL_RETURN_IF_ERROR( + ProcessC2paAtom(box, source, insertion_point, byte_instructions)); + + // Skip this box + source.Seek(box.start + box.box_size); + return absl::OkStatus(); + } + + bool added_byte_instruction = false; + if (BoxHasOffsets(box.type) && bytes_needed_to_add > 0) { + // Move the current position to this box. + if (!source.Seek(box.start) || source.pos() != box.start) { + return absl::InternalError("failed to seek to box start"); + } + + auto byte_instruction = ByteInstruction{ + .operation = ByteInstruction::Operation::kReplace, .offset = box.start}; + + riegeli::StringWriter<> writer(&byte_instruction.bytes); + ABSL_RETURN_IF_ERROR(FixOffsets(box, source, bytes_needed_to_add, writer)); + if (!writer.Close()) { + return absl::InternalError("failed to close string writer"); + } + + byte_instructions.push_back(std::move(byte_instruction)); + added_byte_instruction = true; + } + + if (absl::c_any_of(excluded_atoms, [&box](absl::string_view excluded_atom) { + return absl::StartsWith(box.xpath, excluded_atom); + })) { + // Parent atom is excluded, so skip this box. + return absl::OkStatus(); + } + for (const auto& matcher : exclusion_matchers) { + if (matcher.Matches(box)) { + excluded_atoms.push_back(box.xpath); + + if (matcher.IsMatchingEntireBox()) { + // The entire box is excluded, so we can skip it. + return absl::OkStatus(); + } + // Note: Excluding parts of a box is not supported, as best practice would + // see no additional exclusions aside from required exclusions. + // See: + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_exclusion_list_requirements + return absl::InvalidArgumentError( + "partial exclusions of atom are not supported"); + } + } + + if (box.IsRootBox()) { + int64_t adjusted_box_start = box.start >= insertion_point.offset + ? box.start + bytes_needed_to_add + : box.start; + + hasher.Update(Uint64ToBytes(adjusted_box_start)); + } + + if (added_byte_instruction) { + hasher.Update(byte_instructions.back().bytes); + return absl::OkStatus(); + } + + if (box.IsContainerBox()) { + // The box is a container, so we need to hash the header then continue for + // the data. + int64_t data_start = source.pos(); + + source.Seek(box.start); + std::string header_data; + source.Read(data_start - box.start, header_data); + hasher.Update(header_data); + + return absl::OkStatus(); + } + + // not a container, not excluded, and no offsets, so just hash the whole box + source.Seek(box.start); + uint64_t remaining_bytes = box.box_size; + while (remaining_bytes > 0) { + size_t chunk_size = std::min(remaining_bytes, kMaxChunkBytes); + std::string chunk_data; + if (!source.Read(chunk_size, chunk_data)) { + return absl::DataLossError("failed to read box data"); + } + hasher.Update(chunk_data); + remaining_bytes -= chunk_size; + } + + return absl::OkStatus(); +} + +} // namespace + +absl::StatusOr<std::vector<ByteInstruction>> BmffEmbedder::InspectAndHash( + riegeli::Reader& source, uint64_t manifest_store_size, + const ByteRange& insertion_point, bool can_replace_manifest, + BmffBasedHashAssertion& assertion) const { + uint64_t c2pa_atom_size = CalculateC2paSizeBmff(manifest_store_size); + uint64_t bytes_needed_to_add = insertion_point.length >= c2pa_atom_size + ? 0 + : c2pa_atom_size - insertion_point.length; + + std::vector<BMFFBoxMatcher> exclusion_matchers; + for (const auto& bmff_range : assertion.exclusions()) { + exclusion_matchers.push_back(BMFFBoxMatcher(bmff_range, &source)); + } + + source.Seek(0); + + ABSL_ASSIGN_OR_RETURN(auto hasher, InputHasher::Create(assertion.alg())); + if (!source.SupportsSize()) { + return absl::InvalidArgumentError( + "manifest store not embedded: source does not support size"); + } + + std::vector<ByteInstruction> byte_instructions; + std::vector<std::string> excluded_atoms; + ABSL_RETURN_IF_ERROR(IterateOverBmffBoxes( + source, + [&source, &insertion_point, &can_replace_manifest, &byte_instructions, + &exclusion_matchers, &excluded_atoms, &hasher, + &bytes_needed_to_add](const BmffBoxHeader& box) -> absl::StatusOr<bool> { + ABSL_RETURN_IF_ERROR(ProcessBox(box, source, can_replace_manifest, + exclusion_matchers, insertion_point, + bytes_needed_to_add, byte_instructions, + excluded_atoms, *hasher)); + return true; + })); + + assertion.set_hash(hasher->Digest()); + + return byte_instructions; +} + +int64_t CalculateC2paSizeBmff(uint64_t manifest_store_size) { + BmffBox box = C2paBmffBoxHeader(manifest_store_size); + return BmffBoxHeaderSize(box) + box.data_size; +} + +absl::Status WriteC2paBmff(absl::string_view manifest_store, + riegeli::Writer& output) { + bool failed = false; + ABSL_RETURN_IF_ERROR( + WriteBmffBoxHeader(C2paBmffBoxHeader(manifest_store.size()), output) + .status()); + failed |= !output.Write(kC2paBoxPurpose); + failed |= !output.WriteByte(0); + failed |= !riegeli::WriteBigEndian<uint64_t>(0, output); + failed |= !output.Write(manifest_store); + if (failed) { + return absl::InternalError("manifest store not embedded: write failed"); + } + return absl::OkStatus(); +} + +absl::Status CreateFreeBox(uint64_t size, riegeli::Writer& output) { + if (size < 8) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid free box size: ", size)); + } + + uint64_t data_size = 0; + if (size > std::numeric_limits<uint32_t>::max()) { + data_size = size - 16; + } else { + data_size = size - 8; + } + + ABSL_RETURN_IF_ERROR( + WriteBmffBoxHeader(BmffBox{.type = "free", .data_size = data_size}, + output) + .status()); + + uint64_t remaining_padding = data_size; + constexpr size_t kChunkSize = 64 * 1024; // 64 KiB + std::string zero_chunk(kChunkSize, '\0'); + while (remaining_padding > 0) { + size_t write_size = std::min<uint64_t>(remaining_padding, kChunkSize); + if (!output.Write(absl::string_view(zero_chunk.data(), write_size))) { + return absl::InternalError("manifest store not embedded: write failed"); + } + remaining_padding -= write_size; + } + + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/formats/bmff/embedder.h b/formats/bmff/embedder.h new file mode 100644 index 0000000..b7ce749 --- /dev/null +++ b/formats/bmff/embedder.h
@@ -0,0 +1,63 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_EMBEDDER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_EMBEDDER_H_ + +#include <cstdint> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/byte_range.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "utils/byte_instruction.h" + +namespace credentio { + +// Embedder for inserting a C2PA manifest store into a BMFF file. +class BmffEmbedder { + public: + // Reads through the source, locating where the manifest should be inserted + // and where any atoms needing offset adjustment are located. Simultaneously + // computes the hard binding hash of the asset. + absl::StatusOr<std::vector<ByteInstruction>> InspectAndHash( + riegeli::Reader& source, uint64_t manifest_store_size, + const ByteRange& insertion_point, bool can_replace_manifest, + BmffBasedHashAssertion& assertion) const; +}; + +// Calculates the size of the C2PA (UUID) box in the BMFF format. +int64_t CalculateC2paSizeBmff(uint64_t manifest_store_size); +// Writes the C2PA manifest store to the output in the BMFF format at the +// current position and returns the offset of the appended box. +// +// NOTE: This shall be called before the first 'mdat' box in the file and +// before any 'moov' box in the file. To accommodate major_brand and +// compatible_brand verification, it shall be called after the 'ftyp' box. +// +// BMFF files with Merkle tree content bindings are not supported. +absl::Status WriteC2paBmff(absl::string_view manifest_store, + riegeli::Writer& output); + +// Creates a free box in the BMFF format at the current position. +absl::Status CreateFreeBox(uint64_t size, riegeli::Writer& output); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_EMBEDDER_H_
diff --git a/formats/bmff/extractor_test.cc b/formats/bmff/extractor_test.cc index d3dc19a..5672bef 100644 --- a/formats/bmff/extractor_test.cc +++ b/formats/bmff/extractor_test.cc
@@ -42,7 +42,6 @@ namespace credentio { namespace { -using ::absl_testing::IsOk; using ::absl_testing::StatusIs; using ::credentio_testing::BigBox; using ::credentio_testing::Box; @@ -232,7 +231,7 @@ absl::StatusOr<ExtractorResult> expected_result = GetParam().result; if (expected_result.ok()) { - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_THAT(*result, Eq(expected_result->manifest_store)); } else { EXPECT_THAT(result, @@ -251,7 +250,7 @@ absl::StatusOr<ExtractorResult> expected_result = GetParam().result; if (expected_result.ok()) { - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_THAT(*result, Eq(expected_result->manifest_store)); } else { EXPECT_THAT(result, @@ -268,7 +267,7 @@ absl::StatusOr<ExtractorResult> expected_result = GetParam().result; if (expected_result.ok()) { - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_THAT(*result, Eq(expected_result->asset_byte_info.manifest_store_location)); } else {
diff --git a/formats/bmff/hasher.cc b/formats/bmff/hasher.cc new file mode 100644 index 0000000..21af00f --- /dev/null +++ b/formats/bmff/hasher.cc
@@ -0,0 +1,73 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/bmff/hasher.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/strings/str_cat.h" +#include "bindings/binding_hasher.h" +#include "bindings/bmff_binding_hasher.h" +#include "crypto/algorithms.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +BmffBasedHashAssertion GetBmffHashTemplate(HashAlgorithm alg) { + BmffBasedHashAssertion assertion; + assertion.set_name("BMFF file hash"); + assertion.set_alg(absl::StrCat(alg)); + std::string hash(HashSizeBytes(alg), '\0'); + assertion.set_hash(hash); + + // Add the required exclusions described in + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_exclusion_list_requirements. + assertion.add_exclusions()->set_xpath("/ftyp"); + BmffRange* uuid_exclusion = assertion.add_exclusions(); + uuid_exclusion->set_xpath("/uuid"); + BmffData* data = uuid_exclusion->add_data(); + data->set_offset(8); + // The C2PA manifest store extended box type value, which will always + // appear at offset 8 in the C2PA BMFF box + // (https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_definition). + data->set_value("\330\376\303\326\033\016H<\222\227X(\207~\304\201"); + assertion.add_exclusions()->set_xpath("/mfra"); + // Exclude 'free' boxes. A 'free' box is often used for a pre-allocated space + // before writing the final C2PA manifest store. + assertion.add_exclusions()->set_xpath("/free"); + + return assertion; +} + +absl::Status DigestBmff(riegeli::Reader& input, + BmffBasedHashAssertion& assertion_template) { + StatusBindingHasherTracker tracker; + BmffBindingHasher hasher(&assertion_template, &tracker); + auto digest = hasher.Digest(input).value_or(""); + ABSL_RETURN_IF_ERROR(tracker.status()); + if (digest.empty()) { + return absl::InternalError("digest is empty but tracker status is OK"); + } + if (digest.size() != assertion_template.hash().size()) { + return absl::InternalError("digest size mismatch"); + } + assertion_template.set_hash(digest); + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/formats/bmff/hasher.h b/formats/bmff/hasher.h new file mode 100644 index 0000000..b86aa3f --- /dev/null +++ b/formats/bmff/hasher.h
@@ -0,0 +1,35 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_HASHER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_HASHER_H_ + +#include "absl/status/status.h" +#include "crypto/algorithms.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +// Returns a BMFF hash assertion template for size estimation, for +// non-fragmented BMFF assets. +BmffBasedHashAssertion GetBmffHashTemplate(HashAlgorithm alg); +// Digests a BMFF file and updates the given hash assertion template. +// Note that the serialized size of the assertion will not change. +absl::Status DigestBmff(riegeli::Reader& input, + BmffBasedHashAssertion& assertion_template); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_HASHER_H_
diff --git a/formats/bmff/hasher_test.cc b/formats/bmff/hasher_test.cc new file mode 100644 index 0000000..20a041f --- /dev/null +++ b/formats/bmff/hasher_test.cc
@@ -0,0 +1,117 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/bmff/hasher.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "crypto/algorithms.h" +#include "formats/bmff/test_utils.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/string_reader.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::credentio::kC2paBmffBoxUuid; +using ::credentio_testing::Box; +using ::credentio_testing::BoxFlags; +using ::credentio_testing::BoxVersion; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::credentio_testing::UuidBoxPayload; +using ::testing::HasSubstr; + +TEST(BmffHasherTest, GetHashAssertionTemplate) { + auto expected = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { xpath: "/ftyp" } + exclusions { + xpath: "/uuid" + data { + offset: 8 + value: "\330\376\303\326\033\016H<\222\227X(\207~\304\201" + } + } + exclusions { xpath: "/mfra" } + exclusions { xpath: "/free" } + alg: "sha256" + hash: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + name: "BMFF file hash" + )pb"); + EXPECT_THAT(GetBmffHashTemplate(HashAlgorithm::kSha256), + EqualsProto(expected)); +} + +TEST(BmffHasherTest, Digest) { + auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { xpath: "/ftyp" } + exclusions { + xpath: "/uuid" + data { + offset: 8 + value: "\330\376\303\326\033\016H<\222\227X(\207~\304\201" + } + } + exclusions { xpath: "/mfra" } + exclusions { xpath: "/free" } + alg: "sha256" + hash: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + name: "BMFF file hash" + )pb"); + std::string input_contents = absl::StrCat( + Box("uuid", + UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), "")), + Box("trak", "ijkl")); + riegeli::StringReader<> input(input_contents); + ABSL_ASSERT_OK(DigestBmff(input, assertion)); +} + +TEST(BmffHasherTest, HashSizeMismatch) { + auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + exclusions { xpath: "/ftyp" } + exclusions { + xpath: "/uuid" + data { + offset: 8 + value: "\330\376\303\326\033\016H<\222\227X(\207~\304\201" + } + } + exclusions { xpath: "/mfra" } + exclusions { xpath: "/free" } + alg: "sha256" + hash: "invalid hash" + name: "BMFF file hash" + )pb"); + std::string input_contents = absl::StrCat( + Box("uuid", + UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), "")), + Box("trak", "ijkl")); + riegeli::StringReader<> input(input_contents); + EXPECT_THAT( + DigestBmff(input, assertion), + StatusIs(absl::StatusCode::kInternal, HasSubstr("digest size mismatch"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/bmff/offset_adjustment.cc b/formats/bmff/offset_adjustment.cc new file mode 100644 index 0000000..749c86e --- /dev/null +++ b/formats/bmff/offset_adjustment.cc
@@ -0,0 +1,421 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/bmff/offset_adjustment.h" + +#include <cstdint> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "formats/bmff/box_header.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_reading.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { + +namespace { + +absl::StatusOr<uint64_t> ReadVariableSize(riegeli::Reader& reader, + uint8_t size) { + if (size == 0) return 0; + if (size == 4) { + uint32_t val; + if (!riegeli::ReadBigEndian<uint32_t>(reader, val)) { + return absl::DataLossError("Failed to read 4 byte integer"); + } + return val; + } + if (size == 8) { + uint64_t val; + if (!riegeli::ReadBigEndian<uint64_t>(reader, val)) { + return absl::DataLossError("Failed to read 8 byte integer"); + } + return val; + } + return absl::DataLossError(absl::StrCat("invalid byte_size: ", size)); +} + +absl::Status WriteVariableSize(uint64_t value, uint8_t size, + riegeli::Writer& writer) { + if (size == 0) return absl::OkStatus(); + if (size == 4) { + if (value > 0xffffffff) { + return absl::InvalidArgumentError("Value too large for 4 bytes"); + } + if (!riegeli::WriteBigEndian<uint32_t>(static_cast<uint32_t>(value), + writer)) { + return writer.status(); + } + return absl::OkStatus(); + } + if (size == 8) { + if (!riegeli::WriteBigEndian<uint64_t>(value, writer)) { + return writer.status(); + } + return absl::OkStatus(); + } + return absl::InvalidArgumentError(absl::StrCat("invalid byte_size: ", size)); +} + +} // namespace + +absl::StatusOr<IlocBox> ParseIloc(riegeli::Reader& reader, + const BmffBoxHeader& header) { + IlocBox box; + uint8_t offset_and_length_size; + if (!riegeli::ReadBigEndian<uint8_t>(reader, offset_and_length_size)) { + return absl::DataLossError("Failed to read offset and length size"); + } + box.offset_size = offset_and_length_size >> 4; + box.length_size = offset_and_length_size & 0xf; + + uint8_t base_offset_and_index_size; + if (!riegeli::ReadBigEndian<uint8_t>(reader, base_offset_and_index_size)) { + return absl::DataLossError("Failed to read base offset and index size"); + } + box.base_offset_size = base_offset_and_index_size >> 4; + box.index_size = base_offset_and_index_size & 0xf; + + uint32_t item_count = 0; + if (header.version < 2) { + uint16_t count; + if (!riegeli::ReadBigEndian<uint16_t>(reader, count)) { + return absl::DataLossError("Failed to read item count"); + } + item_count = count; + } else if (header.version == 2) { + if (!riegeli::ReadBigEndian<uint32_t>(reader, item_count)) { + return absl::DataLossError("Failed to read item count"); + } + } + + box.items.reserve(item_count); + for (uint32_t i = 0; i < item_count; ++i) { + IlocItem item; + if (header.version < 2) { + uint16_t item_id; + if (!riegeli::ReadBigEndian<uint16_t>(reader, item_id)) { + return absl::DataLossError("Failed to read item ID"); + } + item.item_id = item_id; + } else if (header.version == 2) { + if (!riegeli::ReadBigEndian<uint32_t>(reader, item.item_id)) { + return absl::DataLossError("Failed to read item ID"); + } + } + + if (header.version == 1 || header.version == 2) { + if (!riegeli::ReadBigEndian<uint16_t>(reader, item.construction_method)) { + return absl::DataLossError( + "Failed to read reserved and construction method"); + } + } + + if (!riegeli::ReadBigEndian<uint16_t>(reader, item.data_reference_index)) { + return absl::DataLossError("Failed to read data reference index"); + } + + ABSL_ASSIGN_OR_RETURN(item.base_offset, + ReadVariableSize(reader, box.base_offset_size)); + + uint16_t extent_count; + if (!riegeli::ReadBigEndian<uint16_t>(reader, extent_count)) { + return absl::DataLossError("Failed to read extent count"); + } + + item.extents.reserve(extent_count); + for (int j = 0; j < extent_count; ++j) { + IlocExtent extent; + if ((header.version == 1 || header.version == 2) && box.index_size > 0) { + ABSL_ASSIGN_OR_RETURN(extent.index, + ReadVariableSize(reader, box.index_size)); + } + ABSL_ASSIGN_OR_RETURN(extent.offset, + ReadVariableSize(reader, box.offset_size)); + ABSL_ASSIGN_OR_RETURN(extent.length, + ReadVariableSize(reader, box.length_size)); + item.extents.push_back(extent); + } + box.items.push_back(item); + } + return box; +} + +absl::Status SerializeIloc(const IlocBox& box, const BmffBoxHeader& header, + riegeli::Writer& writer) { + uint8_t offset_and_length_size = (box.offset_size << 4) | box.length_size; + if (!riegeli::WriteBigEndian<uint8_t>(offset_and_length_size, writer)) { + return writer.status(); + } + + uint8_t base_offset_and_index_size = + (box.base_offset_size << 4) | box.index_size; + if (!riegeli::WriteBigEndian<uint8_t>(base_offset_and_index_size, writer)) { + return writer.status(); + } + + if (header.version < 2) { + if (box.items.size() > 0xffff) { + return absl::InvalidArgumentError("Too many items for version < 2"); + } + if (!riegeli::WriteBigEndian<uint16_t>( + static_cast<uint16_t>(box.items.size()), writer)) { + return writer.status(); + } + } else if (header.version == 2) { + if (!riegeli::WriteBigEndian<uint32_t>( + static_cast<uint32_t>(box.items.size()), writer)) { + return writer.status(); + } + } + + for (const auto& item : box.items) { + if (header.version < 2) { + if (item.item_id > 0xffff) { + return absl::InvalidArgumentError("Item ID too large for version < 2"); + } + if (!riegeli::WriteBigEndian<uint16_t>( + static_cast<uint16_t>(item.item_id), writer)) { + return writer.status(); + } + } else if (header.version == 2) { + if (!riegeli::WriteBigEndian<uint32_t>(item.item_id, writer)) { + return writer.status(); + } + } + + if (header.version == 1 || header.version == 2) { + if (!riegeli::WriteBigEndian<uint16_t>(item.construction_method, + writer)) { + return writer.status(); + } + } + + if (!riegeli::WriteBigEndian<uint16_t>(item.data_reference_index, writer)) { + return writer.status(); + } + + ABSL_RETURN_IF_ERROR( + WriteVariableSize(item.base_offset, box.base_offset_size, writer)); + + if (item.extents.size() > 0xffff) { + return absl::InvalidArgumentError("Too many extents"); + } + if (!riegeli::WriteBigEndian<uint16_t>( + static_cast<uint16_t>(item.extents.size()), writer)) { + return writer.status(); + } + + for (const auto& extent : item.extents) { + if ((header.version == 1 || header.version == 2) && box.index_size > 0) { + ABSL_RETURN_IF_ERROR( + WriteVariableSize(extent.index, box.index_size, writer)); + } + ABSL_RETURN_IF_ERROR( + WriteVariableSize(extent.offset, box.offset_size, writer)); + ABSL_RETURN_IF_ERROR( + WriteVariableSize(extent.length, box.length_size, writer)); + } + } + return absl::OkStatus(); +} + +void AdjustOffsets(IlocBox& box, int64_t adjustment) { + for (auto& item : box.items) { + if (item.data_reference_index == 0) { + uint16_t method = item.construction_method & 0xf; + if (method == 0) { + if (box.base_offset_size > 0) { + // The `extent.offset` is a relative offset. We must add it to the + // base_offset to find the absolute position of the data. + item.base_offset += adjustment; + } else if (box.offset_size > 0) { + // The `extent.offset` is an absolute offset. We must add the + // adjustment to it directly. + for (auto& extent : item.extents) { + extent.offset += adjustment; + } + } + } + } + } +} + +absl::StatusOr<StcoBox> ParseStco(riegeli::Reader& reader) { + StcoBox box; + uint32_t entry_count; + if (!riegeli::ReadBigEndian<uint32_t>(reader, entry_count)) { + return absl::DataLossError("Failed to read entry count"); + } + box.offsets.reserve(entry_count); + for (uint32_t i = 0; i < entry_count; ++i) { + uint32_t offset; + if (!riegeli::ReadBigEndian<uint32_t>(reader, offset)) { + return absl::DataLossError("Failed to read offset"); + } + box.offsets.push_back(offset); + } + return box; +} + +absl::Status SerializeStco(const StcoBox& box, riegeli::Writer& writer) { + if (box.offsets.size() > 0xffffffff) { + return absl::InvalidArgumentError("Too many offsets"); + } + if (!riegeli::WriteBigEndian<uint32_t>( + static_cast<uint32_t>(box.offsets.size()), writer)) { + return writer.status(); + } + for (uint32_t offset : box.offsets) { + if (!riegeli::WriteBigEndian<uint32_t>(offset, writer)) { + return writer.status(); + } + } + return absl::OkStatus(); +} + +void AdjustOffsets(StcoBox& box, int64_t adjustment) { + for (auto& offset : box.offsets) { + offset += adjustment; + } +} + +absl::StatusOr<Co64Box> ParseCo64(riegeli::Reader& reader) { + Co64Box box; + uint32_t entry_count; + if (!riegeli::ReadBigEndian<uint32_t>(reader, entry_count)) { + return absl::DataLossError("Failed to read entry count"); + } + box.offsets.reserve(entry_count); + for (uint32_t i = 0; i < entry_count; ++i) { + uint64_t offset; + if (!riegeli::ReadBigEndian<uint64_t>(reader, offset)) { + return absl::DataLossError("Failed to read offset"); + } + box.offsets.push_back(offset); + } + return box; +} + +absl::Status SerializeCo64(const Co64Box& box, riegeli::Writer& writer) { + if (box.offsets.size() > 0xffffffff) { + return absl::InvalidArgumentError("Too many offsets"); + } + if (!riegeli::WriteBigEndian<uint32_t>( + static_cast<uint32_t>(box.offsets.size()), writer)) { + return writer.status(); + } + for (uint64_t offset : box.offsets) { + if (!riegeli::WriteBigEndian<uint64_t>(offset, writer)) { + return writer.status(); + } + } + return absl::OkStatus(); +} + +void AdjustOffsets(Co64Box& box, int64_t adjustment) { + for (auto& offset : box.offsets) { + offset += adjustment; + } +} + +absl::StatusOr<SaioBox> ParseSaio(riegeli::Reader& reader, + const BmffBoxHeader& header) { + SaioBox box; + if ((header.flags[2] & 1) == 1) { + if (!riegeli::ReadBigEndian<uint32_t>(reader, box.aux_info_type)) { + return absl::DataLossError("Failed to read aux_info_type"); + } + if (!riegeli::ReadBigEndian<uint32_t>(reader, + box.aux_info_type_parameter)) { + return absl::DataLossError("Failed to read aux_info_type_parameter"); + } + } + + uint32_t entry_count; + if (!riegeli::ReadBigEndian<uint32_t>(reader, entry_count)) { + return absl::DataLossError("Failed to read entry count"); + } + + box.offsets.reserve(entry_count); + for (uint32_t i = 0; i < entry_count; ++i) { + if (header.version == 0) { + uint32_t offset; + if (!riegeli::ReadBigEndian<uint32_t>(reader, offset)) { + return absl::DataLossError("Failed to read offset"); + } + box.offsets.push_back(offset); + } else { + uint64_t offset; + if (!riegeli::ReadBigEndian<uint64_t>(reader, offset)) { + return absl::DataLossError("Failed to read offset"); + } + box.offsets.push_back(offset); + } + } + return box; +} + +absl::Status SerializeSaio(const SaioBox& box, const BmffBoxHeader& header, + riegeli::Writer& writer) { + if ((header.flags[2] & 1) == 1) { + if (!riegeli::WriteBigEndian<uint32_t>(box.aux_info_type, writer)) { + return writer.status(); + } + if (!riegeli::WriteBigEndian<uint32_t>(box.aux_info_type_parameter, + writer)) { + return writer.status(); + } + } + + if (box.offsets.size() > 0xffffffff) { + return absl::InvalidArgumentError("Too many offsets"); + } + if (!riegeli::WriteBigEndian<uint32_t>( + static_cast<uint32_t>(box.offsets.size()), writer)) { + return writer.status(); + } + + for (uint64_t offset : box.offsets) { + if (header.version == 0) { + if (offset > 0xffffffff) { + return absl::InvalidArgumentError("Offset too large for version 0"); + } + if (!riegeli::WriteBigEndian<uint32_t>(static_cast<uint32_t>(offset), + writer)) { + return writer.status(); + } + } else { + if (!riegeli::WriteBigEndian<uint64_t>(offset, writer)) { + return writer.status(); + } + } + } + return absl::OkStatus(); +} + +void AdjustOffsets(SaioBox& box, int64_t adjustment) { + for (auto& offset : box.offsets) { + offset += adjustment; + } +} + +} // namespace credentio
diff --git a/formats/bmff/offset_adjustment.h b/formats/bmff/offset_adjustment.h new file mode 100644 index 0000000..ae3ada8 --- /dev/null +++ b/formats/bmff/offset_adjustment.h
@@ -0,0 +1,111 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_OFFSET_ADJUSTMENT_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_OFFSET_ADJUSTMENT_H_ + +#include <cstdint> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "formats/bmff/box_header.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +struct IlocExtent { + uint64_t index = 0; + uint64_t offset = 0; + uint64_t length = 0; +}; + +struct IlocItem { + uint32_t item_id = 0; + uint16_t construction_method = 0; // Present if version 1 or 2 + uint16_t data_reference_index = 0; + uint64_t base_offset = 0; + std::vector<IlocExtent> extents; +}; + +struct IlocBox { + uint8_t offset_size = 0; + uint8_t length_size = 0; + uint8_t base_offset_size = 0; + uint8_t index_size = 0; + std::vector<IlocItem> items; +}; + +struct StcoBox { + std::vector<uint32_t> offsets; +}; + +struct Co64Box { + std::vector<uint64_t> offsets; +}; + +struct SaioBox { + uint32_t aux_info_type = 0; + uint32_t aux_info_type_parameter = 0; + std::vector<uint64_t> offsets; +}; + +// Parses the ILOC box payload from the reader. +// The reader should be positioned at the start of the box payload (after the +// header). +absl::StatusOr<IlocBox> ParseIloc(riegeli::Reader& reader, + const BmffBoxHeader& header); + +// Serializes the ILOC box payload to the writer. +absl::Status SerializeIloc(const IlocBox& box, const BmffBoxHeader& header, + riegeli::Writer& writer); + +// Adjusts the offsets in the ILOC box. +// Only extents with data_reference_index == 0 are adjusted. +void AdjustOffsets(IlocBox& box, int64_t adjustment); + +// Parses the STCO box payload from the reader. +absl::StatusOr<StcoBox> ParseStco(riegeli::Reader& reader); + +// Serializes the STCO box payload to the writer. +absl::Status SerializeStco(const StcoBox& box, riegeli::Writer& writer); + +// Adjusts the offsets in the STCO box. +void AdjustOffsets(StcoBox& box, int64_t adjustment); + +// Parses the CO64 box payload from the reader. +absl::StatusOr<Co64Box> ParseCo64(riegeli::Reader& reader); + +// Serializes the CO64 box payload to the writer. +absl::Status SerializeCo64(const Co64Box& box, riegeli::Writer& writer); + +// Adjusts the offsets in the CO64 box. +void AdjustOffsets(Co64Box& box, int64_t adjustment); + +// Parses the SAIO box payload from the reader. +absl::StatusOr<SaioBox> ParseSaio(riegeli::Reader& reader, + const BmffBoxHeader& header); + +// Serializes the SAIO box payload to the writer. +absl::Status SerializeSaio(const SaioBox& box, const BmffBoxHeader& header, + riegeli::Writer& writer); + +// Adjusts the offsets in the SAIO box. +void AdjustOffsets(SaioBox& box, int64_t adjustment); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_OFFSET_ADJUSTMENT_H_
diff --git a/formats/bmff/offset_adjustment_test.cc b/formats/bmff/offset_adjustment_test.cc new file mode 100644 index 0000000..eb512bc --- /dev/null +++ b/formats/bmff/offset_adjustment_test.cc
@@ -0,0 +1,510 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/bmff/offset_adjustment.h" + +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "formats/bmff/box_header.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "testing/test_string_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::credentio_testing::Uint16Str; +using ::credentio_testing::Uint32Str; +using ::credentio_testing::Uint64Str; +using ::credentio_testing::Uint8Str; +using ::testing::ElementsAre; +using ::testing::HasSubstr; + +TEST(OffsetAdjustmentTest, Stco) { + std::string data = absl::StrCat(Uint32Str(2), // entry_count + Uint32Str(100), // offset 1 + Uint32Str(200) // offset 2 + ); + + riegeli::StringReader reader(data); + auto box_or = ParseStco(reader); + ABSL_ASSERT_OK(box_or); + StcoBox box = *box_or; + EXPECT_THAT(box.offsets, ElementsAre(100, 200)); + + AdjustOffsets(box, 50); + EXPECT_THAT(box.offsets, ElementsAre(150, 250)); + + std::string serialized; + riegeli::StringWriter writer(&serialized); + ABSL_ASSERT_OK(SerializeStco(box, writer)); + writer.Close(); + + std::string expected_data = absl::StrCat(Uint32Str(2), // entry_count + Uint32Str(150), // offset 1 + Uint32Str(250) // offset 2 + ); + EXPECT_EQ(serialized, expected_data); +} + +TEST(OffsetAdjustmentTest, Co64) { + std::string data = absl::StrCat(Uint32Str(2), // entry_count + Uint64Str(100), // offset 1 + Uint64Str(200) // offset 2 + ); + + riegeli::StringReader reader(data); + auto box_or = ParseCo64(reader); + ABSL_ASSERT_OK(box_or); + Co64Box box = *box_or; + EXPECT_THAT(box.offsets, ElementsAre(100, 200)); + + AdjustOffsets(box, 50); + EXPECT_THAT(box.offsets, ElementsAre(150, 250)); + + std::string serialized; + riegeli::StringWriter writer(&serialized); + ABSL_ASSERT_OK(SerializeCo64(box, writer)); + writer.Close(); + + std::string expected_data = absl::StrCat(Uint32Str(2), // entry_count + Uint64Str(150), // offset 1 + Uint64Str(250) // offset 2 + ); + EXPECT_EQ(serialized, expected_data); +} + +TEST(OffsetAdjustmentTest, SaioVersion0NoAuxInfo) { + BmffBoxHeader header; + header.version = 0; + header.flags = std::string("\x00\x00\x00", 3); + + std::string data = absl::StrCat(Uint32Str(2), // entry_count + Uint32Str(100), // offset 1 + Uint32Str(200) // offset 2 + ); + + riegeli::StringReader reader(data); + auto box_or = ParseSaio(reader, header); + ABSL_ASSERT_OK(box_or); + SaioBox box = *box_or; + EXPECT_THAT(box.offsets, ElementsAre(100, 200)); + + AdjustOffsets(box, 50); + EXPECT_THAT(box.offsets, ElementsAre(150, 250)); + + std::string serialized; + riegeli::StringWriter writer(&serialized); + ABSL_ASSERT_OK(SerializeSaio(box, header, writer)); + writer.Close(); + + std::string expected_data = absl::StrCat(Uint32Str(2), // entry_count + Uint32Str(150), // offset 1 + Uint32Str(250) // offset 2 + ); + EXPECT_EQ(serialized, expected_data); +} + +TEST(OffsetAdjustmentTest, SaioVersion1WithAuxInfo) { + BmffBoxHeader header; + header.version = 1; + header.flags = std::string("\x00\x00\x01", 3); // flags & 1 + + std::string data = + absl::StrCat(Uint32Str(0x12345678), // aux_info_type + Uint32Str(0xabcdef01), // aux_info_type_parameter + Uint32Str(2), // entry_count + Uint64Str(100), // offset 1 + Uint64Str(200) // offset 2 + ); + + riegeli::StringReader reader(data); + auto box_or = ParseSaio(reader, header); + ABSL_ASSERT_OK(box_or); + SaioBox box = *box_or; + EXPECT_EQ(box.aux_info_type, 0x12345678); + EXPECT_EQ(box.aux_info_type_parameter, 0xabcdef01); + EXPECT_THAT(box.offsets, ElementsAre(100, 200)); + + AdjustOffsets(box, 50); + EXPECT_THAT(box.offsets, ElementsAre(150, 250)); + + std::string serialized; + riegeli::StringWriter writer(&serialized); + ABSL_ASSERT_OK(SerializeSaio(box, header, writer)); + writer.Close(); + + std::string expected_data = + absl::StrCat(Uint32Str(0x12345678), // aux_info_type + Uint32Str(0xabcdef01), // aux_info_type_parameter + Uint32Str(2), // entry_count + Uint64Str(150), // offset 1 + Uint64Str(250) // offset 2 + ); + EXPECT_EQ(serialized, expected_data); +} + +TEST(OffsetAdjustmentTest, IlocVersion0) { + BmffBoxHeader header; + header.version = 0; + + // offset_size = 4, length_size = 4 -> 0x44 + // base_offset_size = 4, index_size = 0 -> 0x40 + std::string data = + absl::StrCat(Uint8Str(0x44), // offset_size, length_size + Uint8Str(0x40), // base_offset_size, index_size + Uint16Str(1), // item_count + // Item 1 + Uint16Str(1), // item_id + Uint16Str(0), // data_reference_index + Uint32Str(1000), // base_offset + Uint16Str(2), // extent_count + // Extent 1 + Uint32Str(100), // offset + Uint32Str(20), // length + // Extent 2 + Uint32Str(200), // offset + Uint32Str(30) // length + ); + + riegeli::StringReader reader(data); + auto box_or = ParseIloc(reader, header); + ABSL_ASSERT_OK(box_or); + IlocBox box = *box_or; + EXPECT_EQ(box.offset_size, 4); + EXPECT_EQ(box.length_size, 4); + EXPECT_EQ(box.base_offset_size, 4); + EXPECT_EQ(box.index_size, 0); + ASSERT_EQ(box.items.size(), 1); + EXPECT_EQ(box.items[0].item_id, 1); + EXPECT_EQ(box.items[0].data_reference_index, 0); + EXPECT_EQ(box.items[0].base_offset, 1000); + ASSERT_EQ(box.items[0].extents.size(), 2); + EXPECT_EQ(box.items[0].extents[0].offset, 100); + EXPECT_EQ(box.items[0].extents[0].length, 20); + EXPECT_EQ(box.items[0].extents[1].offset, 200); + EXPECT_EQ(box.items[0].extents[1].length, 30); + + AdjustOffsets(box, 50); + EXPECT_EQ(box.items[0].base_offset, 1050); + EXPECT_EQ(box.items[0].extents[0].offset, 100); + EXPECT_EQ(box.items[0].extents[1].offset, 200); + + std::string serialized; + riegeli::StringWriter writer(&serialized); + ABSL_ASSERT_OK(SerializeIloc(box, header, writer)); + writer.Close(); + + std::string expected_data = + absl::StrCat(Uint8Str(0x44), // offset_size, length_size + Uint8Str(0x40), // base_offset_size, index_size + Uint16Str(1), // item_count + // Item 1 + Uint16Str(1), // item_id + Uint16Str(0), // data_reference_index + Uint32Str(1050), // base_offset + Uint16Str(2), // extent_count + // Extent 1 + Uint32Str(100), // offset + Uint32Str(20), // length + // Extent 2 + Uint32Str(200), // offset + Uint32Str(30) // length + ); + EXPECT_EQ(serialized, expected_data); +} + +TEST(OffsetAdjustmentTest, IlocVersion1WithIndex) { + BmffBoxHeader header; + header.version = 1; + + // offset_size = 4, length_size = 4 -> 0x44 + // base_offset_size = 4, index_size = 4 -> 0x44 + std::string data = + absl::StrCat(Uint8Str(0x44), // offset_size, length_size + Uint8Str(0x44), // base_offset_size, index_size + Uint16Str(1), // item_count + // Item 1 + Uint16Str(1), // item_id + Uint16Str(0x1230), // reserved_and_construction_method + Uint16Str(0), // data_reference_index + Uint32Str(1000), // base_offset + Uint16Str(1), // extent_count + // Extent 1 + Uint32Str(5), // index + Uint32Str(100), // offset + Uint32Str(20) // length + ); + + riegeli::StringReader reader(data); + auto box_or = ParseIloc(reader, header); + ABSL_ASSERT_OK(box_or); + IlocBox box = *box_or; + EXPECT_EQ(box.offset_size, 4); + EXPECT_EQ(box.length_size, 4); + EXPECT_EQ(box.base_offset_size, 4); + EXPECT_EQ(box.index_size, 4); + ASSERT_EQ(box.items.size(), 1); + EXPECT_EQ(box.items[0].item_id, 1); + EXPECT_EQ(box.items[0].construction_method, 0x1230); + EXPECT_EQ(box.items[0].data_reference_index, 0); + EXPECT_EQ(box.items[0].base_offset, 1000); + ASSERT_EQ(box.items[0].extents.size(), 1); + EXPECT_EQ(box.items[0].extents[0].index, 5); + EXPECT_EQ(box.items[0].extents[0].offset, 100); + EXPECT_EQ(box.items[0].extents[0].length, 20); + + AdjustOffsets(box, 50); + EXPECT_EQ(box.items[0].base_offset, 1050); + EXPECT_EQ(box.items[0].extents[0].offset, 100); + + std::string serialized; + riegeli::StringWriter writer(&serialized); + ABSL_ASSERT_OK(SerializeIloc(box, header, writer)); + writer.Close(); + + std::string expected_data = + absl::StrCat(Uint8Str(0x44), // offset_size, length_size + Uint8Str(0x44), // base_offset_size, index_size + Uint16Str(1), // item_count + // Item 1 + Uint16Str(1), // item_id + Uint16Str(0x1230), // reserved_and_construction_method + Uint16Str(0), // data_reference_index + Uint32Str(1050), // base_offset + Uint16Str(1), // extent_count + // Extent 1 + Uint32Str(5), // index + Uint32Str(100), // offset + Uint32Str(20) // length + ); + EXPECT_EQ(serialized, expected_data); +} + +TEST(OffsetAdjustmentTest, IlocVersion2LargeSizes) { + BmffBoxHeader header; + header.version = 2; + + // offset_size = 8, length_size = 8 -> 0x88 + // base_offset_size = 8, index_size = 8 -> 0x88 + std::string data = + absl::StrCat(Uint8Str(0x88), // offset_size, length_size + Uint8Str(0x88), // base_offset_size, index_size + Uint32Str(1), // item_count (32-bit in v2) + // Item 1 + Uint32Str(1), // item_id (32-bit in v2) + Uint16Str(0x1230), // reserved_and_construction_method + Uint16Str(0), // data_reference_index + Uint64Str(1000), // base_offset + Uint16Str(1), // extent_count + // Extent 1 + Uint64Str(5), // index + Uint64Str(100), // offset + Uint64Str(20) // length + ); + + riegeli::StringReader reader(data); + auto box_or = ParseIloc(reader, header); + ABSL_ASSERT_OK(box_or); + IlocBox box = *box_or; + EXPECT_EQ(box.offset_size, 8); + EXPECT_EQ(box.length_size, 8); + EXPECT_EQ(box.base_offset_size, 8); + EXPECT_EQ(box.index_size, 8); + ASSERT_EQ(box.items.size(), 1); + EXPECT_EQ(box.items[0].item_id, 1); + EXPECT_EQ(box.items[0].construction_method, 0x1230); + EXPECT_EQ(box.items[0].data_reference_index, 0); + EXPECT_EQ(box.items[0].base_offset, 1000); + ASSERT_EQ(box.items[0].extents.size(), 1); + EXPECT_EQ(box.items[0].extents[0].index, 5); + EXPECT_EQ(box.items[0].extents[0].offset, 100); + EXPECT_EQ(box.items[0].extents[0].length, 20); + + AdjustOffsets(box, 50); + EXPECT_EQ(box.items[0].base_offset, 1050); + EXPECT_EQ(box.items[0].extents[0].offset, 100); + + std::string serialized; + riegeli::StringWriter writer(&serialized); + ABSL_ASSERT_OK(SerializeIloc(box, header, writer)); + writer.Close(); + + std::string expected_data = + absl::StrCat(Uint8Str(0x88), // offset_size, length_size + Uint8Str(0x88), // base_offset_size, index_size + Uint32Str(1), // item_count (32-bit in v2) + // Item 1 + Uint32Str(1), // item_id (32-bit in v2) + Uint16Str(0x1230), // reserved_and_construction_method + Uint16Str(0), // data_reference_index + Uint64Str(1050), // base_offset + Uint16Str(1), // extent_count + // Extent 1 + Uint64Str(5), // index + Uint64Str(100), // offset + Uint64Str(20) // length + ); + EXPECT_EQ(serialized, expected_data); +} + +TEST(OffsetAdjustmentTest, IlocNonZeroDataReferenceIndexNotAdjusted) { + BmffBoxHeader header; + header.version = 0; + + std::string data = + absl::StrCat(Uint8Str(0x44), // offset_size, length_size + Uint8Str(0x40), // base_offset_size, index_size + Uint16Str(1), // item_count + // Item 1 + Uint16Str(1), // item_id + Uint16Str(1), // data_reference_index (Non-zero) + Uint32Str(1000), // base_offset + Uint16Str(1), // extent_count + // Extent 1 + Uint32Str(100), // offset + Uint32Str(20) // length + ); + + riegeli::StringReader reader(data); + auto box_or = ParseIloc(reader, header); + ABSL_ASSERT_OK(box_or); + IlocBox box = *box_or; + AdjustOffsets(box, 50); + // Offset should NOT be adjusted because data_reference_index != 0 + EXPECT_EQ(box.items[0].extents[0].offset, 100); +} + +TEST(OffsetAdjustmentTest, IlocZeroBaseOffsetSize) { + BmffBoxHeader header; + header.version = 0; + + // offset_size = 4, length_size = 4 -> 0x44 + // base_offset_size = 0, index_size = 0 -> 0x00 + std::string data = + absl::StrCat(Uint8Str(0x44), // offset_size, length_size + Uint8Str(0x00), // base_offset_size, index_size + Uint16Str(1), // item_count + // Item 1 + Uint16Str(1), // item_id + Uint16Str(0), // data_reference_index + Uint16Str(1), // extent_count + // Extent 1 + Uint32Str(100), // offset + Uint32Str(20) // length + ); + + riegeli::StringReader reader(data); + auto box_or = ParseIloc(reader, header); + ABSL_ASSERT_OK(box_or); + IlocBox box = *box_or; + AdjustOffsets(box, 50); + // Extent offset should be adjusted because base_offset_size == 0 + EXPECT_EQ(box.items[0].extents[0].offset, 150); +} + +TEST(OffsetAdjustmentTest, IlocZeroOffsetSize) { + BmffBoxHeader header; + header.version = 0; + + // offset_size = 0, length_size = 4 -> 0x04 + // base_offset_size = 4, index_size = 0 -> 0x40 + std::string data = + absl::StrCat(Uint8Str(0x04), // offset_size, length_size + Uint8Str(0x40), // base_offset_size, index_size + Uint16Str(1), // item_count + // Item 1 + Uint16Str(1), // item_id + Uint16Str(0), // data_reference_index + Uint32Str(1000), // base_offset + Uint16Str(1), // extent_count + // Extent 1 + Uint32Str(20) // length (No offset) + ); + + riegeli::StringReader reader(data); + auto box_or = ParseIloc(reader, header); + ABSL_ASSERT_OK(box_or); + IlocBox box = *box_or; + AdjustOffsets(box, 50); + // Base offset should be adjusted + EXPECT_EQ(box.items[0].base_offset, 1050); +} + +TEST(OffsetAdjustmentTest, IlocNonZeroConstructionMethodNotAdjusted) { + BmffBoxHeader header; + header.version = 1; + + // offset_size = 4, length_size = 4 -> 0x44 + // base_offset_size = 4, index_size = 0 -> 0x40 + std::string data = + absl::StrCat(Uint8Str(0x44), // offset_size, length_size + Uint8Str(0x40), // base_offset_size, index_size + Uint16Str(1), // item_count + // Item 1 + Uint16Str(1), // item_id + Uint16Str(0x0001), // reserved (0), method (1) + Uint16Str(0), // data_reference_index + Uint32Str(1000), // base_offset + Uint16Str(1), // extent_count + // Extent 1 + Uint32Str(100), // offset + Uint32Str(20) // length + ); + + riegeli::StringReader reader(data); + auto box_or = ParseIloc(reader, header); + ABSL_ASSERT_OK(box_or); + IlocBox box = *box_or; + AdjustOffsets(box, 50); + // Offset should NOT be adjusted because construction_method != 0 + EXPECT_EQ(box.items[0].base_offset, 1000); + EXPECT_EQ(box.items[0].extents[0].offset, 100); +} + +TEST(OffsetAdjustmentTest, IlocSerializeInvalidSize) { + BmffBoxHeader header; + header.version = 0; + + IlocBox box; + box.offset_size = 4; + box.length_size = 4; + box.base_offset_size = 1; // Invalid size + box.index_size = 0; + box.items.resize(1); + box.items[0].item_id = 1; + box.items[0].data_reference_index = 0; + box.items[0].base_offset = 1000; + box.items[0].extents.resize(1); + box.items[0].extents[0].offset = 100; + box.items[0].extents[0].length = 20; + + std::string serialized; + riegeli::StringWriter writer(&serialized); + EXPECT_THAT(SerializeIloc(box, header, writer), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("invalid byte_size: 1"))); + writer.Close(); +} + +} // namespace +} // namespace credentio
diff --git a/formats/bmff/writer.cc b/formats/bmff/writer.cc new file mode 100644 index 0000000..3581109 --- /dev/null +++ b/formats/bmff/writer.cc
@@ -0,0 +1,521 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/bmff/writer.h" + +#include <algorithm> +#include <cstddef> +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "async/async_context.h" +#include "crypto/algorithms.h" +#include "formats/bmff/box_header.h" +#include "formats/bmff/embedder.h" +#include "formats/bmff/hasher.h" +#include "formats/byte_range.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/bytes/writer.h" +#include "utils/byte_instruction.h" + +namespace credentio { +namespace { + +constexpr int64_t kCreateFreeBoxThreshold = 30; + +// Scans through the source, modifying the source's values to adjust offsets to +// account for the manifest size as needed and hashing the applicable bytes. +// The method will return the offset where the manifest store should be +// inserted, the length of the manifest store, and an ordered vector of byte +// ranges of atoms which require offset adjustment before being written to the +// destination. +absl::StatusOr<std::vector<ByteInstruction>> InspectAndHash( + riegeli::Reader& source, uint64_t manifest_store_size, + const ByteRange& insertion_point, bool can_replace_manifest, + BmffBasedHashAssertion& assertion) { + if (!source.Seek(0) || source.pos() != 0) { + return absl::InvalidArgumentError("cannot seek to start of input"); + } + BmffEmbedder embedder; + ABSL_ASSIGN_OR_RETURN( + std::vector<ByteInstruction> instructions, + embedder.InspectAndHash(source, manifest_store_size, insertion_point, + can_replace_manifest, assertion)); + return std::move(instructions); +} + +// Wraps the manifest store in an atom. +absl::StatusOr<std::string> WrapManifestStoreInAtom( + absl::string_view manifest_store) { + std::string atom; + riegeli::StringWriter writer(&atom); + ABSL_RETURN_IF_ERROR(WriteC2paBmff(manifest_store, writer)); + if (!writer.Close()) { + return absl::InternalError("failed to close string writer"); + } + return std::move(atom); +} + +absl::Status GenerateManifestStoreInstructions( + const ByteRange& insertion_point, absl::string_view manifest_store, + std::vector<ByteInstruction>& byte_instructions, + bool client_supplied_hard_binding = false) { + // Wrap the manifest store in an atom. + ABSL_ASSIGN_OR_RETURN(std::string manifest_atom, + WrapManifestStoreInAtom(manifest_store)); + + if (client_supplied_hard_binding && + insertion_point.length < manifest_atom.size()) { + return absl::ResourceExhaustedError(absl::StrCat( + "not enough space to embed C2PA manifest with client-supplied hard " + "binding (insertion_point.length=", + insertion_point.length, ", manifest_atom.size=", manifest_atom.size(), + "); skipping embedding to avoid byte shifting")); + } + + if (insertion_point.length == 0) { + // No overwritable bytes, only insert. + byte_instructions.push_back( + {.operation = ByteInstruction::Operation::kInsert, + .offset = insertion_point.offset, + .bytes = std::move(manifest_atom)}); + return absl::OkStatus(); + } + + if (insertion_point.length == manifest_atom.size()) { + // The atom fits exactly, just overwrite. + byte_instructions.push_back( + {.operation = ByteInstruction::Operation::kReplace, + .offset = insertion_point.offset, + .bytes = std::move(manifest_atom)}); + return absl::OkStatus(); + } + + if (insertion_point.length < manifest_atom.size()) { + // Not enough space, overwrite the initial data, insert the + // remaining. + byte_instructions.push_back( + {.operation = ByteInstruction::Operation::kReplace, + .offset = insertion_point.offset, + .bytes = manifest_atom.substr(0, insertion_point.length)}); + byte_instructions.push_back( + {.operation = ByteInstruction::Operation::kInsert, + .offset = insertion_point.offset + insertion_point.length, + .bytes = manifest_atom.substr(insertion_point.length)}); + return absl::OkStatus(); + } + + // More than enough space + int64_t manifest_size = manifest_atom.size(); + int64_t padding_size = insertion_point.length - manifest_size; + + if (padding_size < kCreateFreeBoxThreshold) { + // Minor amount of remaining space, just pad with \0. + ABSL_ASSIGN_OR_RETURN( + manifest_atom, WrapManifestStoreInAtom(absl::StrCat( + manifest_store, std::string(padding_size, '\0')))); + byte_instructions.push_back( + {.operation = ByteInstruction::Operation::kReplace, + .offset = insertion_point.offset, + .bytes = std::move(manifest_atom)}); + return absl::OkStatus(); + } + + // Significant amount of remaining space, create a free box. + std::string free_box; + riegeli::StringWriter<> free_box_writer(&free_box); + ABSL_RETURN_IF_ERROR(CreateFreeBox(padding_size, free_box_writer)); + if (!free_box_writer.Close()) { + return absl::InternalError("failed to close string writer"); + } + + byte_instructions.push_back( + {.operation = ByteInstruction::Operation::kReplace, + .offset = insertion_point.offset, + .bytes = std::move(manifest_atom)}); + byte_instructions.push_back( + {.operation = ByteInstruction::Operation::kReplace, + .offset = insertion_point.offset + manifest_size, + .bytes = std::move(free_box)}); + + return absl::OkStatus(); +} + +absl::StatusOr<bool> CanReplaceManifest( + const GenerateManifestStoreRequest& request) { + if (!request.has_actions()) { + return absl::InvalidArgumentError("request must include `actions`"); + } + if (request.actions().inception_action_case() == + Actions::INCEPTION_ACTION_NOT_SET) { + return absl::InvalidArgumentError( + "request must include an inception action (created or opened)"); + } + return request.actions().has_opened(); +} + +} // namespace + +absl::Status BmffWriter::Write( + GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata) const { + absl::Notification done; + absl::Status status; + Write(std::move(request), source, destination, output_metadata, + AsyncContext(), [&](absl::Status s) { + status = std::move(s); + done.Notify(); + }); + done.WaitForNotification(); + return status; +} + +void BmffWriter::Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata, + AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb) const { + if (!source->SupportsRandomAccess()) { + AsyncResolver<void>(std::move(context), std::move(cb)) + .Return(absl::InvalidArgumentError( + "source does not support random access")); + return; + } + + auto byte_instructions = std::make_shared<std::vector<ByteInstruction>>(); + + WriteInternal( + std::move(request), source, output_metadata, byte_instructions, context, + [resolver = AsyncResolver<void>(context, std::move(cb)), source, + byte_instructions, destination](absl::Status status) mutable { + if (!status.ok()) { + resolver.Return(std::move(status)); + return; + } + status = ApplyByteInstructions(source, *byte_instructions, destination); + resolver.Return(std::move(status)); + }); +} + +absl::Status BmffWriter::Write( + GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset, + OutputMetadata* absl_nullable output_metadata) const { + absl::Notification done; + absl::Status status; + Write(std::move(request), asset, output_metadata, AsyncContext(), + [&](absl::Status s) { + status = std::move(s); + done.Notify(); + }); + done.WaitForNotification(); + return status; +} + +void BmffWriter::Write(GenerateManifestStoreRequest request, + riegeli::Writer* absl_nonnull asset, + OutputMetadata* absl_nullable output_metadata, + AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb) const { + if (!asset->SupportsRandomAccess()) { + AsyncResolver<void>(std::move(context), std::move(cb)) + .Return( + absl::InvalidArgumentError("asset does not support random access")); + return; + } + if (!asset->SupportsReadMode()) { + AsyncResolver<void>(std::move(context), std::move(cb)) + .Return(absl::InvalidArgumentError("asset does not support read mode")); + return; + } + if (request.options().has_max_bytes_to_shift() && + request.options().max_bytes_to_shift() < 0) { + AsyncResolver<void>(std::move(context), std::move(cb)) + .Return(absl::InvalidArgumentError( + "max_bytes_to_shift must be non-negative")); + return; + } + + auto options = request.options(); + riegeli::Reader* source = asset->ReadMode(0); + auto byte_instructions = std::make_shared<std::vector<ByteInstruction>>(); + + WriteInternal( + std::move(request), source, output_metadata, byte_instructions, context, + [resolver = AsyncResolver<void>(context, std::move(cb)), asset, + options = std::move(options), + byte_instructions](absl::Status status) mutable { + if (!status.ok()) { + resolver.Return(std::move(status)); + return; + } + + std::optional<size_t> insert_idx; + auto first_insert_it = std::find_if( + byte_instructions->begin(), byte_instructions->end(), + [](const ByteInstruction& instr) { + return instr.operation == ByteInstruction::Operation::kInsert; + }); + if (first_insert_it != byte_instructions->end()) { + insert_idx = first_insert_it - byte_instructions->begin(); + } + + auto file_size = asset->Size(); + uint64_t bytes_shifted = 0; + if (file_size.has_value() && insert_idx.has_value() && + *file_size > (*byte_instructions)[*insert_idx].offset) { + bytes_shifted = *file_size - (*byte_instructions)[*insert_idx].offset; + } + + if (options.has_max_bytes_to_shift() && + bytes_shifted > + static_cast<uint64_t>(options.max_bytes_to_shift())) { + uint64_t overwritable_bytes = 0; + if (*insert_idx > 0) { + const auto& prev_instruction = + (*byte_instructions)[*insert_idx - 1]; + if (prev_instruction.operation == + ByteInstruction::Operation::kReplace && + prev_instruction.offset + prev_instruction.bytes.size() == + (*byte_instructions)[*insert_idx].offset) { + overwritable_bytes = prev_instruction.bytes.size(); + } + } + uint64_t manifest_atom_size = + overwritable_bytes + + (*byte_instructions)[*insert_idx].bytes.size(); + resolver.Return(absl::InvalidArgumentError(absl::StrCat( + "embedding requires shifting ", bytes_shifted, + " bytes, which exceeds the limit of ", + options.max_bytes_to_shift(), + " bytes (pre-allocated manifest size: ", overwritable_bytes, + " bytes, final manifest size: ", manifest_atom_size, " bytes)"))); + return; + } + + if (!asset->Seek(0)) { + resolver.Return( + absl::InternalError("failed to seek to start of asset")); + return; + } + status = ApplyByteInstructions(*byte_instructions, asset); + resolver.Return(std::move(status)); + }); +} + +void BmffWriter::WriteInternal( + GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source, + OutputMetadata* absl_nullable output_metadata, + std::shared_ptr<std::vector<ByteInstruction>> byte_instructions, + AsyncContext context, absl::AnyInvocable<void(absl::Status) &&> cb) const { + AsyncResolver<void> resolver(context, std::move(cb)); + if (source->pos() != 0) { + resolver.Return(absl::InvalidArgumentError( + "source must be positioned at the start of the file")); + return; + } + auto can_replace_manifest = CanReplaceManifest(request); + if (!can_replace_manifest.ok()) { + resolver.Return( + absl::Status(can_replace_manifest.status().code(), + absl::StrCat(can_replace_manifest.status().message(), + "; verifying request has valid actions"))); + return; + } + + if (request.has_hard_binding() && + !request.hard_binding().has_bmff_based_hash()) { + resolver.Return(absl::InvalidArgumentError( + "request `hard_binding` must be a `bmff_based_hash` assertion")); + return; + } + + auto insertion_point = LocateManifestInsertionPoint(*source); + if (!insertion_point.ok()) { + resolver.Return( + absl::Status(insertion_point.status().code(), + absl::StrCat(insertion_point.status().message(), + "; locating manifest insertion point"))); + return; + } + + if (request.has_hard_binding()) { + generator_.GenerateManifestStore( + std::move(request), context, + [output_metadata, byte_instructions, insertion_point = *insertion_point, + resolver = std::move(resolver)]( + absl::StatusOr<GenerateManifestStoreResponse> response) mutable { + if (!response.ok()) { + resolver.Return(response.status()); + return; + } + + constexpr int64_t kMaxManifestStoreSizeBytes = + 100 * 1024 * 1024; // 100 MiB + if (response->manifest_store().empty() || + response->manifest_store().size() > kMaxManifestStoreSizeBytes) { + resolver.Return(absl::InvalidArgumentError( + absl::StrCat("invalid manifest store size: ", + response->manifest_store().size()))); + return; + } + + // Add the byte instructions for the manifest store atom to the + // result. + auto gen_status = GenerateManifestStoreInstructions( + insertion_point, response->manifest_store(), *byte_instructions, + /*client_supplied_hard_binding=*/true); + if (!gen_status.ok()) { + resolver.Return(std::move(gen_status)); + return; + } + + // Sort all byte instructions by offset. + std::sort(byte_instructions->begin(), byte_instructions->end(), + [](const ByteInstruction& a, const ByteInstruction& b) { + return a.offset < b.offset; + }); + + if (output_metadata != nullptr) { + output_metadata->timestamp_included = response->is_timestamped(); + } + resolver.Return(absl::OkStatus()); + }); + return; + } + + // Step 1: Estimate the manifest size + *request.mutable_hard_binding()->mutable_bmff_based_hash() = + GetBmffHashTemplate(HashAlgorithm::kSha256); + + if (options_.exclusion_processor != nullptr) { + auto status = options_.exclusion_processor(*source, request); + if (!status.ok()) { + resolver.Return(std::move(status)); + return; + } + } + + generator_.GenerateManifestStoreTemplate( + request, context, + [this, source, request, can_replace_manifest = *can_replace_manifest, + output_metadata, byte_instructions, insertion_point = *insertion_point, + context, resolver = std::move(resolver)]( + absl::StatusOr<ManifestStoreTemplate> template_response) mutable { + if (!template_response.ok()) { + resolver.Return(template_response.status()); + return; + } + + // Step 2: Construct the final generated manifest store request. + constexpr int64_t kMaxManifestStoreSizeBytes = + 100 * 1024 * 1024; // 100 MiB + if (template_response->manifest_store_size() <= 0 || + template_response->manifest_store_size() > + kMaxManifestStoreSizeBytes) { + resolver.Return(absl::InvalidArgumentError( + absl::StrCat("invalid manifest store template size: ", + template_response->manifest_store_size()))); + return; + } + + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + std::move(*template_response); + *finalize_request.mutable_hard_binding()->mutable_bmff_based_hash() = + request.hard_binding().bmff_based_hash(); + + // Step 3: Determine where the manifest should be inserted, where atoms + // which need adjustment are located, and compute the hash of the + // applicable bytes. + auto inspect_res = InspectAndHash( + *source, + finalize_request.manifest_store_template().manifest_store_size(), + insertion_point, can_replace_manifest, + *finalize_request.mutable_hard_binding() + ->mutable_bmff_based_hash()); + if (!inspect_res.ok()) { + resolver.Return(absl::Status( + inspect_res.status().code(), + absl::StrCat(inspect_res.status().message(), + "; embedding manifest store template"))); + return; + } + *byte_instructions = std::move(*inspect_res); + + // Step 4: Generate the finalized manifest store. + const size_t expected_manifest_store_size = + finalize_request.manifest_store_template().manifest_store_size(); + + generator_.FinalizeManifestStore( + std::move(finalize_request), context, + [expected_manifest_store_size, insertion_point, output_metadata, + byte_instructions, resolver = std::move(resolver)]( + absl::StatusOr<FinalizeManifestStoreResponse> + response) mutable { + if (!response.ok()) { + resolver.Return(response.status()); + return; + } + + if (response->manifest_store().size() != + expected_manifest_store_size) { + resolver.Return(absl::InternalError( + "manifest store size does not match template")); + return; + } + + // Add the byte instructions for the manifest store atom to the + // result. + auto gen_status = GenerateManifestStoreInstructions( + insertion_point, response->manifest_store(), + *byte_instructions); + if (!gen_status.ok()) { + resolver.Return(std::move(gen_status)); + return; + } + + // Sort all byte instructions by offset. + std::sort(byte_instructions->begin(), byte_instructions->end(), + [](const ByteInstruction& a, const ByteInstruction& b) { + return a.offset < b.offset; + }); + + if (output_metadata != nullptr) { + output_metadata->timestamp_included = + response->is_timestamped(); + } + resolver.Return(absl::OkStatus()); + }); + }); +} + +} // namespace credentio
diff --git a/formats/bmff/writer.h b/formats/bmff/writer.h new file mode 100644 index 0000000..3c4d89b --- /dev/null +++ b/formats/bmff/writer.h
@@ -0,0 +1,112 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_WRITER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_WRITER_H_ + +#include <memory> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "async/async_context.h" +#include "generator/generator.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "utils/byte_instruction.h" + +namespace credentio { + +// Writer of C2PA manifest stores into BMFF assets. +class BmffWriter { + public: + // Information about the C2PA manifest store embedded in the output asset. + struct OutputMetadata { + // Whether the C2PA active manifest embedded in the output asset has a + // timestamp. + bool timestamp_included = false; + }; + + using ExclusionProcessor = absl::AnyInvocable<absl::Status( + riegeli::Reader&, GenerateManifestStoreRequest&) const>; + + struct Options { + ExclusionProcessor exclusion_processor = nullptr; + }; + + // Creates a BMFF writer. + // + // The generator must outlive the writer. + explicit BmffWriter(const ManifestStoreGenerator* absl_nonnull generator) + : BmffWriter(generator, Options{}) {} + + BmffWriter(const ManifestStoreGenerator* absl_nonnull generator, + Options options) + : generator_(*ABSL_DIE_IF_NULL(generator)), + options_(std::move(options)) {} + + // Generates a C2PA manifest store and embeds it into a copy of the given + // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the + // same underlying data. + void Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata, + AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb) const; + // Blocking version. + absl::Status Write( + GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata = nullptr) const; + + // Generates a C2PA manifest store and embeds it in the given asset just + // as the above methods, but does so in-place. + // + // The supplied writer must support random access and read mode. Read mode is + // not enabled by default on the riegeli::StringWriter or riegeli::FileWriter, + // it can be enabled by passing an Option to their constructors: + // - StringWriter(&data, StringWriterBase::Options().set_append(true)); + // - FileWriter(filepath, FileWriterBase::Options().set_existing(true)); + void Write(GenerateManifestStoreRequest request, + riegeli::Writer* absl_nonnull asset, + OutputMetadata* absl_nullable output_metadata, + AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb) const; + // Blocking version. + absl::Status Write( + GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset, + OutputMetadata* absl_nullable output_metadata = nullptr) const; + + private: + void WriteInternal( + GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + OutputMetadata* absl_nullable output_metadata, + std::shared_ptr<std::vector<ByteInstruction>> byte_instructions, + AsyncContext context, absl::AnyInvocable<void(absl::Status) &&> cb) const; + + const ManifestStoreGenerator& generator_; + Options options_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_WRITER_H_
diff --git a/formats/gif/BUILD b/formats/gif/BUILD index 19badc8..f6abcb2 100644 --- a/formats/gif/BUILD +++ b/formats/gif/BUILD
@@ -69,6 +69,91 @@ ) cc_library( + name = "block", + srcs = ["block.cc"], + hdrs = ["block.h"], + deps = [ + ":constants", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "block_test", + srcs = ["block_test.cc"], + deps = [ + ":block", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_writer", + ], +) + +cc_library( + name = "embedder", + srcs = ["embedder.cc"], + hdrs = ["embedder.h"], + deps = [ + ":block", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:copy_all", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_test( + name = "embedder_test", + srcs = ["embedder_test.cc"], + deps = [ + ":embedder", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +) + +cc_library( + name = "hasher", + srcs = ["hasher.cc"], + hdrs = ["hasher.h"], + deps = [ + ":constants", + ":reader", + "//bindings:input_hasher", + "//crypto:algorithms", + "//proto:data_hash_assertion_cc_proto", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "hasher_test", + srcs = ["hasher_test.cc"], + deps = [ + ":constants", + ":hasher", + ":test_builder", + "//crypto:algorithms", + "//testing:proto_test_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( name = "extractor", srcs = ["extractor.cc"], hdrs = ["extractor.h"], @@ -147,3 +232,29 @@ "@abseil-cpp//absl/status:statusor", ], ) + +cc_library( + name = "writer", + srcs = ["writer.cc"], + hdrs = ["writer.h"], + deps = [ + ":block", + ":embedder", + ":hasher", + "//async:async_context", + "//crypto:algorithms", + "//generator", + "//proto:data_hash_assertion_cc_proto", + "//proto:generator_cc_proto", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + ], +)
diff --git a/formats/gif/block.cc b/formats/gif/block.cc new file mode 100644 index 0000000..7cb06d2 --- /dev/null +++ b/formats/gif/block.cc
@@ -0,0 +1,92 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/gif/block.h" + +#include <algorithm> +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "formats/gif/constants.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { + +namespace { + +absl::Status WriteDataAsBlocks(absl::string_view payload, + riegeli::Writer& output) { + for (int64_t start = 0; start < payload.size(); start += 255) { + int64_t block_size = std::min<int64_t>(255, payload.size() - start); + + if (!output.WriteByte(block_size)) { + return output.status(); + } + if (!output.Write(payload.substr(start, block_size))) { + return output.status(); + } + } + + if (!output.WriteByte(0)) { + return output.status(); + } + + return absl::OkStatus(); +} + +} // namespace + +// Given the size of a jumbf payload in bytes, it will return the total number +// of bytes required to store the payload within a GIF block where the data is +// segmented into blocks of at most 255 bytes. +uint64_t CalculateTotalGifC2paBlockSize(uint64_t jumbf_payload_size) { + // The C2PA block is: + // - 1 byte for the extension introducer (0x21) + // - 1 byte for the extension type (0xFF) + // - 1 byte for the informational data (always 0x0B) + // - 11 bytes for the c2pa informational data + // - ceil(jumbf_payload_size / 255) + jumbf_payload_size bytes for the data + // - 1 byte for the termination block (0x00) + return 15 + jumbf_payload_size + (jumbf_payload_size + 254) / 255; +} + +// Writes the given payload as a GIF block with n blocks of at most 255 bytes +// each. +absl::Status WriteGifC2paBlock(absl::string_view payload, + riegeli::Writer& output) { + if (!output.WriteByte(kGifExtensionIntroducer)) { + return output.status(); + } + if (!output.WriteByte(kGifExtensionApplication)) { + return output.status(); + } + if (!output.WriteByte(kGifC2paExtensionInfoSize)) { + return output.status(); + } + if (!output.Write(kGifC2paIdentifier)) { + return output.status(); + } + if (!riegeli::WriteBigEndian<uint16_t>(kGifC2paAuthenticationCode, output)) { + return output.status(); + } + if (!output.WriteByte(0)) { + return output.status(); + } + return WriteDataAsBlocks(payload, output); +} + +} // namespace credentio
diff --git a/formats/gif/block.h b/formats/gif/block.h new file mode 100644 index 0000000..1d6ce1b --- /dev/null +++ b/formats/gif/block.h
@@ -0,0 +1,38 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_GIF_BLOCK_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_BLOCK_H_ + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// Given the size of a jumbf payload in bytes, it will return the total number +// of bytes required to store the payload within a GIF block where the data is +// segmented into blocks of at most 255 bytes. +uint64_t CalculateTotalGifC2paBlockSize(uint64_t jumbf_payload_size); + +// Writes the given payload as a GIF block with n blocks of at most 255 bytes +// each. +absl::Status WriteGifC2paBlock(absl::string_view payload, + riegeli::Writer& output); +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_GIF_BLOCK_H_
diff --git a/formats/gif/block_test.cc b/formats/gif/block_test.cc new file mode 100644 index 0000000..c5e3df6 --- /dev/null +++ b/formats/gif/block_test.cc
@@ -0,0 +1,86 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/gif/block.h" + +#include <cstdint> +#include <string> +#include <vector> + +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_writer.h" + +namespace credentio { +namespace { + +TEST(BlockTest, Write10BytePayload) { + std::string manifest_store = "0123456789"; + + std::vector<uint8_t> expected_output = { + 0x21, 0xFF, 0x0B, 'C', '2', 'P', 'A', '_', 'G', 'I', 'F', 0x01, 0x00, + 0x00, 0x0a, '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 0x00, + }; + std::string expected(expected_output.begin(), expected_output.end()); + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteGifC2paBlock(manifest_store, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, expected); + EXPECT_EQ(output_s.size(), 26); +} + +TEST(BlockTest, Write300BytePayload) { + // Manifest store is 300 bytes long. + std::string manifest_store(300, 'a'); + + // Construct the expected output. + std::vector<uint8_t> expected_output = { + 0x21, 0xFF, 0x0B, 'C', '2', 'P', 'A', '_', + 'G', 'I', 'F', 0x01, 0x00, 0x00, 0xFF, + }; + expected_output.reserve(expected_output.size() + 302); + for (uint32_t i = 0; i < 255; ++i) { + expected_output.push_back('a'); + } + expected_output.push_back(45); + for (uint32_t i = 0; i < 45; ++i) { + expected_output.push_back('a'); + } + expected_output.push_back(0x00); + std::string expected(expected_output.begin(), expected_output.end()); + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteGifC2paBlock(manifest_store, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, expected); + EXPECT_EQ(output_s.size(), 317); +} + +TEST(BlockTest, CalculateSizeOneSubBlock) { + EXPECT_EQ(CalculateTotalGifC2paBlockSize(10), 26); +} + +TEST(BlockTest, CalculateSizeMultipleSubBlocks) { + EXPECT_EQ(CalculateTotalGifC2paBlockSize(1000), 1019); +} + +} // namespace +} // namespace credentio
diff --git a/formats/gif/embedder.cc b/formats/gif/embedder.cc new file mode 100644 index 0000000..fcabfa3 --- /dev/null +++ b/formats/gif/embedder.cc
@@ -0,0 +1,46 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/gif/embedder.h" + +#include <cstdint> +#include <optional> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/strings/string_view.h" +#include "formats/gif/block.h" +#include "riegeli/bytes/copy_all.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +absl::Status GifEmbedder::CopyAndEmbed( + riegeli::Reader& input, absl::string_view manifest_store, + int64_t insert_at_offset, std::optional<int64_t> existing_c2pa_length, + riegeli::Writer& output) const { + if (!input.Copy(insert_at_offset, output)) { + return input.StatusOrAnnotate( + absl::InternalError("failed to copy input up to insert point")); + } + // Embed the manifest store. + ABSL_RETURN_IF_ERROR(WriteGifC2paBlock(manifest_store, output)); + input.Skip(existing_c2pa_length.value_or(0)); + + return riegeli::CopyAll(input, output); +} + +} // namespace credentio
diff --git a/formats/gif/embedder.h b/formats/gif/embedder.h new file mode 100644 index 0000000..3d1b3ea --- /dev/null +++ b/formats/gif/embedder.h
@@ -0,0 +1,43 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_GIF_EMBEDDER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_EMBEDDER_H_ + +#include <cstdint> +#include <optional> + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { +// A utility class to embed a C2PA manifest store as an Application Extension +// block into a GIF file. +class GifEmbedder { + public: + // Copies the data from the input to the output and embeds the manifest store. + // NOTE: Input and output must be disjoint, i.e., in-place embedding is not + // supported. + absl::Status CopyAndEmbed(riegeli::Reader& input, + absl::string_view manifest_store, + int64_t insert_at_offset, + std::optional<int64_t> existing_c2pa_length, + riegeli::Writer& output) const; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_GIF_EMBEDDER_H_
diff --git a/formats/gif/embedder_test.cc b/formats/gif/embedder_test.cc new file mode 100644 index 0000000..b8739f1 --- /dev/null +++ b/formats/gif/embedder_test.cc
@@ -0,0 +1,81 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/gif/embedder.h" + +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" + +namespace credentio { +namespace { + +TEST(EmbedderTest, Write10BytePayload) { + std::string image = "insert_@underscore"; + riegeli::StringReader<> input(image); + + std::string manifest_store = "0123456789"; + + std::vector<uint8_t> expected_output = { + 'i', 'n', 's', 'e', 'r', 't', 0x21, 0xFF, 0x0B, 'C', '2', + 'P', 'A', '_', 'G', 'I', 'F', 0x01, 0x00, 0x00, 0x0a, '0', + '1', '2', '3', '4', '5', '6', '7', '8', '9', 0x00, '_', + '@', 'u', 'n', 'd', 'e', 'r', 's', 'c', 'o', 'r', 'e', + }; + std::string expected(expected_output.begin(), expected_output.end()); + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + + GifEmbedder embedder; + ABSL_ASSERT_OK( + embedder.CopyAndEmbed(input, manifest_store, 6, std::nullopt, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, expected); +} + +TEST(EmbedderTest, ReplaceExistingPayload) { + std::string image = "before_OLD_C2PA_after"; + riegeli::StringReader<> input(image); + + std::string manifest_store = "0123456789"; + + std::vector<uint8_t> expected_output = { + 'b', 'e', 'f', 'o', 'r', 'e', '_', 0x21, 0xFF, 0x0B, 'C', '2', 'P', + 'A', '_', 'G', 'I', 'F', 0x01, 0x00, 0x00, 0x0a, '0', '1', '2', '3', + '4', '5', '6', '7', '8', '9', 0x00, 'a', 'f', 't', 'e', 'r', + }; + std::string expected(expected_output.begin(), expected_output.end()); + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + + GifEmbedder embedder; + ABSL_ASSERT_OK(embedder.CopyAndEmbed(input, manifest_store, 7, 9, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, expected); +} + +} // namespace +} // namespace credentio
diff --git a/formats/gif/hasher.cc b/formats/gif/hasher.cc new file mode 100644 index 0000000..5cc2132 --- /dev/null +++ b/formats/gif/hasher.cc
@@ -0,0 +1,106 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/gif/hasher.h" + +#include <memory> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "bindings/input_hasher.h" +#include "crypto/algorithms.h" +#include "formats/gif/constants.h" +#include "formats/gif/reader.h" +#include "proto/data_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +absl::StatusOr<DigestGifResult> DigestGif(riegeli::Reader& input, + HashAlgorithm alg) { + DigestGifResult result = {.insertion_point = {.offset = 0}}; + + DataHashAssertion* assertion = &result.data_hash; + assertion->set_alg(absl::StrCat(alg)); + + DataRange* manifest_exclusion = assertion->add_exclusions(); + manifest_exclusion->set_start(0); + manifest_exclusion->set_length(0); + + bool found_logical_descriptor = false; + bool found_image_descriptor = false; + + std::unique_ptr<InputHasher> hasher; + ABSL_ASSIGN_OR_RETURN(hasher, InputHasher::Create(alg)); + + ABSL_RETURN_IF_ERROR(IterateOverGifBlocks( + input, + [&input, &hasher, &manifest_exclusion, &found_image_descriptor, + &found_logical_descriptor, + &result](const GifBlock& block) -> absl::StatusOr<bool> { + if (block.type == kGifImageDescriptorLabel) { + found_image_descriptor = true; + + // We reached the Image Descriptor, C2PA must be before this point. + if (result.insertion_point.offset == 0) { + return absl::InvalidArgumentError( + "No location found to insert C2PA block"); + } + } else if (block.type == kGifLogicalScreenDescriptorLabel) { + found_logical_descriptor = true; + // Insert the C2PA block after the logical descriptor. + result.insertion_point.offset = block.offset + block.length; + manifest_exclusion->set_start(result.insertion_point.offset); + } else if (block.type == "C2PA") { + if (result.existing_c2pa_length.has_value()) { + // This means that the asset has multiple C2PA blocks. + return absl::InvalidArgumentError( + "Unexpected additional C2PA block found"); + } + if (found_image_descriptor) { + return absl::InvalidArgumentError( + "Unexpected C2PA block found after Image Descriptor"); + } + if (!found_logical_descriptor) { + return absl::InvalidArgumentError( + "Unexpected C2PA block found before Logical Descriptor"); + } + + // Replace the existing C2PA block instead + result.insertion_point.offset = block.offset; + result.existing_c2pa_length = block.length; + + manifest_exclusion->set_start(block.offset); + // Don't update the hash for the existing C2PA block. + return true; + } + + ABSL_RETURN_IF_ERROR(hasher->Update(input, block.offset, block.length)); + return true; + })); + + if (result.insertion_point.offset == 0 || !found_logical_descriptor) { + return absl::InvalidArgumentError( + "No Logical Screen Descriptor found in GIF"); + } + + // Set the hash + assertion->set_hash(hasher->Digest()); + return result; +} + +} // namespace credentio
diff --git a/formats/gif/hasher.h b/formats/gif/hasher.h new file mode 100644 index 0000000..dbb63b0 --- /dev/null +++ b/formats/gif/hasher.h
@@ -0,0 +1,50 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_GIF_HASHER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_HASHER_H_ + +#include <cstdint> +#include <optional> + +#include "absl/status/statusor.h" +#include "crypto/algorithms.h" +#include "proto/data_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Insertion point for a C2PA manifest store into a GIF image. The offset is +// relative to the start of the GIF image. +struct GifInsertionPoint { + int64_t offset; + + bool operator==(const GifInsertionPoint& other) const = default; +}; + +struct DigestGifResult { + DataHashAssertion data_hash; + GifInsertionPoint insertion_point; + std::optional<int64_t> existing_c2pa_length; +}; + +// Digests the given GIF image and returns a DigestGifResult that can be +// used to generate a C2PA manifest store. The manifest store will be placed +// before the first Graphic Control Extension or Image Descriptor. +absl::StatusOr<DigestGifResult> DigestGif(riegeli::Reader& input, + HashAlgorithm alg); +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_GIF_HASHER_H_
diff --git a/formats/gif/hasher_test.cc b/formats/gif/hasher_test.cc new file mode 100644 index 0000000..7654991 --- /dev/null +++ b/formats/gif/hasher_test.cc
@@ -0,0 +1,129 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/gif/hasher.h" + +#include <optional> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "crypto/algorithms.h" +#include "formats/gif/constants.h" +#include "formats/gif/test_builder.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::HasSubstr; + +TEST(HasherTest, RecordsExistingC2paLength) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication, + std::string(kGifC2paIdentifier) + "___", "manifest_store"); + builder.AddBlock(kGifTrailerIntroducer); + std::string asset = builder.GetAsset(); + + riegeli::StringReader<> input(asset); + + auto result_or = DigestGif(input, HashAlgorithm::kSha256); + ABSL_ASSERT_OK(result_or); + EXPECT_TRUE(result_or->existing_c2pa_length.has_value()); + EXPECT_GT(*result_or->existing_c2pa_length, 0); +} + +TEST(HasherTest, FailsWhenMultipleC2paBlocksExist) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication, + std::string(kGifC2paIdentifier) + "___", "manifest_store_1"); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication, + std::string(kGifC2paIdentifier) + "___", "manifest_store_2"); + builder.AddBlock(kGifTrailerIntroducer); + std::string asset = builder.GetAsset(); + + riegeli::StringReader<> input(asset); + + EXPECT_THAT(DigestGif(input, HashAlgorithm::kSha256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Unexpected additional C2PA block found"))); +} + +TEST(HasherTest, FailsWhenC2paBlockAfterImageDescriptor) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddImageDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication, + std::string(kGifC2paIdentifier) + "___", "manifest_store"); + builder.AddBlock(kGifTrailerIntroducer); + std::string asset = builder.GetAsset(); + + riegeli::StringReader<> input(asset); + + EXPECT_THAT( + DigestGif(input, HashAlgorithm::kSha256), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr("Unexpected C2PA block found after Image Descriptor"))); +} + +TEST(HasherTest, FailsWhenNoLogicalDescriptor) { + TestGifBuilder builder; + builder.AddGifHeader(); + std::string asset = builder.GetAsset(); + + riegeli::StringReader<> input(asset); + + EXPECT_THAT(DigestGif(input, HashAlgorithm::kSha256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("No Logical Screen Descriptor found in GIF"))); +} + +TEST(HasherTest, SuccessfullyHashesGif) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication, + "other_app_info", "misc_data"); + builder.AddBlock(kGifTrailerIntroducer); + std::string asset = builder.GetAsset(); + + riegeli::StringReader<> input(asset); + + auto result_or = DigestGif(input, HashAlgorithm::kSha256); + ABSL_ASSERT_OK(result_or); + EXPECT_EQ(result_or->insertion_point, GifInsertionPoint{.offset = 13}); + EXPECT_THAT( + result_or->data_hash, + EqualsProto(ParseTextProtoOrDie<DataHashAssertion>(R"pb( + alg: "sha256" + hash: "\334(\"\336\341 WHK\0219$\202p-\246\373\246t;\310Zd5x\345R\341a_\354s" + exclusions { start: 13 length: 0 } + )pb"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/gif/reader.cc b/formats/gif/reader.cc index 37889ff..59cd441 100644 --- a/formats/gif/reader.cc +++ b/formats/gif/reader.cc
@@ -124,6 +124,16 @@ return absl::OkStatus(); } +absl::Status PopulateImageData(riegeli::Reader& input, GifBlock& block) { + block.type = std::string(kGifImageDataLabel); + block.offset = input.pos(); + if (!input.Skip(1)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to skip LZW minimum code size")); + } + return PopulateBlock(input, block); +} + absl::Status PopulateApplicationExtension(riegeli::Reader& input, GifBlock& block) { int64_t next_size_offset = input.pos(); @@ -234,7 +244,21 @@ break; } case kGifImageDescriptorIntroducer: { + // Process the image descriptor. ABSL_RETURN_IF_ERROR(PopulateImageDescriptor(input, block)); + ABSL_ASSIGN_OR_RETURN(should_continue, processor(block)); + ABSL_RETURN_IF_ERROR(input.status()); + if (!should_continue) { + return absl::OkStatus(); + } + + if (!input.Seek(block.offset + block.length)) { + return input.StatusOrAnnotate( + absl::InternalError("Failed to seek to offset")); + } + + // Process the image data directly following the image descriptor. + ABSL_RETURN_IF_ERROR(PopulateImageData(input, block)); break; } case kGifTrailerIntroducer: { @@ -243,8 +267,8 @@ break; } default: - block.type = std::string(kGifImageDataLabel); - ABSL_RETURN_IF_ERROR(PopulateBlock(input, block)); + return absl::UnimplementedError( + absl::StrFormat("Introducer not supported: %#04x", introducer)); } ABSL_ASSIGN_OR_RETURN(should_continue, processor(block));
diff --git a/formats/gif/reader_test.cc b/formats/gif/reader_test.cc index e4ce30d..2c4d7a3 100644 --- a/formats/gif/reader_test.cc +++ b/formats/gif/reader_test.cc
@@ -199,23 +199,6 @@ })); } -TEST(GifReaderTest, ReadsTableData) { - TestGifBuilder builder; - builder.AddGifHeader(); - builder.AddLogicalDescriptor(/*has_color_table=*/false); - builder.AddBlock(0x02, /*type=*/std::nullopt, /*info=*/std::nullopt, - "this_is_some_image_data"); - std::string contents = builder.GetAsset(); - - riegeli::StringReader<> input(contents); - - EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ - .offset = 13, - .length = 26, - .type = "TBID", - })); -} - TEST(GifReaderTest, ReadsImageDescriptorWithoutColorTable) { TestGifBuilder builder; builder.AddGifHeader(); @@ -226,9 +209,9 @@ riegeli::StringReader<> input(contents); EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ - .offset = 13, - .length = 10, - .type = "2C", + .offset = 23, + .length = 13, + .type = "TBID", })); } @@ -242,9 +225,9 @@ riegeli::StringReader<> input(contents); EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ - .offset = 13, - .length = 22, - .type = "2C", + .offset = 35, + .length = 13, + .type = "TBID", })); }
diff --git a/formats/gif/test_builder.cc b/formats/gif/test_builder.cc index 351e147..79369ea 100644 --- a/formats/gif/test_builder.cc +++ b/formats/gif/test_builder.cc
@@ -76,6 +76,9 @@ }; gif_data_ += std::string(color_table.begin(), color_table.end()); } + + gif_data_ += 2; // LZW minimum code size + gif_data_ += EncodeDataToBlocks("image_data", /*add_termination_block=*/true); } void TestGifBuilder::AddBlock(uint8_t introducer, std::optional<uint8_t> type,
diff --git a/formats/gif/writer.cc b/formats/gif/writer.cc new file mode 100644 index 0000000..10d811e --- /dev/null +++ b/formats/gif/writer.cc
@@ -0,0 +1,181 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/gif/writer.h" + +#include <cstdint> +#include <optional> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "async/async_context.h" +#include "crypto/algorithms.h" +#include "formats/gif/block.h" +#include "formats/gif/embedder.h" +#include "formats/gif/hasher.h" +#include "generator/generator.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { +namespace { + +void AdjustDataHashExclusions(DataHashAssertion* absl_nonnull assertion, + int64_t c2pa_offset, int64_t c2pa_size) { + for (auto& exclusion : *assertion->mutable_exclusions()) { + if (exclusion.start() < c2pa_offset) { + // Before the C2PA segment, so no adjustment needed. + continue; + } + if (exclusion.start() == c2pa_offset) { + // This is the C2PA segment, just need to fill in the length. + exclusion.set_length(c2pa_size); + continue; + } + // This is after the C2PA segment, so we need to add the manifest size to + // the offset. + exclusion.set_start(exclusion.start() + c2pa_size); + } +} + +absl::Status CopyAndEmbed(riegeli::Reader& input, + absl::string_view manifest_store, + int64_t insert_at_offset, + std::optional<int64_t> existing_c2pa_length, + riegeli::Writer& destination) { + if (!input.Seek(0)) { + return input.StatusOrAnnotate( + absl::DataLossError("failed to seek to start of input")); + } + GifEmbedder embedder; + ABSL_RETURN_IF_ERROR( + embedder.CopyAndEmbed(input, manifest_store, insert_at_offset, + existing_c2pa_length, destination)); + return absl::OkStatus(); +} + +} // namespace + +GifWriter::GifWriter(const ManifestStoreGenerator* absl_nonnull generator) + : generator_(*ABSL_DIE_IF_NULL(generator)) {} + +absl::Status GifWriter::Write( + GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata) const { + absl::Notification done; + absl::Status status; + Write(std::move(request), source, destination, output_metadata, + AsyncContext(), [&](absl::Status s) { + status = std::move(s); + done.Notify(); + }); + done.WaitForNotification(); + return status; +} + +void GifWriter::Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata, + AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb) const { + AsyncResolver<void> resolver(context, std::move(cb)); + if (request.has_hard_binding()) { + resolver.Return(absl::InvalidArgumentError( + "request must not include `hard_binding` (binding is computed by " + "GifWriter)")); + return; + } + + auto digest_result = DigestGif(*source, HashAlgorithm::kSha256); + if (!digest_result.ok()) { + resolver.Return(digest_result.status()); + return; + } + + if (digest_result->existing_c2pa_length.has_value() && + (!request.has_actions() || !request.actions().has_opened())) { + resolver.Return(absl::AlreadyExistsError( + "C2PA chunk already exists, but no `opened` action was provided")); + return; + } + + request.mutable_hard_binding()->mutable_data_hash()->Swap( + &digest_result->data_hash); + auto insertion_point = std::move(digest_result->insertion_point); + auto existing_c2pa_length = digest_result->existing_c2pa_length; + + generator_.GenerateManifestStoreTemplate( + request, context, + [generator = &generator_, source, request, + insertion_point = std::move(insertion_point), existing_c2pa_length, + destination, output_metadata, context, resolver = std::move(resolver)]( + absl::StatusOr<ManifestStoreTemplate> template_response) mutable { + if (!template_response.ok()) { + resolver.Return(template_response.status()); + return; + } + + uint64_t embedded_size = CalculateTotalGifC2paBlockSize( + template_response->manifest_store_size()); + + FinalizeManifestStoreRequest finalize_request; + finalize_request.mutable_manifest_store_template()->Swap( + &*template_response); + finalize_request.mutable_hard_binding()->MergeFrom( + request.hard_binding()); + + AdjustDataHashExclusions( + finalize_request.mutable_hard_binding()->mutable_data_hash(), + insertion_point.offset, embedded_size); + + generator->FinalizeManifestStore( + finalize_request, context, + [source, insertion_point, existing_c2pa_length, destination, + output_metadata, resolver = std::move(resolver)]( + absl::StatusOr<FinalizeManifestStoreResponse> + finalize_response) mutable { + if (!finalize_response.ok()) { + resolver.Return(finalize_response.status()); + return; + } + + auto copy_status = CopyAndEmbed( + *source, finalize_response->manifest_store(), + insertion_point.offset, existing_c2pa_length, *destination); + if (!copy_status.ok()) { + resolver.Return(std::move(copy_status)); + return; + } + if (output_metadata != nullptr) { + output_metadata->timestamp_included = + finalize_response->is_timestamped(); + } + resolver.Return(absl::OkStatus()); + }); + }); +} + +} // namespace credentio
diff --git a/formats/gif/writer.h b/formats/gif/writer.h new file mode 100644 index 0000000..5e3fad9 --- /dev/null +++ b/formats/gif/writer.h
@@ -0,0 +1,68 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_GIF_WRITER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_WRITER_H_ + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "async/async_context.h" +#include "generator/generator.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// Writes C2PA manifest stores into GIF images. +class GifWriter { + public: + // Information about the C2PA manifest store embedded in the output asset. + struct OutputMetadata { + // Whether the C2PA active manifest embedded in the output asset has a + // timestamp. + bool timestamp_included = false; + }; + + // Creates a GIF writer. + // + // The generator must outlive the writer. + explicit GifWriter(const ManifestStoreGenerator* absl_nonnull generator); + + // Generates a C2PA manifest store and embeds it into a copy of the given + // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the + // same underlying data. The request must not include `hard_binding`. + // Fails if C2PA metadata already exists and no `opened` action is present. + void Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata, + AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb) const; + // Blocking version. + absl::Status Write( + GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata = nullptr) const; + + private: + const ManifestStoreGenerator& generator_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_GIF_WRITER_H_
diff --git a/formats/hasher_data.cc b/formats/hasher_data.cc new file mode 100644 index 0000000..b38d193 --- /dev/null +++ b/formats/hasher_data.cc
@@ -0,0 +1,111 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/hasher_data.h" + +#include <cstdint> +#include <limits> +#include <memory> +#include <optional> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/substitute.h" +#include "bindings/input_hasher.h" +#include "crypto/algorithms.h" +#include "proto/data_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +namespace { + +absl::StatusOr<uint64_t> GetSize(riegeli::Reader& input) { + if (!input.SupportsSize()) { + return absl::InvalidArgumentError("input does not support size"); + } + std::optional<uint64_t> input_size = input.Size(); + if (!input_size.has_value()) { + return absl::InvalidArgumentError("input does not have a size"); + } + return *input_size; +} + +absl::StatusOr<DataHashAssertion> DigestDataHashInternal( + riegeli::Reader& input, HashAlgorithm alg, uint64_t manifest_store_offset, + uint64_t manifest_store_length) { + ABSL_ASSIGN_OR_RETURN(uint64_t input_size, GetSize(input)); + + if (manifest_store_offset > + static_cast<uint64_t>(std::numeric_limits<int64_t>::max()) || + manifest_store_length > + static_cast<uint64_t>(std::numeric_limits<int64_t>::max())) { + return absl::InvalidArgumentError(absl::Substitute( + "Manifest store offset ($0) or length ($1) exceeds int64 max", + manifest_store_offset, manifest_store_length)); + } + + uint64_t manifest_store_end; + if (__builtin_add_overflow(manifest_store_offset, manifest_store_length, + &manifest_store_end)) { + return absl::InvalidArgumentError(absl::Substitute( + "Manifest store end calculation overflowed: offset $0, length $1", + manifest_store_offset, manifest_store_length)); + } + + if (manifest_store_end > input_size) { + return absl::InvalidArgumentError(absl::Substitute( + "Manifest store range [$0, $1) exceeds input size $2", + manifest_store_offset, manifest_store_end, input_size)); + } + + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<InputHasher> hasher, + InputHasher::Create(alg)); + ABSL_RETURN_IF_ERROR(hasher->Update(input, 0, manifest_store_offset)); + ABSL_RETURN_IF_ERROR(hasher->Update(input, manifest_store_end, + input_size - manifest_store_end)); + DataHashAssertion assertion; + assertion.set_alg(absl::StrCat(alg)); + assertion.set_hash(hasher->Digest()); + DataRange* exclusion = assertion.add_exclusions(); + exclusion->set_start(static_cast<int64_t>(manifest_store_offset)); + exclusion->set_length(static_cast<int64_t>(manifest_store_length)); + return assertion; +} + +} // namespace + +absl::StatusOr<DataHashAssertion> DataHashTemplate(HashAlgorithm alg) { + DataHashAssertion assertion; + assertion.set_alg(absl::StrCat(alg)); + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<InputHasher> hasher, + InputHasher::Create(alg)); + assertion.set_hash(hasher->Digest()); + DataRange* exclusion = assertion.add_exclusions(); + exclusion->set_start(0); + exclusion->set_length(0); + return assertion; +} + +absl::StatusOr<DataHashAssertion> DigestDataHash( + riegeli::Reader& input, HashAlgorithm alg, uint64_t manifest_store_offset, + uint64_t manifest_store_length) { + return DigestDataHashInternal(input, alg, manifest_store_offset, + manifest_store_length); +} + +} // namespace credentio
diff --git a/formats/hasher_data.h b/formats/hasher_data.h new file mode 100644 index 0000000..e0ce246 --- /dev/null +++ b/formats/hasher_data.h
@@ -0,0 +1,40 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_HASHER_DATA_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_HASHER_DATA_H_ + +#include <cstdint> + +#include "absl/status/statusor.h" +#include "crypto/algorithms.h" +#include "proto/data_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +// Produces a "template" DataHashAssertion. +// This assertion will have the same contents as an assertion produced by +// DigestDataHash, except for the specific hash value (which will have the same +// length) and numeric offset/length values. +absl::StatusOr<DataHashAssertion> DataHashTemplate(HashAlgorithm alg); + +// Same as above, but for a Riegeli reader. +absl::StatusOr<DataHashAssertion> DigestDataHash( + riegeli::Reader& input, HashAlgorithm alg, uint64_t manifest_store_offset, + uint64_t manifest_store_length); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_HASHER_DATA_H_
diff --git a/formats/id3/BUILD b/formats/id3/BUILD index 90a715f..48f5359 100644 --- a/formats/id3/BUILD +++ b/formats/id3/BUILD
@@ -156,3 +156,86 @@ "@abseil-cpp//absl/status:statusor", ], ) + +cc_library( + name = "embedder", + srcs = ["embedder.cc"], + hdrs = ["embedder.h"], + deps = [ + ":constants", + ":id3", + ":reader", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_test( + name = "embedder_test", + srcs = ["embedder_test.cc"], + deps = [ + ":embedder", + ":extractor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +) + +cc_library( + name = "writer", + srcs = ["writer.cc"], + hdrs = ["writer.h"], + deps = [ + ":embedder", + "//crypto:algorithms", + "//formats:hasher_data", + "//generator", + "//proto:generator_cc_proto", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:cord_reader", + "@riegeli//riegeli/bytes:cord_writer", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_test( + name = "writer_test", + srcs = ["writer_test.cc"], + deps = [ + ":extractor", + ":writer", + "//async:async_context", + "//formats:byte_range", + "//generator", + "//proto:generator_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:nonprod_generator", + "//testing:nonprod_validator", + "//validator:asset_validator", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +)
diff --git a/formats/id3/embedder.cc b/formats/id3/embedder.cc new file mode 100644 index 0000000..3f6f450 --- /dev/null +++ b/formats/id3/embedder.cc
@@ -0,0 +1,193 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/id3/embedder.h" + +#include <cstdint> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "formats/id3/constants.h" +#include "formats/id3/id3.h" +#include "formats/id3/reader.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { +namespace { + +// The size of the ID3 header excluded from the ID3 tag size calculation. +constexpr uint32_t kExcludedId3TagHeaderSize = 10; +// The size of the ID3 extended header excluded from the ID3 extended header +// size calculation. +constexpr uint32_t kExcludedId3ExtendedHeaderSize = 4; + +constexpr absl::string_view kGeobFrameFileName = "c2pa"; +constexpr absl::string_view kGeobFrameContentDescription = + "c2pa manifest store"; + +// Encodes the C2PA manifest store as an ID3 GEOB frame, returns the encoded +// frame as an `absl::Cord`. ID3v2.4 frame sizes are encoded as synchsafe +// integers, whereas ID3v2.3 frame sizes are 32-bit big-endian integers. +absl::StatusOr<absl::Cord> EncodeManifestStoreAsId3Frame( + absl::string_view manifest_store, uint8_t major_version) { + absl::Cord geob_frame_data; + // Encode manifest store as the GEOB frame data. + geob_frame_data.Append( + absl::string_view("\x00", 1)); // Text encoding [ISO-8859-1] + geob_frame_data.Append(kGeobFrameMimeType); // MIME type + geob_frame_data.Append(absl::string_view("\x00", 1)); // NULL terminator + geob_frame_data.Append(kGeobFrameFileName); // File name + geob_frame_data.Append(absl::string_view("\x00", 1)); // NULL terminator + geob_frame_data.Append(kGeobFrameContentDescription); // Content description + geob_frame_data.Append(absl::string_view("\x00", 1)); // NULL terminator + geob_frame_data.Append(manifest_store); // Manifest store as encapsulated + // object + + absl::Cord output; + // Write the GEOB frame header. + output.Append(absl::string_view("GEOB", 4)); + uint32_t frame_size = geob_frame_data.size(); + char size_bytes[4]; + if (major_version == 4) { + if (frame_size > 0x0FFFFFFF) { + return absl::InvalidArgumentError( + "Frame size exceeds maximum allowable ID3v2.4 frame size"); + } + // Encode the frame size as a synchsafe integer. + size_bytes[0] = (frame_size >> 21) & 0x7F; + size_bytes[1] = (frame_size >> 14) & 0x7F; + size_bytes[2] = (frame_size >> 7) & 0x7F; + size_bytes[3] = frame_size & 0x7F; + } else { // ID3v2.3 + size_bytes[0] = (frame_size >> 24) & 0xFF; + size_bytes[1] = (frame_size >> 16) & 0xFF; + size_bytes[2] = (frame_size >> 8) & 0xFF; + size_bytes[3] = frame_size & 0xFF; + } + output.Append(absl::string_view(size_bytes, 4)); + output.Append(absl::string_view("\x00\x00", 2)); // Flags + // Append the GEOB frame data. + output.Append(std::move(geob_frame_data)); + + return output; +} + +} // namespace + +absl::StatusOr<int64_t> Id3Embedder::CopyAndEmbed( + riegeli::Reader& input, absl::string_view manifest_store, + riegeli::Writer& output) const { + if (!input.SupportsSize() || !input.Size().has_value()) { + return absl::InvalidArgumentError("input does not support size"); + } + uint64_t asset_size = input.Size().value(); + + int64_t beginning_offset = input.pos(); + auto status = IterateOverId3Frames( + input, [&](const Id3Frame& frame) -> absl::StatusOr<bool> { + if (frame.id == "GEOB") { + ABSL_ASSIGN_OR_RETURN(auto geob_frame, + GeobFrame::Parse(input, frame.data_offset(), + frame.data_length())); + if (geob_frame.mime_type == kGeobFrameMimeType || + geob_frame.mime_type == kGeobFrameMimeTypeDeprecated) { + return absl::AlreadyExistsError( + "C2PA manifest store already exists."); + } + } + return true; + }); + + if (absl::IsNotFound(status.status())) { + // No ID3v2 tag found in the input audio file, so we prepend a simplest ID3 + // tag with only one GEOB frame for the manifest store. + ABSL_ASSIGN_OR_RETURN( + absl::Cord manifest_store_frame_encoded, + EncodeManifestStoreAsId3Frame(manifest_store, /*major_version=*/3)); + Id3Header id3_header = { + .use_unsynchronisation = false, + .has_extended_header = false, + .is_experimental = false, + .tag_size = static_cast<uint32_t>(manifest_store_frame_encoded.size()), + }; + + if (!output.Write(id3_header.ToCord())) { + return output.StatusOrAnnotate( + absl::InternalError("failed to write ID3 header")); + } + if (!output.Write(manifest_store_frame_encoded)) { + return output.StatusOrAnnotate( + absl::InternalError("failed to write manifest store frame")); + } + int64_t manifest_store_offset = output.pos() - manifest_store.size(); + + if (!input.Seek(beginning_offset)) { + return input.StatusOrAnnotate( + absl::InternalError("failed to seek to start of input")); + } + if (!input.Copy(asset_size - beginning_offset, output)) { + return input.StatusOrAnnotate( + absl::InternalError("failed to copy input")); + } + return manifest_store_offset; + } + if (!status.ok()) { + // Propagate other errors during reader creation. + return status.status(); + } + + Id3Header id3_header = status.value(); + uint32_t id3_header_size = + kExcludedId3TagHeaderSize + + (id3_header.has_extended_header + ? kExcludedId3ExtendedHeaderSize + id3_header.extended_header_size + : 0); + ABSL_ASSIGN_OR_RETURN( + absl::Cord manifest_store_frame, + EncodeManifestStoreAsId3Frame(manifest_store, id3_header.major_version)); + // Update the tag size to include the new manifest store frame. + if (static_cast<uint64_t>(id3_header.tag_size) + manifest_store_frame.size() > + 0x0FFFFFFF) { + return absl::InvalidArgumentError( + "Tag size exceeds maximum allowable ID3 tag size"); + } + id3_header.tag_size += static_cast<uint32_t>(manifest_store_frame.size()); + + if (!output.Write(id3_header.ToCord())) { + return output.StatusOrAnnotate( + absl::InternalError("failed to write ID3 header")); + } + if (!output.Write(manifest_store_frame)) { + return output.StatusOrAnnotate( + absl::InternalError("failed to write manifest store frame")); + } + + int64_t manifest_store_offset = output.pos() - manifest_store.size(); + // Copy the rest of the input audio file. + if (!input.Seek(beginning_offset + id3_header_size)) { + return input.StatusOrAnnotate( + absl::InternalError("failed to seek to start of input")); + } + if (!input.Copy(asset_size - id3_header_size, output)) { + return input.StatusOrAnnotate(absl::InternalError("failed to copy input")); + } + return manifest_store_offset; +} +} // namespace credentio
diff --git a/formats/id3/embedder.h b/formats/id3/embedder.h new file mode 100644 index 0000000..5b41d65 --- /dev/null +++ b/formats/id3/embedder.h
@@ -0,0 +1,40 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ID3_EMBEDDER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_EMBEDDER_H_ + +#include <cstdint> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// A utility class for embedding a C2PA manifest store as a ID3v2 `GEOB` frame +// in a non-C2PA ID3-compatible audio file. +class Id3Embedder { + public: + explicit Id3Embedder() = default; + + absl::StatusOr<int64_t> CopyAndEmbed(riegeli::Reader& input, + absl::string_view manifest_store, + riegeli::Writer& output) const; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ID3_EMBEDDER_H_
diff --git a/formats/id3/embedder_test.cc b/formats/id3/embedder_test.cc new file mode 100644 index 0000000..f445017 --- /dev/null +++ b/formats/id3/embedder_test.cc
@@ -0,0 +1,195 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/id3/embedder.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "formats/id3/extractor.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::Eq; +using ::testing::HasSubstr; + +// Simple MPEG-2 Layer 3 frame header followed by 4 bytes of dummy data. +constexpr char kMp3FileWithNoId3Tag[] = {'\xFF', '\xF3', '\x40', '\xC0', + 0x01, 0x02, 0x03, 0x04}; + +constexpr char kMp3FileWithNoC2paFrame[] = { + 'I', 'D', '3', '\x03', '\x00', '\x00', '\x00', '\x00', '\x00', + '\x0F', // Tag size = 15 + // A 15-byte COMM frame + 'C', 'O', 'M', 'M', '\x00', '\x00', '\x00', '\x05', '\x00', '\x00', '\x00', + '\x00', '\x00', '\x00', '\x00'}; + +constexpr char kMp3FileWithId3v24NoC2paFrame[] = { + 'I', 'D', '3', '\x04', '\x00', '\x00', '\x00', '\x00', '\x00', + '\x0F', // Tag size = 15 + // A 15-byte COMM frame + 'C', 'O', 'M', 'M', '\x00', '\x00', '\x00', '\x05', '\x00', '\x00', '\x00', + '\x00', '\x00', '\x00', '\x00'}; + +constexpr char kMp3FileWithNoC2paFrameWithExtendedHeader[] = { + 'I', 'D', '3', // ID3 file identifier + 0x03, 0x00, // Version 2.3 + 0x40, // Flags (bit 6 indicates extended header) + 0x00, 0x00, 0x00, 0x19, // Tag size = 25 + // Extended header + 0x00, 0x00, 0x00, 0x06, // Extended header size = 6 + 0x00, 0x00, // Extended flags + 0x00, 0x00, 0x00, 0x00, // Size of padding + // A 15-byte COMM frame + 'C', 'O', 'M', 'M', 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00}; + +constexpr char kMp3FileWithC2paManifestStore[] = { + 'I', 'D', '3', // ID3 file identifier + 0x03, 0x00, // Version 2.3 + 0x00, // Flags + 0x00, 0x00, 0x00, 0x3e, // Tag size = 62 (52 + 10) + 'G', 'E', 'O', 'B', // Frame ID + 0x00, 0x00, 0x00, 0x34, // Frame Size = 52 + 0x00, 0x00, // Frame flags + 0x01, // Text encoding [UTF-16] + // MIME type + 'a', 'p', 'p', 'l', 'i', 'c', 'a', 't', 'i', 'o', 'n', '/', 'c', '2', 'p', + 'a', 0x00, + // File name [UTF-16LE] + '\xFF', '\xFE', 'f', 0x00, 'o', 0x00, 'o', 0x00, 0x00, 0x00, + // Content description [UTF-16LE] + '\xFF', '\xFE', 'b', 0x00, 'a', 0x00, 'r', 0x00, 0x00, 0x00, + // Encapsulated object + 'm', 'a', 'n', 'i', 'f', 'e', 's', 't', ' ', 's', 't', 'o', 'r', 'e'}; + +TEST(Id3EmbedderTest, CopyAndEmbedNoId3Tag) { + std::string mp3_file_contents = + std::string(kMp3FileWithNoId3Tag, sizeof(kMp3FileWithNoId3Tag)); + absl::string_view kManifestStore = "C2PA manifest store bytes"; + riegeli::StringReader<> input(mp3_file_contents); + std::string output_s; + riegeli::StringWriter<> output(&output_s); + + Id3Embedder embedder; + auto manifest_store_offset = + embedder.CopyAndEmbed(input, kManifestStore, output); + ABSL_ASSERT_OK(manifest_store_offset.status()); + ASSERT_TRUE(output.Close()); + + EXPECT_THAT(output_s.substr(*manifest_store_offset, kManifestStore.size()), + Eq(kManifestStore)); + + Id3Extractor extractor; + riegeli::StringReader<> output_reader(output_s); + EXPECT_THAT(extractor.ExtractManifestStore(output_reader), + IsOkAndHolds(kManifestStore)); +} + +TEST(Id3EmbedderTest, CopyAndEmbedNoC2paFrame) { + std::string mp3_file_contents = + std::string(kMp3FileWithNoC2paFrame, sizeof(kMp3FileWithNoC2paFrame)); + absl::string_view kManifestStore = "C2PA manifest store bytes"; + riegeli::StringReader input(mp3_file_contents); + std::string output_s; + riegeli::StringWriter<> output(&output_s); + + Id3Embedder embedder; + auto manifest_store_offset = + embedder.CopyAndEmbed(input, kManifestStore, output); + ABSL_ASSERT_OK(manifest_store_offset.status()); + ASSERT_TRUE(output.Close()); + + EXPECT_THAT(output_s.substr(*manifest_store_offset, kManifestStore.size()), + Eq(kManifestStore)); + + Id3Extractor extractor; + riegeli::StringReader<> output_reader(output_s); + EXPECT_THAT(extractor.ExtractManifestStore(output_reader), + IsOkAndHolds(kManifestStore)); +} + +TEST(Id3EmbedderTest, CopyAndEmbedNoC2paFrameWithExtendedHeader) { + std::string mp3_file_contents = + std::string(kMp3FileWithNoC2paFrameWithExtendedHeader, + sizeof(kMp3FileWithNoC2paFrameWithExtendedHeader)); + absl::string_view kManifestStore = "C2PA manifest store bytes"; + riegeli::StringReader input(mp3_file_contents); + std::string output_s; + riegeli::StringWriter<> output(&output_s); + + Id3Embedder embedder; + auto manifest_store_offset = + embedder.CopyAndEmbed(input, kManifestStore, output); + ABSL_ASSERT_OK(manifest_store_offset.status()); + ASSERT_TRUE(output.Close()); + + EXPECT_THAT(output_s.substr(*manifest_store_offset, kManifestStore.size()), + Eq(kManifestStore)); + + Id3Extractor extractor; + riegeli::StringReader<> output_reader(output_s); + EXPECT_THAT(extractor.ExtractManifestStore(output_reader), + IsOkAndHolds(kManifestStore)); +} + +TEST(Id3EmbedderTest, CopyAndEmbedId3v24SynchsafeFrameSize) { + std::string mp3_file_contents = std::string( + kMp3FileWithId3v24NoC2paFrame, sizeof(kMp3FileWithId3v24NoC2paFrame)); + std::string manifest_store(300, 'a'); + riegeli::StringReader input(mp3_file_contents); + std::string output_s; + riegeli::StringWriter<> output(&output_s); + + Id3Embedder embedder; + auto manifest_store_offset = + embedder.CopyAndEmbed(input, manifest_store, output); + ABSL_ASSERT_OK(manifest_store_offset.status()); + ASSERT_TRUE(output.Close()); + + EXPECT_THAT(output_s.substr(*manifest_store_offset, manifest_store.size()), + Eq(manifest_store)); + + Id3Extractor extractor; + riegeli::StringReader<> output_reader(output_s); + EXPECT_THAT(extractor.ExtractManifestStore(output_reader), + IsOkAndHolds(manifest_store)); +} + +TEST(Id3EmbedderTest, CopyAndEmbedC2paAlreadyExists) { + std::string mp3_file_contents = std::string( + kMp3FileWithC2paManifestStore, sizeof(kMp3FileWithC2paManifestStore)); + absl::string_view kManifestStore = "C2PA manifest store bytes"; + riegeli::StringReader input(mp3_file_contents); + std::string output_s; + riegeli::StringWriter<> output(&output_s); + + Id3Embedder embedder; + EXPECT_THAT(embedder.CopyAndEmbed(input, kManifestStore, output), + StatusIs(absl::StatusCode::kAlreadyExists, + HasSubstr("C2PA manifest store already exists"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/id3/extractor_test.cc b/formats/id3/extractor_test.cc index 3331874..b8e302d 100644 --- a/formats/id3/extractor_test.cc +++ b/formats/id3/extractor_test.cc
@@ -28,7 +28,7 @@ namespace credentio { namespace { -using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; using ::absl_testing::StatusIs; using ::testing::AllOf; using ::testing::Eq; @@ -36,20 +36,6 @@ using ::testing::HasSubstr; using ::testing::Optional; -#ifndef ASSERT_OK_AND_ASSIGN -#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y -#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) - -#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ - ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) - -#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ - auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ - ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ - ::absl_testing::IsOk()); \ - lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) -#endif - constexpr unsigned char kId3TagWithC2paManifestStore[] = { 'I', 'D', '3', // ID3 file identifier 0x03, 0x00, // Version 2.3 @@ -132,7 +118,7 @@ riegeli::StringReader<> input(mp3_file_contents); Id3Extractor extractor; auto result = extractor.ExtractManifestStore(input); - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_THAT(*result, HasSubstr("manifest store")); } @@ -143,7 +129,7 @@ riegeli::StringReader<> input(mp3_file_contents); Id3Extractor extractor; auto result = extractor.ExtractManifestStore(input); - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_THAT(*result, HasSubstr("manifest store")); } @@ -166,13 +152,10 @@ sizeof(kId3TagWithC2paManifestStore)); riegeli::StringReader<> input(mp3_file_contents); Id3Extractor extractor; - ASSERT_OK_AND_ASSIGN( - auto manifest_store_location, - extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true})); - - EXPECT_THAT(manifest_store_location, - Optional(AllOf(Field(&ByteRange::offset, Eq(58)), - Field(&ByteRange::length, Eq(14))))); + EXPECT_THAT( + extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Eq(58)), + Field(&ByteRange::length, Eq(14)))))); } TEST(Id3ExtractorTest, ExtractBoxesUnimplemented) {
diff --git a/formats/id3/id3_test.cc b/formats/id3/id3_test.cc index dacab97..d30e43f 100644 --- a/formats/id3/id3_test.cc +++ b/formats/id3/id3_test.cc
@@ -27,7 +27,6 @@ namespace credentio { namespace { -using ::absl_testing::IsOk; using ::absl_testing::StatusIs; using ::testing::HasSubstr; @@ -44,7 +43,7 @@ riegeli::StringReader<> input(geob_frame_contents); auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/geob_frame_contents.size()); - ASSERT_THAT(geob_or, IsOk()); + ABSL_ASSERT_OK(geob_or); GeobFrame geob = *geob_or; EXPECT_EQ(geob.mime_type, "text/plain"); @@ -66,7 +65,7 @@ riegeli::StringReader<> input(geob_frame_contents); auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/geob_frame_contents.size()); - ASSERT_THAT(geob_or, IsOk()); + ABSL_ASSERT_OK(geob_or); GeobFrame geob = *geob_or; EXPECT_EQ(geob.mime_type, "text/plain"); @@ -88,7 +87,7 @@ riegeli::StringReader<> input(geob_frame_contents); auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/geob_frame_contents.size()); - ASSERT_THAT(geob_or, IsOk()); + ABSL_ASSERT_OK(geob_or); GeobFrame geob = *geob_or; EXPECT_EQ(geob.mime_type, "text/plain"); @@ -108,7 +107,7 @@ riegeli::StringReader<> input(geob_frame_contents); auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/geob_frame_contents.size()); - ASSERT_THAT(geob_or, IsOk()); + ABSL_ASSERT_OK(geob_or); GeobFrame geob = *geob_or; EXPECT_EQ(geob.mime_type, "text/plain"); @@ -128,7 +127,7 @@ riegeli::StringReader<> input(geob_frame_contents); auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/geob_frame_contents.size()); - ASSERT_THAT(geob_or, IsOk()); + ABSL_ASSERT_OK(geob_or); GeobFrame geob = *geob_or; EXPECT_EQ(geob.mime_type, "text/plain"); @@ -264,7 +263,7 @@ riegeli::StringReader<> input(geob_frame_contents); auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/geob_frame_contents.size()); - ASSERT_THAT(geob_or, IsOk()); + ABSL_ASSERT_OK(geob_or); GeobFrame geob = *geob_or; EXPECT_EQ(geob.mime_type, "text/plain"); @@ -281,7 +280,7 @@ auto id3_header_contents = std::string(id3_header, sizeof(id3_header)); riegeli::StringReader<> input(id3_header_contents); auto header_or = Id3Header::Parse(input); - ASSERT_THAT(header_or, IsOk()); + ABSL_ASSERT_OK(header_or); Id3Header header = *header_or; EXPECT_FALSE(header.use_unsynchronisation); @@ -303,7 +302,7 @@ auto id3_header_contents = std::string(id3_header, sizeof(id3_header)); riegeli::StringReader<> input(id3_header_contents); auto header_or = Id3Header::Parse(input); - ASSERT_THAT(header_or, IsOk()); + ABSL_ASSERT_OK(header_or); Id3Header header = *header_or; EXPECT_FALSE(header.use_unsynchronisation);
diff --git a/formats/id3/reader_test.cc b/formats/id3/reader_test.cc index 1e98844..7ec8436 100644 --- a/formats/id3/reader_test.cc +++ b/formats/id3/reader_test.cc
@@ -110,7 +110,7 @@ frames.push_back(frame); return true; }); - ASSERT_THAT(status, IsOk()); + ABSL_ASSERT_OK(status); ASSERT_TRUE(status.value().has_extended_header); EXPECT_THAT(frames, testing::UnorderedElementsAre(
diff --git a/formats/id3/writer.cc b/formats/id3/writer.cc new file mode 100644 index 0000000..2a0ea76 --- /dev/null +++ b/formats/id3/writer.cc
@@ -0,0 +1,121 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/id3/writer.h" + +#include <cstdint> +#include <string> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "formats/hasher_data.h" +#include "formats/id3/embedder.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/cord_reader.h" +#include "riegeli/bytes/cord_writer.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { +namespace { + +// Copies the input to the output cord, with the given manifest store embedded, +// and returns the offset of the manifest store in the result. +absl::StatusOr<int64_t> CopyAndEmbedCord(riegeli::Reader& input, + absl::string_view manifest_store, + riegeli::Writer& output) { + if (!input.Seek(0)) { + return absl::InternalError("failed to seek to start of input"); + } + Id3Embedder embedder; + ABSL_ASSIGN_OR_RETURN(int64_t manifest_store_offset, + embedder.CopyAndEmbed(input, manifest_store, output)); + return manifest_store_offset; +} +} // namespace + +absl::Status Id3Writer::Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination) { + if (request.has_hard_binding()) { + return absl::InvalidArgumentError( + "request must not include `hard_binding` (binding is computed by " + "Id3Writer)"); + } + + // The two-phase approach is required for ID3-compatible audio: The hash can't + // be computed correctly until the manifest store size is known, because the + // manifest size affects the top-level ID3 tag size. + + // Phase 1: Compute preliminary hard binding, produce a manifest store + // template, and embed a zero-filled placeholder in the asset. + HashAlgorithm alg = HashAlgorithm::kSha256; + ABSL_ASSIGN_OR_RETURN(*request.mutable_hard_binding()->mutable_data_hash(), + DataHashTemplate(alg)); + ABSL_ASSIGN_OR_RETURN(ManifestStoreTemplate manifest_store_template, + generator_.GenerateManifestStoreTemplate(request)); + constexpr int64_t kMaxManifestStoreSizeBytes = 100 * 1024 * 1024; // 100 MiB + if (manifest_store_template.manifest_store_size() <= 0 || + manifest_store_template.manifest_store_size() > + kMaxManifestStoreSizeBytes) { + return absl::InvalidArgumentError( + absl::StrCat("invalid manifest store template size: ", + manifest_store_template.manifest_store_size())); + } + absl::Cord phase1_result; + riegeli::CordWriter phase1_writer(&phase1_result); + ABSL_ASSIGN_OR_RETURN( + int64_t manifest_store_offset, + CopyAndEmbedCord( + *source, + std::string(manifest_store_template.manifest_store_size(), '\0'), + phase1_writer)); + if (!phase1_writer.Close()) { + return absl::InternalError("failed to close phase 1 writer"); + } + + // Phase 2: Compute final hard binding based on the updated content, finalize + // the manifest store, and embed the final manifest store in the asset. + riegeli::CordReader phase2_reader(&phase1_result); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + std::move(manifest_store_template); + ABSL_ASSIGN_OR_RETURN( + *finalize_request.mutable_hard_binding()->mutable_data_hash(), + DigestDataHash( + phase2_reader, HashAlgorithm::kSha256, + /*manifest_store_offset=*/manifest_store_offset, + /*manifest_store_length=*/ + finalize_request.manifest_store_template().manifest_store_size())); + ABSL_ASSIGN_OR_RETURN(auto response, + generator_.FinalizeManifestStore(finalize_request)); + if (response.manifest_store().size() != + finalize_request.manifest_store_template().manifest_store_size()) { + return absl::InternalError("manifest store size does not match template"); + } + destination->Write(phase1_result.Subcord(0, manifest_store_offset)); + destination->Write(response.manifest_store()); + destination->Write(phase1_result.Subcord( + manifest_store_offset + response.manifest_store().size(), + phase1_result.size())); + return absl::OkStatus(); +} +} // namespace credentio
diff --git a/formats/id3/writer.h b/formats/id3/writer.h new file mode 100644 index 0000000..562e709 --- /dev/null +++ b/formats/id3/writer.h
@@ -0,0 +1,51 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ID3_WRITER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_WRITER_H_ + +#include "absl/base/nullability.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "generator/generator.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// Writer of C2PA manifest stores into ID3-compatible audio assets. +class Id3Writer { + public: + // Creates a ID3 writer. + // + // The generator must outlive the writer. + explicit Id3Writer(const ManifestStoreGenerator* absl_nonnull generator) + : generator_(*ABSL_DIE_IF_NULL(generator)) {} + + // Generates a C2PA manifest store and embeds it into a copy of the given + // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the + // same underlying data. The request must not include `hard_binding`. + absl::Status Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination); + + private: + const ManifestStoreGenerator& generator_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ID3_WRITER_H_
diff --git a/formats/id3/writer_test.cc b/formats/id3/writer_test.cc new file mode 100644 index 0000000..36c3b47 --- /dev/null +++ b/formats/id3/writer_test.cc
@@ -0,0 +1,192 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/id3/writer.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/functional/any_invocable.h" +#include "absl/log/absl_check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "async/async_context.h" +#include "formats/byte_range.h" +#include "formats/id3/extractor.h" +#include "generator/generator.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/generator.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_status.pb.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "testing/nonprod_generator.h" +#include "testing/nonprod_validator.h" +#include "validator/asset_validator.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::AllOf; +using ::testing::Field; +using ::testing::Gt; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::Optional; + +constexpr char kMp3FileWithNoId3Tag[] = {'\xFF', '\xF3', '\x40', '\xC0', + 0x01, 0x02, 0x03, 0x04}; + +class Id3WriterTest : public ::testing::Test { + protected: + void SetUp() override { + auto generator = CreateNonProdManifestStoreGenerator({}); + ABSL_CHECK_OK(generator); + generator_ = std::move(*generator); + writer_ = std::make_unique<Id3Writer>(generator_.get()); + auto validator = CreateNonProdValidator({}); + ABSL_CHECK_OK(validator); + validator_ = std::move(*validator); + } + std::unique_ptr<ManifestStoreGenerator> generator_; + std::unique_ptr<Id3Writer> writer_; + std::unique_ptr<AssetValidator> validator_; +}; + +TEST_F(Id3WriterTest, Write) { + std::string mp3_file_contents = + std::string(kMp3FileWithNoId3Tag, sizeof(kMp3FileWithNoId3Tag)); + riegeli::StringReader source(mp3_file_contents); + ABSL_ASSERT_OK(source.status()); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + ABSL_ASSERT_OK(writer_->Write(request, &source, &destination)); + ASSERT_TRUE(source.Close()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader result_reader(result); + ABSL_ASSERT_OK(result_reader.status()); + + EXPECT_THAT(Id3Extractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)), + Field(&ByteRange::length, Gt(0)))))); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validation_result = validator_->Validate(result_reader, "audio/mpeg"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST_F(Id3WriterTest, WriteTwiceFails) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + + std::string mp3_file_contents = + std::string(kMp3FileWithNoId3Tag, sizeof(kMp3FileWithNoId3Tag)); + riegeli::StringReader source1(mp3_file_contents); + ABSL_ASSERT_OK(source1.status()); + std::string result1; + riegeli::StringWriter destination1(&result1); + ABSL_ASSERT_OK(destination1.status()); + ABSL_ASSERT_OK(writer_->Write(request, &source1, &destination1)); + ASSERT_TRUE(source1.Close()); + ASSERT_TRUE(destination1.Close()); + + riegeli::StringReader source2(result1); + ABSL_ASSERT_OK(source2.status()); + std::string result2; + riegeli::StringWriter destination2(&result2); + ABSL_ASSERT_OK(destination2.status()); + EXPECT_THAT( + writer_->Write(request, &source2, &destination2), + StatusIs(absl::StatusCode::kAlreadyExists, HasSubstr("already exists"))); +} + +TEST_F(Id3WriterTest, WriteWithHardBindingFails) { + std::string mp3_file_contents = + std::string(kMp3FileWithNoId3Tag, sizeof(kMp3FileWithNoId3Tag)); + riegeli::StringReader source(mp3_file_contents); + ABSL_ASSERT_OK(source.status()); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + request.mutable_hard_binding(); + EXPECT_THAT(writer_->Write(request, &source, &destination), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("must not include `hard_binding`"))); +} + +class BadTemplateSizeGenerator : public ManifestStoreGenerator { + public: + void GenerateManifestStore( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&> + cb) const override {} + + void GenerateManifestStoreTemplate( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb) + const override { + ManifestStoreTemplate t; + t.set_manifest_store_size(-1); + std::move(cb)(t); + } + + void FinalizeManifestStore( + FinalizeManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&> + cb) const override {} +}; + +TEST_F(Id3WriterTest, WriteWithBadTemplateSizeFails) { + BadTemplateSizeGenerator bad_generator; + Id3Writer writer(&bad_generator); + std::string mp3_file_contents = + std::string(kMp3FileWithNoId3Tag, sizeof(kMp3FileWithNoId3Tag)); + riegeli::StringReader source(mp3_file_contents); + std::string result; + riegeli::StringWriter destination(&result); + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + EXPECT_THAT(writer.Write(request, &source, &destination), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("invalid manifest store template size"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/jpeg/BUILD b/formats/jpeg/BUILD index 982f3a0..3ab0008 100644 --- a/formats/jpeg/BUILD +++ b/formats/jpeg/BUILD
@@ -19,6 +19,90 @@ package(default_visibility = ["//visibility:public"]) cc_library( + name = "app11_io", + srcs = ["app11_io.cc"], + hdrs = ["app11_io.h"], + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_reading", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "app11_io_test", + srcs = ["app11_io_test.cc"], + deps = [ + ":app11_io", + ":extractor", + "//constants:labels", + "//formats:asset_box", + "//formats:byte_range", + "//formats/jpeg/testing:jumbf_creator", + "//jumbf:box_builder", + "//jumbf:constants", + "//jumbf:encode_params", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/base:object", + "@riegeli//riegeli/bytes:limiting_writer", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +) + +cc_library( + name = "embedder", + srcs = ["embedder.cc"], + hdrs = ["embedder.h"], + deps = [ + ":app11_io", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:copy_all", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_test( + name = "embedder_test", + srcs = ["embedder_test.cc"], + deps = [ + ":embedder", + ":extractor", + "//constants:labels", + "//jumbf:box_builder", + "//jumbf:constants", + "//jumbf:encode_params", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( name = "reader", srcs = ["reader.cc"], hdrs = ["reader.h"], @@ -193,3 +277,93 @@ "@abseil-cpp//absl/status:statusor", ], ) + +cc_library( + name = "hasher", + srcs = ["hasher.cc"], + hdrs = ["hasher.h"], + deps = [ + ":assessor", + ":c2pa_segment_handler", + ":reader", + "//bindings:input_hasher", + "//crypto:algorithms", + "//formats:byte_range", + "//proto:data_hash_assertion_cc_proto", + "//proto:generator_cc_proto", + "//proto:multi_asset_hash_assertion_cc_proto", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:str_format", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "writer", + srcs = ["writer.cc"], + hdrs = ["writer.h"], + deps = [ + ":app11_io", + ":hasher", + ":reader", + "//async:async_context", + "//crypto:algorithms", + "//formats:byte_range", + "//generator", + "//proto:data_hash_assertion_cc_proto", + "//proto:generator_cc_proto", + "//utils:byte_instruction", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_test( + name = "writer_test", + srcs = ["writer_test.cc"], + deps = [ + ":extractor", + ":reader", + ":writer", + "//async:async_context", + "//formats:byte_range", + "//formats/jpeg/testing:jpeg_creator", + "//formats/jpeg/testing:jumbf_creator", + "//generator", + "//proto:generator_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:fake_manifest_store_generator", + "//testing:nonprod_generator", + "//testing:nonprod_validator", + "//testing:proto_test_utils", + "//validator:asset_validator", + "//validator:result", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +)
diff --git a/formats/jpeg/app11_io.cc b/formats/jpeg/app11_io.cc new file mode 100644 index 0000000..fa32054 --- /dev/null +++ b/formats/jpeg/app11_io.cc
@@ -0,0 +1,157 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/jpeg/app11_io.h" + +#include <cstddef> +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_reading.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { +namespace { + +constexpr uint16_t kMaxPayloadSegmentByteSize = 65000; + +struct JumbfHeader { + uint32_t lbox; + uint32_t tbox; + uint64_t xlbox; + + uint64_t byte_size() const { + return sizeof(lbox) + sizeof(tbox) + (lbox == 1 ? sizeof(xlbox) : 0); + } + uint64_t box_size() const { return lbox == 1 ? xlbox : lbox; } +}; + +absl::StatusOr<JumbfHeader> ConsumePayloadJumbfHeader(riegeli::Reader& reader) { + JumbfHeader header; + if (!riegeli::ReadBigEndian<uint32_t>(reader, header.lbox)) { + return reader.StatusOrAnnotate(absl::DataLossError("Failed to read lbox")); + } + if (!riegeli::ReadBigEndian<uint32_t>(reader, header.tbox)) { + return reader.StatusOrAnnotate(absl::DataLossError("Failed to read tbox")); + } + if (header.lbox == 1) { + if (!riegeli::ReadBigEndian<uint64_t>(reader, header.xlbox)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to read xlbox")); + } + } + return header; +} + +absl::Status WritePayloadChunk(const JumbfHeader& header, + absl::string_view payload_chunk, + uint16_t segment_id, uint32_t sequence_num, + riegeli::Writer& output) { + if (!riegeli::WriteBigEndian<uint16_t>(0xFFEB, output)) { + return output.status(); + } + uint64_t total_seg_length = + sizeof(uint16_t) /*seg_length*/ + sizeof(uint16_t) /*ext_type*/ + + sizeof(uint16_t) /*segment_id*/ + sizeof(uint32_t) /*sequence_num*/ + + header.byte_size() + payload_chunk.size(); + if (total_seg_length > UINT16_MAX) { + return absl::OutOfRangeError("maximum segment size too large"); + } + uint16_t seg_length = static_cast<uint16_t>(total_seg_length); + if (!riegeli::WriteBigEndian<uint16_t>(seg_length, output)) { + return output.status(); + } + // JPEG extension type. + if (!riegeli::WriteBigEndian<uint16_t>(0x4A50, output)) { + return output.status(); + } + if (!riegeli::WriteBigEndian<uint16_t>(segment_id, output)) { + return output.status(); + } + if (!riegeli::WriteBigEndian<uint32_t>(sequence_num, output)) { + return output.status(); + } + if (!riegeli::WriteBigEndian<uint32_t>(header.lbox, output)) { + return output.status(); + } + if (!riegeli::WriteBigEndian<uint32_t>(header.tbox, output)) { + return output.status(); + } + if (header.lbox == 1) { + if (!riegeli::WriteBigEndian<uint64_t>(header.xlbox, output)) { + return output.status(); + } + } + if (!output.Write(payload_chunk)) { + return output.status(); + } + return absl::OkStatus(); +} +} // namespace + +uint64_t CalculateTotalJpegApp11SegmentsSize(uint64_t jumbf_payload_size) { + uint64_t jpeg_header_size = sizeof(uint16_t) + // marker + sizeof(uint16_t) + // size + sizeof(uint16_t) + // ext_type + sizeof(uint16_t) + // segment_id + sizeof(uint32_t); // sequence_num + uint64_t jumbf_header_size = + JumbfHeader{.lbox = static_cast<uint32_t>( + jumbf_payload_size > UINT32_MAX ? 1 : 0)} + .byte_size(); + + uint64_t payload_size_without_header = jumbf_payload_size - jumbf_header_size; + uint64_t number_of_chunks = + (payload_size_without_header / kMaxPayloadSegmentByteSize) + + (payload_size_without_header % kMaxPayloadSegmentByteSize != 0); + + return ((jpeg_header_size + jumbf_header_size) * number_of_chunks) + + payload_size_without_header; +} + +absl::Status WriteJpegApp11Payload(uint16_t segment_id, + absl::string_view payload, + riegeli::Writer& output) { + riegeli::StringReader<> payload_header_reader(payload.substr(0, 16)); + ABSL_ASSIGN_OR_RETURN(auto header, + ConsumePayloadJumbfHeader(payload_header_reader)); + if (header.box_size() != payload.size()) { + return absl::InvalidArgumentError(absl::Substitute( + "invalid jumbf box -- box size does not match payload size: $0 vs $1", + header.box_size(), payload.size())); + } + uint32_t seq_num = 1; // Starts at 1 per ISO/IEC 19566-5. + size_t cursor = header.byte_size(); + while (cursor < payload.size()) { + uint16_t chunk_size = kMaxPayloadSegmentByteSize; + ABSL_RETURN_IF_ERROR(WritePayloadChunk(header, + payload.substr(cursor, chunk_size), + segment_id, seq_num, output)); + cursor += chunk_size; + if (++seq_num == 0) { + return absl::OutOfRangeError(absl::Substitute( + "too many segments required for payload of size $0", payload.size())); + } + } + return absl::OkStatus(); +} +} // namespace credentio
diff --git a/formats/jpeg/app11_io.h b/formats/jpeg/app11_io.h new file mode 100644 index 0000000..0bd56ba --- /dev/null +++ b/formats/jpeg/app11_io.h
@@ -0,0 +1,40 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_APP11_IO_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_APP11_IO_H_ + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// Given the size of a jumbf payload in bytes, it will return the total number +// of bytes required to store the payload across n APP11 segments including all +// JPEG markers and APP11 headers. The number of segments needed is based on a +// maximum segment size defined by kMaxPayloadSegmentByteSize in the .cc file. +uint64_t CalculateTotalJpegApp11SegmentsSize(uint64_t jumbf_payload_size); + +// Writes the given payload as continuous APP11 segments with the same +// segment_id. The payload must be a valid JUMBF box. +absl::Status WriteJpegApp11Payload(uint16_t segment_id, + absl::string_view payload, + riegeli::Writer& output); +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_APP11_IO_H_
diff --git a/formats/jpeg/app11_io_test.cc b/formats/jpeg/app11_io_test.cc new file mode 100644 index 0000000..8069de8 --- /dev/null +++ b/formats/jpeg/app11_io_test.cc
@@ -0,0 +1,198 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/jpeg/app11_io.h" + +#include <cstddef> +#include <cstdint> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/jpeg/extractor.h" +#include "formats/jpeg/testing/jumbf_creator.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box_builder.h" +#include "jumbf/constants.h" +#include "jumbf/encode_params.h" +#include "riegeli/base/object.h" +#include "riegeli/bytes/limiting_writer.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::ElementsAre; +using ::testing::HasSubstr; +using ::testing::Optional; + +// Creates a jumbf SuperBox that looks superficially like a Manifest Store. +absl::StatusOr<std::string> CreateFakeManifestStore(size_t content_size) { + jumbf::SuperBoxBuilder manifest_store_builder( + kManifestStoreUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord(kManifestStoreLabel)}); + ABSL_RETURN_IF_ERROR(manifest_store_builder.AddContent( + jumbf::kBinaryDataBoxType, absl::Cord(std::string(content_size, 'x')))); + ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox manifest_store_box, + std::move(manifest_store_builder).Finalize()); + return std::string(std::move(manifest_store_box).AsCord()); +} + +TEST(App11IOTest, WriteSmallPayload) { + auto manifest_store_or = CreateFakeManifestStore(/*content_size=*/100); + ABSL_ASSERT_OK(manifest_store_or); + const auto& manifest_store = *manifest_store_or; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK( + WriteJpegApp11Payload(/*segment_id=*/30, manifest_store, output)); + ASSERT_TRUE(output.Close()); + + riegeli::StringReader<absl::string_view> input(output_s); + JpegExtractor extractor; + + EXPECT_THAT(extractor.ExtractManifestStore(input), + IsOkAndHolds(manifest_store)); + + ASSERT_TRUE(input.Seek(0)); + + EXPECT_THAT(extractor.ExtractManifestStoreLocation(input, {}), + IsOkAndHolds(Optional(ByteRange{.offset = 0, .length = 158}))); + + ASSERT_TRUE(input.Seek(0)); + + EXPECT_THAT( + extractor.ExtractBoxes(input, {}), + IsOkAndHolds(ElementsAre(AssetBox{ + .identifier = "C2PA", .byte_range = {.offset = 0, .length = 158}}))); +} + +TEST(App11IOTest, WriteLargePayload) { + auto manifest_store_or = CreateFakeManifestStore(/*content_size=*/250000); + ABSL_ASSERT_OK(manifest_store_or); + const auto& manifest_store = *manifest_store_or; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK( + WriteJpegApp11Payload(/*segment_id=*/30, manifest_store, output)); + ASSERT_TRUE(output.Close()); + + riegeli::StringReader<absl::string_view> input(output_s); + + JpegExtractor extractor; + EXPECT_THAT(extractor.ExtractManifestStore(input), + IsOkAndHolds(manifest_store)); + + ASSERT_TRUE(input.Seek(0)); + + EXPECT_THAT(extractor.ExtractManifestStoreLocation(input, {}), + IsOkAndHolds(Optional(ByteRange{.offset = 0, .length = 250118}))); + + ASSERT_TRUE(input.Seek(0)); + + EXPECT_THAT(extractor.ExtractBoxes(input, {}), + IsOkAndHolds(ElementsAre( + AssetBox{.identifier = "C2PA", + .byte_range = {.offset = 0, .length = 250118}}))); +} + +TEST(App11IOTest, WriteInvalidPayload) { + std::string output_s; + riegeli::StringWriter<> output(&output_s); + EXPECT_THAT( + WriteJpegApp11Payload(/*segment_id=*/30, std::string(6, '\0'), output), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("Failed to read tbox"))); +} + +TEST(App11IOTest, WriteInvalidJumbfPayload) { + std::string output_s; + riegeli::StringWriter<> output(&output_s); + EXPECT_THAT( + WriteJpegApp11Payload(/*segment_id=*/30, std::string(60, '\0'), output), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("invalid jumbf box"))); +} + +TEST(App11IOTest, WritePayloadWithXlbox) { + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_EXPECT_OK(WriteJpegApp11Payload( + /*segment_id=*/30, CreateJumbf({.force_xlbox = true}), output)); + ASSERT_TRUE(output.Close()); +} + +TEST(App11IOTest, WritePayloadFails) { + auto manifest_store_or = CreateFakeManifestStore(/*content_size=*/100); + ABSL_ASSERT_OK(manifest_store_or); + const auto& manifest_store = *manifest_store_or; + + std::string output_s; + riegeli::StringWriter<> string_writer(&output_s); + // Limit the writer to 1 byte, forcing a failure. + riegeli::LimitingWriter<> limited_writer( + &string_writer, riegeli::LimitingWriterBase::Options().set_max_length(1)); + EXPECT_THAT( + WriteJpegApp11Payload(/*segment_id=*/30, manifest_store, limited_writer), + StatusIs(absl::StatusCode::kResourceExhausted)); + EXPECT_FALSE(limited_writer.Close()); +} + +TEST(App11IOTest, WritePayloadFailsImmediately) { + auto manifest_store_or = CreateFakeManifestStore(/*content_size=*/100); + ABSL_ASSERT_OK(manifest_store_or); + const auto& manifest_store = *manifest_store_or; + + riegeli::StringWriter<> failing_writer(riegeli::kClosed); + failing_writer.Fail(absl::InternalError("Test failure")); + EXPECT_THAT( + WriteJpegApp11Payload(/*segment_id=*/30, manifest_store, failing_writer), + StatusIs(absl::StatusCode::kInternal, HasSubstr("Test failure"))); + EXPECT_FALSE(failing_writer.Close()); +} + +TEST(App11IOTest, CalculateSizeOneSegment) { + EXPECT_EQ(CalculateTotalJpegApp11SegmentsSize(1000), 1012); +} + +TEST(App11IOTest, CalculateSizeExactSingleSegmentSize) { + EXPECT_EQ(CalculateTotalJpegApp11SegmentsSize(65008), 65020); +} + +TEST(App11IOTest, CalculateSizeMultipleSegments) { + EXPECT_EQ(CalculateTotalJpegApp11SegmentsSize(65200), 65232); +} + +TEST(App11IOTest, CalculateSizeOf64BitPayload) { + EXPECT_EQ(CalculateTotalJpegApp11SegmentsSize( + static_cast<uint64_t>(UINT32_MAX) + 1000), + 4296818435); +} + +} // namespace +} // namespace credentio
diff --git a/formats/jpeg/box_header_test.cc b/formats/jpeg/box_header_test.cc index 77ffdf9..9d35f8b 100644 --- a/formats/jpeg/box_header_test.cc +++ b/formats/jpeg/box_header_test.cc
@@ -28,7 +28,6 @@ namespace credentio { namespace { -using ::absl_testing::IsOk; using ::absl_testing::StatusIs; using ::testing::Range; using ::testing::TestParamInfo; @@ -58,7 +57,7 @@ } auto header_or = ConsumeJpegBoxHeader(input); - ASSERT_THAT(header_or, IsOk()); + ABSL_ASSERT_OK(header_or); JpegBoxHeader header = *header_or; EXPECT_EQ(header.offset, 0);
diff --git a/formats/jpeg/embedder.cc b/formats/jpeg/embedder.cc new file mode 100644 index 0000000..b7f13af --- /dev/null +++ b/formats/jpeg/embedder.cc
@@ -0,0 +1,48 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/jpeg/embedder.h" + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/strings/string_view.h" +#include "formats/jpeg/app11_io.h" +#include "riegeli/bytes/copy_all.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +absl::Status JpegEmbedder::CopyAndEmbed(riegeli::Reader& input, + absl::string_view manifest_store, + int64_t insert_at_offset, + riegeli::Writer& output) const { + int64_t cursor = input.pos(); + + // Copy the data up to the insert point. + if (!input.Copy(insert_at_offset - cursor, output)) { + return input.StatusOrAnnotate(absl::DataLossError("failed to copy input")); + } + + // Embed the manifest store. + ABSL_RETURN_IF_ERROR( + WriteJpegApp11Payload(segment_id_, manifest_store, output)); + + // Copy the rest of the file. + return riegeli::CopyAll(input, output); +} +} // namespace credentio
diff --git a/formats/jpeg/embedder.h b/formats/jpeg/embedder.h new file mode 100644 index 0000000..4421286 --- /dev/null +++ b/formats/jpeg/embedder.h
@@ -0,0 +1,47 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_EMBEDDER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_EMBEDDER_H_ + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { +// A utility class to embed a C2PA manifest store as one or more APP11 segments +// into a non-C2PA JPEG file. +class JpegEmbedder { + public: + explicit JpegEmbedder(uint16_t segment_id) : segment_id_(segment_id) {} + // Copies the data from the input to the output, embeds the manifest store + // as one or more APP11 segments before the first segment other than SOI + // and APP0..APP10. + // NOTE: Input and output must be disjoint, i.e., in-place embedding is not + // supported. + absl::Status CopyAndEmbed(riegeli::Reader& input, + absl::string_view manifest_store, + int64_t insert_at_offset, + riegeli::Writer& output) const; + + private: + uint16_t segment_id_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_EMBEDDER_H_
diff --git a/formats/jpeg/embedder_test.cc b/formats/jpeg/embedder_test.cc new file mode 100644 index 0000000..aae24a2 --- /dev/null +++ b/formats/jpeg/embedder_test.cc
@@ -0,0 +1,160 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/jpeg/embedder.h" + +#include <cstddef> +#include <cstdint> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "constants/labels.h" +#include "formats/jpeg/extractor.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box_builder.h" +#include "jumbf/constants.h" +#include "jumbf/encode_params.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::testing::AllOf; +using ::testing::HasSubstr; + +absl::StatusOr<std::string> CreateFakeManifestStore(size_t content_size = 100) { + jumbf::SuperBoxBuilder manifest_store_builder( + kManifestStoreUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord(kManifestStoreLabel)}); + ABSL_RETURN_IF_ERROR(manifest_store_builder.AddContent( + jumbf::kBinaryDataBoxType, absl::Cord(std::string(content_size, 'x')))); + ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox manifest_store_box, + std::move(manifest_store_builder).Finalize()); + return std::string(std::move(manifest_store_box).AsCord()); +} + +struct JpegSegment { + uint16_t marker; + std::string payload; +}; + +absl::StatusOr<std::string> CreateJpeg(absl::Span<const JpegSegment> segments) { + std::string buffer; + riegeli::StringWriter<> output(&buffer); + for (auto segment : segments) { + if (!riegeli::WriteBigEndian<uint16_t>(segment.marker, output)) { + return output.status(); + } + if (!segment.payload.empty()) { + uint16_t seg_size = sizeof(uint16_t) + segment.payload.size(); + if (!riegeli::WriteBigEndian<uint16_t>(seg_size, output)) { + return output.status(); + } + if (!output.Write(segment.payload)) { + return output.status(); + } + } + } + output.Close(); + return buffer; +} + +TEST(JpegEmbedderTest, CopyAndEmbedAfterSOI) { + auto input_s_or = CreateJpeg({{.marker = /*SOI*/ 0xffd8, .payload = ""}}); + ABSL_ASSERT_OK(input_s_or); + const auto& input_s = *input_s_or; + + riegeli::StringReader<absl::string_view> input(input_s); + std::string output_s; + riegeli::StringWriter output(&output_s); + auto manifest_store_or = CreateFakeManifestStore(); + ABSL_ASSERT_OK(manifest_store_or); + const auto& manifest_store = *manifest_store_or; + + JpegEmbedder embedder(/*segment_id=*/30); + ABSL_ASSERT_OK(embedder.CopyAndEmbed(input, manifest_store, + /*insert_at_offset=*/2, output)); + output.Close(); + + riegeli::StringReader<absl::string_view> output_reader(output_s); + EXPECT_THAT(JpegExtractor().ExtractManifestStore(output_reader), + IsOkAndHolds(manifest_store)); +} + +TEST(JpegEmbedderTest, CopyAndEmbedBeforeSOS) { + auto input_s_or = CreateJpeg({{.marker = /*SOI*/ 0xffd8, .payload = ""}, + {.marker = /*SOS*/ 0xffda, .payload = "abcd"}, + {.marker = /*EOI*/ 0xffd9, .payload = ""}}); + ABSL_ASSERT_OK(input_s_or); + const auto& input_s = *input_s_or; + + riegeli::StringReader input(input_s); + std::string output_s; + riegeli::StringWriter output(&output_s); + auto manifest_store_or = CreateFakeManifestStore(); + ABSL_ASSERT_OK(manifest_store_or); + const auto& manifest_store = *manifest_store_or; + + JpegEmbedder embedder(/*segment_id=*/30); + ABSL_ASSERT_OK(embedder.CopyAndEmbed(input, manifest_store, + /*insert_at_offset=*/2, output)); + output.Close(); + + riegeli::StringReader output_reader(output_s); + EXPECT_THAT(JpegExtractor().ExtractManifestStore(output_reader), + IsOkAndHolds(manifest_store)); +} + +TEST(JpegEmbedderTest, CopyAndEmbedInAppSegments) { + auto input_s_or = + CreateJpeg({{.marker = /*SOI*/ 0xffd8, .payload = ""}, + {.marker = /*APP0*/ 0xFFE0, .payload = "abcd"}, + {.marker = /*APP13*/ 0xFFED, .payload = "efgh"}, + {.marker = /*APP0*/ 0xFFE0, .payload = "ijkl"}}); + ABSL_ASSERT_OK(input_s_or); + const auto& input_s = *input_s_or; + + riegeli::StringReader input(input_s); + std::string output_s; + riegeli::StringWriter output(&output_s); + auto manifest_store_or = CreateFakeManifestStore(); + ABSL_ASSERT_OK(manifest_store_or); + const auto& manifest_store = *manifest_store_or; + + JpegEmbedder embedder(/*segment_id=*/30); + ABSL_ASSERT_OK(embedder.CopyAndEmbed(input, manifest_store, + /*insert_at_offset=*/10, output)); + output.Close(); + EXPECT_THAT(output_s, + AllOf(HasSubstr("abcd"), HasSubstr("efgh"), HasSubstr("ijkl"))); + + riegeli::StringReader output_reader(output_s); + EXPECT_THAT(JpegExtractor().ExtractManifestStore(output_reader), + IsOkAndHolds(manifest_store)); +} + +} // namespace +} // namespace credentio
diff --git a/formats/jpeg/extractor_test.cc b/formats/jpeg/extractor_test.cc index df119ff..dcca14d 100644 --- a/formats/jpeg/extractor_test.cc +++ b/formats/jpeg/extractor_test.cc
@@ -42,24 +42,11 @@ namespace credentio { namespace { +using ::absl_testing::IsOkAndHolds; using ::absl_testing::StatusIs; using ::testing::Eq; using ::testing::HasSubstr; -#ifndef ASSERT_OK_AND_ASSIGN -#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y -#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) - -#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ - ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) - -#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ - auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ - ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ - ::absl_testing::IsOk()); \ - lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) -#endif - std::vector<AssetBox> CreateBoxes( std::vector<std::pair<std::string, uint32_t>> expected) { uint64_t offset = 0; @@ -147,117 +134,115 @@ }; TEST_P(ExtractorTest, Extract) { - ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam())); - riegeli::StringReader input(image); + absl::StatusOr<std::string> image = CreateImage(GetParam()); + ABSL_ASSERT_OK(image); + riegeli::StringReader input(*image); absl::StatusOr<std::string> extractor_result = JpegExtractor().ExtractManifestStore(input); - if (GetParam().expected.ok()) { - ASSERT_OK_AND_ASSIGN(const std::string& manifest_store, extractor_result); - ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected); - EXPECT_THAT(manifest_store, Eq(expected.manifest_store)); + const absl::StatusOr<ExtractorResult>& expected = GetParam().expected; + if (expected.ok()) { + EXPECT_THAT(extractor_result, IsOkAndHolds(Eq(expected->manifest_store))); } else { EXPECT_THAT(extractor_result, - StatusIs(GetParam().expected.status().code(), - HasSubstr(GetParam().expected.status().message()))); + StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); } } TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) { - ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam())); - std::string contents = "padding" + image; + absl::StatusOr<std::string> image = CreateImage(GetParam()); + ABSL_ASSERT_OK(image); + std::string contents = "padding" + *image; riegeli::StringReader input(contents); ASSERT_TRUE(input.Seek(7)); absl::StatusOr<std::string> extractor_result = JpegExtractor().ExtractManifestStore(input); - if (GetParam().expected.ok()) { - ASSERT_OK_AND_ASSIGN(const std::string& manifest_store, extractor_result); - ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected); - EXPECT_THAT(manifest_store, Eq(expected.manifest_store)); + const absl::StatusOr<ExtractorResult>& expected = GetParam().expected; + if (expected.ok()) { + EXPECT_THAT(extractor_result, IsOkAndHolds(Eq(expected->manifest_store))); } else { EXPECT_THAT(extractor_result, - StatusIs(GetParam().expected.status().code(), - HasSubstr(GetParam().expected.status().message()))); + StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); } } TEST_P(ExtractorTest, ExtractManifestStoreLocation) { - ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam())); - riegeli::StringReader input(image); + absl::StatusOr<std::string> image = CreateImage(GetParam()); + ABSL_ASSERT_OK(image); + riegeli::StringReader input(*image); absl::StatusOr<std::optional<ByteRange>> result = JpegExtractor().ExtractManifestStoreLocation(input, {}); - if (GetParam().expected.ok()) { - ASSERT_OK_AND_ASSIGN(auto manifest_store_location, result); - ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected); - EXPECT_THAT(manifest_store_location, - Eq(expected.asset_byte_info.manifest_store_location)); + const absl::StatusOr<ExtractorResult>& expected = GetParam().expected; + if (expected.ok()) { + EXPECT_THAT( + result, + IsOkAndHolds(Eq(expected->asset_byte_info.manifest_store_location))); } else { - EXPECT_THAT(result, - StatusIs(GetParam().expected.status().code(), - HasSubstr(GetParam().expected.status().message()))); + EXPECT_THAT(result, StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); } } TEST_P(ExtractorTest, ExtractBoxes) { - ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam())); - riegeli::StringReader input(image); + absl::StatusOr<std::string> image = CreateImage(GetParam()); + ABSL_ASSERT_OK(image); + riegeli::StringReader input(*image); absl::StatusOr<std::vector<AssetBox>> result = JpegExtractor().ExtractBoxes(input, {}); - if (GetParam().expected.ok()) { - ASSERT_OK_AND_ASSIGN(const auto& boxes, result); - ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected); - EXPECT_THAT(boxes, Eq(expected.asset_byte_info.boxes)); + const absl::StatusOr<ExtractorResult>& expected = GetParam().expected; + if (expected.ok()) { + EXPECT_THAT(result, IsOkAndHolds(Eq(expected->asset_byte_info.boxes))); } else { - EXPECT_THAT(result, - StatusIs(GetParam().expected.status().code(), - HasSubstr(GetParam().expected.status().message()))); + EXPECT_THAT(result, StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); } } TEST_P(ExtractorTest, ExtractManifestStoreLocationWithSuffix) { - ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam())); - std::string contents = image + "suffix"; + absl::StatusOr<std::string> image = CreateImage(GetParam()); + ABSL_ASSERT_OK(image); + std::string contents = *image + "suffix"; riegeli::StringReader input(contents); absl::StatusOr<std::optional<ByteRange>> result = JpegExtractor().ExtractManifestStoreLocation( input, {.end_offset = static_cast<int64_t>(contents.size() - 6)}); - if (GetParam().expected.ok()) { - ASSERT_OK_AND_ASSIGN(auto manifest_store_location, result); - ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected); - EXPECT_THAT(manifest_store_location, - Eq(expected.asset_byte_info.manifest_store_location)); + const absl::StatusOr<ExtractorResult>& expected = GetParam().expected; + if (expected.ok()) { + EXPECT_THAT( + result, + IsOkAndHolds(Eq(expected->asset_byte_info.manifest_store_location))); } else { - EXPECT_THAT(result, - StatusIs(GetParam().expected.status().code(), - HasSubstr(GetParam().expected.status().message()))); + EXPECT_THAT(result, StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); } } TEST_P(ExtractorTest, ExtractBoxesWithSuffix) { - ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam())); - std::string contents = image + "suffix"; + absl::StatusOr<std::string> image = CreateImage(GetParam()); + ABSL_ASSERT_OK(image); + std::string contents = *image + "suffix"; riegeli::StringReader input(contents); absl::StatusOr<std::vector<AssetBox>> result = JpegExtractor().ExtractBoxes( input, {.end_offset = static_cast<int64_t>(contents.size()) - 6}); - if (GetParam().expected.ok()) { - ASSERT_OK_AND_ASSIGN(const auto& boxes, result); - ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected); - EXPECT_THAT(boxes, Eq(expected.asset_byte_info.boxes)); + const absl::StatusOr<ExtractorResult>& expected = GetParam().expected; + if (expected.ok()) { + EXPECT_THAT(result, IsOkAndHolds(Eq(expected->asset_byte_info.boxes))); } else { - EXPECT_THAT(result, - StatusIs(GetParam().expected.status().code(), - HasSubstr(GetParam().expected.status().message()))); + EXPECT_THAT(result, StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); } }
diff --git a/formats/jpeg/hasher.cc b/formats/jpeg/hasher.cc new file mode 100644 index 0000000..cc88f60 --- /dev/null +++ b/formats/jpeg/hasher.cc
@@ -0,0 +1,331 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/jpeg/hasher.h" + +#include <algorithm> +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/container/flat_hash_set.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "bindings/input_hasher.h" +#include "crypto/algorithms.h" +#include "formats/byte_range.h" +#include "formats/jpeg/assessor.h" +#include "formats/jpeg/c2pa_segment_handler.h" +#include "formats/jpeg/reader.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/generator.pb.h" +#include "proto/multi_asset_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +namespace { + +constexpr uint64_t kMaxChunkSize = 1024 * 1024; // 1 MiB + +// Updates the given hashers with the indicated content, leaving the input +// position at the end of the content. +absl::Status UpdateTwoHashers(riegeli::Reader& input, int64_t offset, + int64_t length, InputHasher& hasher1, + InputHasher& hasher2) { + if (!input.Seek(offset)) { + return absl::InvalidArgumentError( + absl::StrCat("Failed to seek to offset ", offset)); + } + + int64_t chunk_size = kMaxChunkSize; + for (int64_t i = 0; i < length; i += chunk_size) { + int64_t l = std::min(length - i, chunk_size); + + std::string data; + if (!input.Read(l, data)) { + return absl::InvalidArgumentError("Failed to read data"); + } + + hasher1.Update(data); + hasher2.Update(data); + } + + return absl::OkStatus(); +} + +// Returns true if the current box hash range should end based on the next +// segment header and whether a C2PA segment needs to be injected. +bool InsertC2paBeforeSegment(absl::string_view next_segment_label) { + if (next_segment_label == "SOI") { + return false; + } + if (!absl::StartsWith(next_segment_label, "APP")) { + // Inject before any non-APP segments. + return true; + } + if (next_segment_label == "APP2") { + // Inject before the APP2 segment. + return true; + } + if (next_segment_label == "APP11") { + // Inject before any existing APP11 segments. + return true; + } + return false; +} + +absl::StatusOr<uint16_t> GetUnusedSegmentId( + const absl::flat_hash_set<uint16_t>& used_segment_ids) { + if (used_segment_ids.size() == UINT16_MAX) { + return absl::InvalidArgumentError("No Available Segment ID"); + } + uint16_t next_segment_id = 1; + while (used_segment_ids.contains(next_segment_id)) { + ++next_segment_id; + } + return next_segment_id; +} + +// Computes data hash(es) for a JPEG asset (which may be a part of a multi-part +// asset). If the starting input position is 0, then +// * a placeholder exclusion will be added for the C2PA segment +// * the overall file hash will be computed (result.hard_binding.data_hash) +// In all cases, a part hash will be computed, covering the asset starting at +// the current input position and ending at the next EOI marker. +// +// If `exclusion_processor` is provided, it is invoked for each segment to +// determine byte ranges to exclude from the hashes. +absl::Status HashJpeg(riegeli::Reader& input, HashAlgorithm alg, + std::optional<JpegExclusionProcessor> exclusion_processor, + InputHasher& asset_hasher, DigestJpegResult& result) { + int64_t asset_start_offset = input.pos(); + bool is_first_asset = asset_start_offset == 0; + + C2paSegmentHandler c2pa_segment_handler; + absl::flat_hash_set<uint16_t> used_segment_ids; + + std::optional<JpegInsertionPoint> insertion_point; + + ABSL_ASSIGN_OR_RETURN(auto part_hasher, InputHasher::Create(alg)); + + PartBinding* part_binding = result.hard_binding.add_part_bindings(); + part_binding->mutable_location()->set_byte_offset(asset_start_offset); + part_binding->set_is_optional(!is_first_asset); + part_binding->mutable_data_hash()->set_alg(absl::StrCat(alg)); + + std::optional<JpegSegment> data_past_asset = std::nullopt; + + ABSL_RETURN_IF_ERROR(IterateOverJpegSegments( + input, [&](const JpegSegment& segment) -> absl::StatusOr<bool> { + if (segment.marker_label == "c2pa.after") { + data_past_asset = std::move(segment); + return false; // stop iteration + } + + if (segment.app11_info.has_value()) { + used_segment_ids.insert(segment.app11_info->segment_id); + } + + if (is_first_asset && !insertion_point.has_value() && + InsertC2paBeforeSegment(segment.marker_label)) { + // Add an exclusion placeholder for the C2PA segment. Length will be + // filled in during encoding. + auto* asset_exclusion = + result.hard_binding.mutable_data_hash()->add_exclusions(); + asset_exclusion->set_start(segment.offset); + asset_exclusion->set_length(0); + + auto* part_exclusion = + part_binding->mutable_data_hash()->add_exclusions(); + part_exclusion->set_start(segment.offset - asset_start_offset); + part_exclusion->set_length(0); + + insertion_point = JpegInsertionPoint{ + .segment_id = 1, // to be updated with the next available ID + .offset = segment.offset, + }; + } + + std::vector<ByteRange> exclusions; + if (exclusion_processor.has_value()) { + ABSL_ASSIGN_OR_RETURN(exclusions, + (*exclusion_processor)(input, segment)); + } + int64_t offset = segment.offset; + for (ByteRange exclusion : exclusions) { + uint64_t segment_end = segment.offset + segment.length; + if (exclusion.offset < offset || exclusion.offset > segment_end || + exclusion.length > segment_end - exclusion.offset) { + return absl::InvalidArgumentError(absl::StrCat( + "Invalid or overlapping exclusion range [", exclusion.offset, + ", ", exclusion.offset + exclusion.length, ") for segment at ", + segment.offset)); + } + + auto* asset_exclusion = + result.hard_binding.mutable_data_hash()->add_exclusions(); + asset_exclusion->set_start(exclusion.offset); + asset_exclusion->set_length(exclusion.length); + + auto* part_exclusion = + part_binding->mutable_data_hash()->add_exclusions(); + part_exclusion->set_start(exclusion.offset - asset_start_offset); + part_exclusion->set_length(exclusion.length); + + ABSL_RETURN_IF_ERROR(UpdateTwoHashers(input, offset, + exclusion.offset - offset, + asset_hasher, *part_hasher)); + offset = exclusion.offset + exclusion.length; + } + if (offset < (segment.offset + segment.length)) { + ABSL_RETURN_IF_ERROR(UpdateTwoHashers( + input, offset, segment.offset + segment.length - offset, + asset_hasher, *part_hasher)); + } + + c2pa_segment_handler.RecordSegment(segment); + return true; + })); + + if (is_first_asset) { + absl::Status c2pa_segment_status = + c2pa_segment_handler.GetC2paSegment(input).status(); + if (c2pa_segment_status.ok() || + absl::StrContains(c2pa_segment_status.message(), "multiple")) { + return absl::AlreadyExistsError("C2PA chunk already exists"); + } + + if (!insertion_point.has_value()) { + return absl::InvalidArgumentError( + "No valid C2PA insertion point found in JPEG"); + } + ABSL_ASSIGN_OR_RETURN(uint16_t next_segment_id, + GetUnusedSegmentId(used_segment_ids)); + insertion_point->segment_id = next_segment_id; + + result.insertion_point = std::move(*insertion_point); + } + + if (data_past_asset.has_value()) { + if (!input.Seek(data_past_asset->offset)) { + return absl::InvalidArgumentError("Failed to seek to data past asset"); + } + } + + part_binding->mutable_location()->set_length(input.pos() - + asset_start_offset); + part_binding->mutable_data_hash()->set_hash(part_hasher->Digest()); + return absl::OkStatus(); +} + +// Adds a part hash covering the remaining bytes of the input, marked as +// optional. +absl::Status HashRemainingBytes(riegeli::Reader& input, HashAlgorithm alg, + InputHasher& asset_hasher, + DigestJpegResult& result) { + if (!input.SupportsSize() || !input.Size().has_value()) { + return absl::InvalidArgumentError("Failed to get size of input"); + } + + int64_t asset_start_offset = input.pos(); + int64_t asset_length = *input.Size() - asset_start_offset; + + ABSL_ASSIGN_OR_RETURN(auto part_hasher, InputHasher::Create(alg)); + + ABSL_RETURN_IF_ERROR(UpdateTwoHashers(input, asset_start_offset, asset_length, + asset_hasher, *part_hasher)); + + auto* part_binding = result.hard_binding.add_part_bindings(); + part_binding->mutable_location()->set_byte_offset(asset_start_offset); + part_binding->mutable_location()->set_length(asset_length); + part_binding->set_is_optional(true); + part_binding->mutable_data_hash()->set_alg(absl::StrCat(alg)); + part_binding->mutable_data_hash()->set_hash(part_hasher->Digest()); + + return absl::OkStatus(); +} + +} // namespace + +absl::StatusOr<DigestJpegResult> DigestJpeg( + riegeli::Reader& input, HashAlgorithm alg, bool generate_multi_asset_hash, + std::optional<JpegExclusionProcessor> exclusion_processor) { + ABSL_ASSIGN_OR_RETURN(auto correct_format, JpegAssessor().IsSupported(input)); + if (!correct_format) { + return absl::InvalidArgumentError("File does not start with a valid JPEG"); + } + if (!input.Seek(0)) { + return absl::InvalidArgumentError("Failed to seek to start of input"); + } + if (!input.SupportsSize() || !input.Size().has_value()) { + return absl::InvalidArgumentError("Failed to get size of input"); + } + + DigestJpegResult result = {}; + result.hard_binding.mutable_data_hash()->set_alg(absl::StrCat(alg)); + + uint64_t input_size = *input.Size(); + + ABSL_ASSIGN_OR_RETURN(auto asset_hasher, InputHasher::Create(alg)); + + int64_t pos; + do { + pos = input.pos(); + + ABSL_ASSIGN_OR_RETURN(bool is_jpeg, JpegAssessor().IsSupported(input)); + if (is_jpeg) { + ABSL_RETURN_IF_ERROR( + HashJpeg(input, alg, exclusion_processor, *asset_hasher, result)); + } else { + ABSL_RETURN_IF_ERROR( + HashRemainingBytes(input, alg, *asset_hasher, result)); + } + + if (!generate_multi_asset_hash) { + break; + } + int64_t newpos = input.pos(); + if (pos == newpos && pos < input_size) { + // Avoid infinite loop in case of bugs. + return absl::InternalError("Hashing made no progress"); + } + pos = newpos; + } while (pos < input_size); + + if (!generate_multi_asset_hash && input.pos() < input_size) { + ABSL_RETURN_IF_ERROR( + asset_hasher->Update(input, input.pos(), input_size - input.pos())); + } + + result.hard_binding.mutable_data_hash()->set_hash(asset_hasher->Digest()); + + if (!generate_multi_asset_hash || + result.hard_binding.part_bindings_size() == 1) { + // Only a single asset was found, no need for multi-asset hash assertion. + result.hard_binding.clear_part_bindings(); + } + + return result; +} + +} // namespace credentio
diff --git a/formats/jpeg/hasher.h b/formats/jpeg/hasher.h new file mode 100644 index 0000000..2da3eee --- /dev/null +++ b/formats/jpeg/hasher.h
@@ -0,0 +1,82 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_HASHER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_HASHER_H_ + +#include <cstdint> +#include <optional> +#include <vector> + +#include "absl/functional/function_ref.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_format.h" +#include "crypto/algorithms.h" +#include "formats/byte_range.h" +#include "formats/jpeg/reader.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/generator.pb.h" +#include "proto/multi_asset_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +struct JpegInsertionPoint { + uint16_t segment_id = 0; + uint64_t offset = 0; + + friend bool operator==(const JpegInsertionPoint&, + const JpegInsertionPoint&) = default; + + template <typename Sink> + friend void AbslStringify(Sink& sink, const JpegInsertionPoint& point) { + absl::Format(&sink, "JpegInsertionPoint{segment_id: %u, offset: %u}", + point.segment_id, point.offset); + } +}; + +struct DigestJpegResult { + HardBinding hard_binding; + JpegInsertionPoint insertion_point; +}; + +// Determines byte ranges within a given JPEG segment to be excluded from +// hashing. Returned ByteRange offsets must be absolute file offsets within +// [segment.offset, segment.offset + segment.length). +using JpegExclusionProcessor = + absl::FunctionRef<absl::StatusOr<std::vector<ByteRange>>( + riegeli::Reader& reader, const JpegSegment& segment)>; + +// Digests the given JPEG image and returns a DigestJpegResult that can be +// used to generate a C2PA manifest store. +// +// The result will contain a Data Hash Assertion, the offset at +// which to inject the C2PA data, and the segment ID to use. If the asset +// contains additional data beyond the initial JPEG's EOI tag it will also +// contain a MultiAssetHashAssertion and a vector of Data Hash Assertions for +// each found part of the file. +// +// Setting `generate_multi_asset_hash` to true will cause a multi-asset hash +// assertion to be included if the input contains data after EOI. Non-JPEG +// parts (e.g., an Android Motion Photo video) will be marked as optional. +// +// If `exclusion_processor` is provided, it is invoked for segments to identify +// byte ranges to exclude from hashing. +absl::StatusOr<DigestJpegResult> DigestJpeg( + riegeli::Reader& input, HashAlgorithm alg, bool generate_multi_asset_hash, + std::optional<JpegExclusionProcessor> exclusion_processor = std::nullopt); +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_HASHER_H_
diff --git a/formats/jpeg/testing/app_segment_creator_test.cc b/formats/jpeg/testing/app_segment_creator_test.cc index 2250aee..854402f 100644 --- a/formats/jpeg/testing/app_segment_creator_test.cc +++ b/formats/jpeg/testing/app_segment_creator_test.cc
@@ -26,7 +26,6 @@ namespace credentio { namespace { -using ::absl_testing::IsOk; using ::absl_testing::StatusIs; TEST(AppSegmentCreatorTest, CreateAppSegmentsSuccess) { @@ -36,7 +35,7 @@ params.max_size = 65535; auto segments_or = CreateAppSegments(params); - ASSERT_THAT(segments_or, IsOk()); + ABSL_ASSERT_OK(segments_or); EXPECT_EQ(segments_or.value().size(), 1); }
diff --git a/formats/jpeg/writer.cc b/formats/jpeg/writer.cc new file mode 100644 index 0000000..adb8a65 --- /dev/null +++ b/formats/jpeg/writer.cc
@@ -0,0 +1,297 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/jpeg/writer.h" + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "async/async_context.h" +#include "crypto/algorithms.h" +#include "formats/jpeg/app11_io.h" +#include "formats/jpeg/hasher.h" +#include "formats/jpeg/reader.h" +#include "generator/generator.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/bytes/writer.h" +#include "utils/byte_instruction.h" + +namespace credentio { +namespace { + +void AdjustDataHashExclusions(DataHashAssertion* absl_nonnull assertion, + int64_t c2pa_offset, int64_t c2pa_size) { + for (auto& exclusion : *assertion->mutable_exclusions()) { + if (exclusion.start() < c2pa_offset) { + // Before the C2PA segment, so no adjustment needed. + continue; + } + if (exclusion.start() == c2pa_offset) { + // This is the C2PA segment, just need to fill in the length. + exclusion.set_length(c2pa_size); + continue; + } + // This is after the C2PA segment, so we need to add the manifest size to + // the offset. + exclusion.set_start(exclusion.start() + c2pa_size); + } +} + +} // namespace + +JpegWriter::JpegWriter(const ManifestStoreGenerator* absl_nonnull generator, + Options options) + : generator_(*ABSL_DIE_IF_NULL(generator)), options_(std::move(options)) {} + +absl::Status JpegWriter::Write( + GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata) const { + absl::Notification done; + absl::Status status; + Write(std::move(request), source, destination, output_metadata, + AsyncContext(), [&](absl::Status s) { + status = std::move(s); + done.Notify(); + }); + done.WaitForNotification(); + return status; +} + +void JpegWriter::Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata, + AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb) const { + auto byte_instructions = std::make_shared<std::vector<ByteInstruction>>(); + WriteInternal( + std::move(request), source, output_metadata, byte_instructions, context, + [resolver = AsyncResolver<void>(context, std::move(cb)), source, + destination, byte_instructions](absl::Status status) mutable { + if (!status.ok()) { + resolver.Return(std::move(status)); + return; + } + status = ApplyByteInstructions(source, *byte_instructions, destination); + resolver.Return(std::move(status)); + }); +} + +absl::Status JpegWriter::Write( + GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset, + OutputMetadata* absl_nullable output_metadata) const { + absl::Notification done; + absl::Status status; + Write(std::move(request), asset, output_metadata, AsyncContext(), + [&](absl::Status s) { + status = std::move(s); + done.Notify(); + }); + done.WaitForNotification(); + return status; +} + +void JpegWriter::Write(GenerateManifestStoreRequest request, + riegeli::Writer* absl_nonnull asset, + OutputMetadata* absl_nullable output_metadata, + AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb) const { + AsyncResolver<void> resolver(context, std::move(cb)); + if (!asset->SupportsRandomAccess()) { + resolver.Return( + absl::InvalidArgumentError("asset does not support random access")); + return; + } + if (!asset->SupportsReadMode()) { + resolver.Return( + absl::InvalidArgumentError("asset does not support read mode")); + return; + } + + riegeli::Reader* source = asset->ReadMode(0); + if (source == nullptr) { + resolver.Return(asset->ok() + ? absl::InternalError("failed to create source reader") + : asset->status()); + return; + } + if (!source->ok()) { + resolver.Return(source->status()); + return; + } + + auto byte_instructions = std::make_shared<std::vector<ByteInstruction>>(); + WriteInternal(std::move(request), source, output_metadata, byte_instructions, + context, + [resolver = std::move(resolver), asset, + byte_instructions](absl::Status status) mutable { + if (!status.ok()) { + resolver.Return(std::move(status)); + return; + } + status = ApplyByteInstructions(*byte_instructions, asset); + resolver.Return(std::move(status)); + }); +} + +void JpegWriter::WriteInternal( + GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source, + OutputMetadata* absl_nullable output_metadata, + std::shared_ptr<std::vector<ByteInstruction>> byte_instructions, + AsyncContext context, absl::AnyInvocable<void(absl::Status) &&> cb) const { + AsyncResolver<void> resolver(context, std::move(cb)); + if (request.has_hard_binding()) { + resolver.Return(absl::InvalidArgumentError( + "request must not include `hard_binding` (binding is computed by " + "JpegWriter)")); + return; + } + + auto exclusion_fn = [this, &request](riegeli::Reader& reader, + const JpegSegment& segment) { + return options_.exclusion_processor(reader, segment, request); + }; + std::optional<JpegExclusionProcessor> exclusion_processor = std::nullopt; + if (options_.exclusion_processor != nullptr) { + exclusion_processor.emplace(std::move(exclusion_fn)); + } + auto digest_result = DigestJpeg(*source, HashAlgorithm::kSha256, + request.options().generate_multi_asset_hash(), + exclusion_processor); + if (!digest_result.ok()) { + resolver.Return(digest_result.status()); + return; + } + + request.mutable_hard_binding()->Swap(&digest_result->hard_binding); + auto insertion_point = std::move(digest_result->insertion_point); + + generator_.GenerateManifestStoreTemplate( + request, context, + [this, request, insertion_point = std::move(insertion_point), + output_metadata, byte_instructions, context, + resolver = std::move(resolver)]( + absl::StatusOr<ManifestStoreTemplate> template_response) mutable { + if (!template_response.ok()) { + resolver.Return(template_response.status()); + return; + } + + // Do things with Generate Template Response + constexpr int64_t kMaxManifestStoreSizeBytes = + 100 * 1024 * 1024; // 100 MiB + if (template_response->manifest_store_size() <= 0 || + template_response->manifest_store_size() > + kMaxManifestStoreSizeBytes) { + resolver.Return(absl::InvalidArgumentError( + absl::StrCat("invalid manifest store template size: ", + template_response->manifest_store_size()))); + return; + } + + uint64_t embedded_size = CalculateTotalJpegApp11SegmentsSize( + template_response->manifest_store_size()); + if (embedded_size > UINT32_MAX) { + resolver.Return( + absl::InternalError("estimated manifest store size is too large " + "to fit in a uint32_t")); + return; + } + + FinalizeManifestStoreRequest finalize_request; + finalize_request.mutable_manifest_store_template()->Swap( + &*template_response); + finalize_request.mutable_hard_binding()->MergeFrom( + request.hard_binding()); + + AdjustDataHashExclusions( + finalize_request.mutable_hard_binding()->mutable_data_hash(), + insertion_point.offset, embedded_size); + + if (finalize_request.hard_binding().part_bindings_size() > 0) { + AdjustDataHashExclusions(finalize_request.mutable_hard_binding() + ->mutable_part_bindings(0) + ->mutable_data_hash(), + insertion_point.offset, embedded_size); + + for (int i = 0; + i < finalize_request.hard_binding().part_bindings_size(); ++i) { + auto* location = finalize_request.mutable_hard_binding() + ->mutable_part_bindings(i) + ->mutable_location(); + if (location->byte_offset() == 0) { + location->set_length(location->length() + embedded_size); + } else { + location->set_byte_offset(location->byte_offset() + + embedded_size); + } + } + } + + generator_.FinalizeManifestStore( + finalize_request, context, + [insertion_point, output_metadata, byte_instructions, + resolver = std::move(resolver)]( + absl::StatusOr<FinalizeManifestStoreResponse> + finalize_response) mutable { + if (!finalize_response.ok()) { + resolver.Return(finalize_response.status()); + return; + } + + std::string app11_payload; + riegeli::StringWriter app11_writer(&app11_payload); + auto write_status = WriteJpegApp11Payload( + insertion_point.segment_id, + finalize_response->manifest_store(), app11_writer); + if (!write_status.ok()) { + resolver.Return(std::move(write_status)); + return; + } + if (!app11_writer.Close()) { + resolver.Return(app11_writer.status()); + return; + } + + byte_instructions->push_back(ByteInstruction{ + .operation = ByteInstruction::Operation::kInsert, + .offset = static_cast<uint64_t>(insertion_point.offset), + .bytes = std::move(app11_payload)}); + + if (output_metadata != nullptr) { + output_metadata->timestamp_included = + finalize_response->is_timestamped(); + } + resolver.Return(absl::OkStatus()); + }); + }); +} +} // namespace credentio
diff --git a/formats/jpeg/writer.h b/formats/jpeg/writer.h new file mode 100644 index 0000000..cfe73fd --- /dev/null +++ b/formats/jpeg/writer.h
@@ -0,0 +1,115 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_WRITER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_WRITER_H_ + +#include <cstdint> +#include <memory> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "async/async_context.h" +#include "formats/byte_range.h" +#include "formats/jpeg/reader.h" +#include "generator/generator.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "utils/byte_instruction.h" + +namespace credentio { + +// Writes C2PA manifest stores into JPEG images. +// +// The writer applies no exclusion policy of its own: unless an +// `ExclusionProcessor` is supplied, every byte of the source, other than the +// manifest store APP11 segment(s), is covered by the hard binding hash. +class JpegWriter { + public: + // Information about the C2PA manifest store embedded in the output asset. + struct OutputMetadata { + // Whether the C2PA active manifest embedded in the output asset has a + // timestamp. + bool timestamp_included = false; + }; + + // A function that determines byte ranges within a given JPEG segment to be + // excluded from the hard binding hash. + using ExclusionProcessor = + absl::AnyInvocable<absl::StatusOr<std::vector<ByteRange>>( + riegeli::Reader&, const JpegSegment&, + const GenerateManifestStoreRequest&) const>; + + struct Options { + // If null, no bytes, other than the manifest store APP11 segment(s), are + // excluded from the hard binding hash. + ExclusionProcessor exclusion_processor = nullptr; + }; + + explicit JpegWriter(const ManifestStoreGenerator* absl_nonnull generator) + : JpegWriter(generator, Options()) {} + JpegWriter(const ManifestStoreGenerator* absl_nonnull generator, + Options options); + + // Generates a C2PA manifest store and embeds it into a copy of the given + // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the + // same underlying data. The request must not include `hard_binding`. + void Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata, + AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb) const; + // Blocking version. + absl::Status Write( + GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata = nullptr) const; + + // Generates a C2PA manifest store and embeds it into the given asset in + // place. + // + // The supplied writer must support random access and read mode. + void Write(GenerateManifestStoreRequest request, + riegeli::Writer* absl_nonnull asset, + OutputMetadata* absl_nullable output_metadata, + AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb) const; + // Blocking version. + absl::Status Write( + GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset, + OutputMetadata* absl_nullable output_metadata = nullptr) const; + + private: + void WriteInternal( + GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + OutputMetadata* absl_nullable output_metadata, + std::shared_ptr<std::vector<ByteInstruction>> byte_instructions, + AsyncContext context, absl::AnyInvocable<void(absl::Status) &&> cb) const; + + const ManifestStoreGenerator& generator_; + Options options_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_WRITER_H_
diff --git a/formats/jpeg/writer_test.cc b/formats/jpeg/writer_test.cc new file mode 100644 index 0000000..1f0b29a --- /dev/null +++ b/formats/jpeg/writer_test.cc
@@ -0,0 +1,559 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/jpeg/writer.h" + +#include <cstddef> +#include <cstdint> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/absl_check.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "async/async_context.h" +#include "formats/byte_range.h" +#include "formats/jpeg/extractor.h" +#include "formats/jpeg/reader.h" +#include "formats/jpeg/testing/jpeg_creator.h" +#include "formats/jpeg/testing/jumbf_creator.h" +#include "generator/generator.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/generator.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_status.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "testing/fake_manifest_store_generator.h" +#include "testing/nonprod_generator.h" +#include "testing/nonprod_validator.h" +#include "testing/proto_test_utils.h" +#include "validator/asset_validator.h" +#include "validator/result.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio_testing::ParseTextProtoOrDie; +using ::credentio_testing::PartiallyEqualsProto; +using ::testing::AllOf; +using ::testing::Contains; +using ::testing::Field; +using ::testing::Gt; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::Not; +using ::testing::Optional; +using ::testing::SizeIs; +using ::testing::StartsWith; + +constexpr char kExifData[] = "Exif\0\0fake exif data"; +constexpr absl::string_view kExifPayload(kExifData, sizeof(kExifData) - 1); + +// Returns a JPEG segment with the given marker and payload. +std::string Segment(uint16_t marker, absl::string_view payload) { + const uint16_t length = static_cast<uint16_t>(payload.size() + 2); + return absl::StrCat( + std::string({static_cast<char>(marker >> 8), static_cast<char>(marker), + static_cast<char>(length >> 8), static_cast<char>(length)}), + payload); +} + +// Returns a minimal JPEG image: SOI, APP0 (JFIF), APP1 (Exif), DQT, EOI. +std::string CreateJpegImage() { + return CreateJpeg(JpegParams{ + .payload = { + Segment(0xffe0, + absl::string_view("JFIF\0\x01\x02\0\0\x01\0\x01\0\0", 14)), + Segment(0xffe1, kExifPayload), + Segment(0xffdb, std::string(65, '\x01')), + }}); +} + +GenerateManifestStoreRequest CreateRequest() { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + return request; +} + +// Returns a fake manifest store: a JUMBF box with a payload of the given size. +std::string CreateFakeManifestStore(size_t payload_size = 32) { + return CreateJumbf({.payload = std::string(payload_size, 'm')}); +} + +// Returns the byte offset of the Exif payload in `jpeg`. +size_t ExifOffset(absl::string_view jpeg) { + size_t offset = jpeg.find(kExifPayload); + ABSL_CHECK_NE(offset, absl::string_view::npos); + return offset; +} + +class JpegWriterTest : public ::testing::Test { + protected: + void SetUp() override { + auto generator = CreateNonProdManifestStoreGenerator(); + ABSL_CHECK_OK(generator); + generator_ = *std::move(generator); + writer_ = std::make_unique<JpegWriter>(generator_.get()); + + auto validator = CreateNonProdValidator(); + ABSL_CHECK_OK(validator); + validator_ = *std::move(validator); + } + + // Validates `asset` and returns the active manifest validation statuses. + absl::StatusOr<ValidationStatusSet> Validate(absl::string_view asset) { + riegeli::StringReader reader(asset); + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<ValidationResult> result, + validator_->Validate(reader, "image/jpeg")); + return result->proto().active_manifest().validation(); + } + + // Expects `asset` to contain a manifest store and to be valid. + void ExpectValid(absl::string_view asset) { + riegeli::StringReader reader(asset); + EXPECT_THAT( + JpegExtractor().ExtractManifestStoreLocation(reader, {}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)), + Field(&ByteRange::length, Gt(0)))))); + + absl::StatusOr<ValidationStatusSet> validation = Validate(asset); + ABSL_ASSERT_OK(validation); + EXPECT_THAT(validation->failures(), IsEmpty()); + EXPECT_THAT(validation->successes(), + Contains(PartiallyEqualsProto( + ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.dataHash.match" + )pb")))); + } + + // Writes a manifest store into a copy of `image` using `writer`. + static absl::StatusOr<std::string> WriteCopy( + const JpegWriter& writer, GenerateManifestStoreRequest request, + absl::string_view image, + JpegWriter::OutputMetadata* output_metadata = nullptr) { + riegeli::StringReader source(image); + std::string result; + riegeli::StringWriter destination(&result); + absl::Status status = writer.Write(std::move(request), &source, + &destination, output_metadata); + if (!status.ok()) return status; + if (!destination.Close()) return destination.status(); + return result; + } + + std::unique_ptr<ManifestStoreGenerator> generator_; + std::unique_ptr<JpegWriter> writer_; + std::unique_ptr<AssetValidator> validator_; +}; + +TEST_F(JpegWriterTest, Write) { + std::string image = CreateJpegImage(); + JpegWriter::OutputMetadata output_metadata; + absl::StatusOr<std::string> result = + WriteCopy(*writer_, CreateRequest(), image, &output_metadata); + ABSL_ASSERT_OK(result); + EXPECT_TRUE(output_metadata.timestamp_included); + EXPECT_GT(result->size(), image.size()); + ExpectValid(*result); + + absl::StatusOr<ValidationStatusSet> validation = Validate(*result); + ABSL_ASSERT_OK(validation); + EXPECT_THAT(validation->informationals(), + Not(Contains(PartiallyEqualsProto( + ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.dataHash.additionalExclusionsPresent" + )pb"))))); +} + +TEST_F(JpegWriterTest, WriteInsertsManifestStoreBeforeFirstNonAppSegment) { + FakeManifestStoreGenerator generator(CreateFakeManifestStore()); + JpegWriter writer(&generator); + std::string image = CreateJpegImage(); + size_t dqt_offset = image.find("\xff\xdb"); + ASSERT_NE(dqt_offset, std::string::npos); + + absl::StatusOr<std::string> result = + WriteCopy(writer, CreateRequest(), image); + ABSL_ASSERT_OK(result); + + // Bytes before the insertion point are unchanged. + EXPECT_THAT(*result, StartsWith(image.substr(0, dqt_offset))); + // An APP11 segment containing the manifest store is inserted. + EXPECT_EQ(result->substr(dqt_offset, 2), "\xff\xeb"); + EXPECT_THAT(*result, HasSubstr(generator.manifest_store())); + // Bytes after the insertion point are unchanged. + EXPECT_EQ(result->substr(result->size() - (image.size() - dqt_offset)), + image.substr(dqt_offset)); +} + +TEST_F(JpegWriterTest, WriteWithoutOutputMetadata) { + absl::StatusOr<std::string> result = + WriteCopy(*writer_, CreateRequest(), CreateJpegImage()); + ABSL_ASSERT_OK(result); + ExpectValid(*result); +} + +TEST_F(JpegWriterTest, WriteWithoutTimestamp) { + auto generator = + CreateNonProdManifestStoreGenerator({.disable_timestamping = true}); + ABSL_ASSERT_OK(generator); + JpegWriter writer(generator->get()); + + JpegWriter::OutputMetadata output_metadata; + absl::StatusOr<std::string> result = + WriteCopy(writer, CreateRequest(), CreateJpegImage(), &output_metadata); + ABSL_ASSERT_OK(result); + EXPECT_FALSE(output_metadata.timestamp_included); + ExpectValid(*result); +} + +TEST_F(JpegWriterTest, WriteInPlace) { + std::string image = CreateJpegImage(); + const size_t original_size = image.size(); + riegeli::StringWriter<> asset( + &image, riegeli::StringWriterBase::Options().set_append(true)); + ABSL_ASSERT_OK(asset.status()); + + JpegWriter::OutputMetadata output_metadata; + ABSL_ASSERT_OK(writer_->Write(CreateRequest(), &asset, &output_metadata)); + ASSERT_TRUE(asset.Close()); + EXPECT_TRUE(output_metadata.timestamp_included); + EXPECT_GT(image.size(), original_size); + ExpectValid(image); +} + +TEST_F(JpegWriterTest, WriteInPlaceWithoutOutputMetadata) { + std::string image = CreateJpegImage(); + riegeli::StringWriter<> asset( + &image, riegeli::StringWriterBase::Options().set_append(true)); + ABSL_ASSERT_OK(asset.status()); + + ABSL_ASSERT_OK(writer_->Write(CreateRequest(), &asset)); + ASSERT_TRUE(asset.Close()); + ExpectValid(image); +} + +TEST_F(JpegWriterTest, WriteInPlaceFailsWhenRandomAccessNotSupported) { + class NonRandomAccessWriter : public riegeli::StringWriter<> { + public: + using StringWriter::StringWriter; + bool SupportsRandomAccess() override { return false; } + }; + std::string image = CreateJpegImage(); + NonRandomAccessWriter asset(&image); + EXPECT_THAT(writer_->Write(CreateRequest(), &asset), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("asset does not support random access"))); +} + +TEST_F(JpegWriterTest, WriteInPlaceFailsWhenReadModeNotSupported) { + class NonReadModeWriter : public riegeli::StringWriter<> { + public: + using StringWriter::StringWriter; + bool SupportsReadMode() override { return false; } + }; + std::string image = CreateJpegImage(); + NonReadModeWriter asset( + &image, riegeli::StringWriterBase::Options().set_append(true)); + EXPECT_THAT(writer_->Write(CreateRequest(), &asset), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("asset does not support read mode"))); +} + +TEST_F(JpegWriterTest, WriteWithHardBindingFails) { + GenerateManifestStoreRequest request = CreateRequest(); + request.mutable_hard_binding(); + EXPECT_THAT(WriteCopy(*writer_, request, CreateJpegImage()), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("must not include `hard_binding`"))); +} + +TEST_F(JpegWriterTest, WriteInPlaceWithHardBindingFails) { + GenerateManifestStoreRequest request = CreateRequest(); + request.mutable_hard_binding(); + std::string image = CreateJpegImage(); + const std::string original = image; + riegeli::StringWriter<> asset( + &image, riegeli::StringWriterBase::Options().set_append(true)); + EXPECT_THAT(writer_->Write(request, &asset), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("must not include `hard_binding`"))); + ASSERT_TRUE(asset.Close()); + EXPECT_EQ(image, original); +} + +TEST_F(JpegWriterTest, WriteWithMultiAssetHash) { + GenerateManifestStoreRequest request = CreateRequest(); + request.mutable_options()->set_generate_multi_asset_hash(true); + absl::StatusOr<std::string> result = + WriteCopy(*writer_, request, CreateJpegImage()); + ABSL_ASSERT_OK(result); + ExpectValid(*result); +} + +TEST_F(JpegWriterTest, WriteFailsWhenNoInsertionPointFound) { + EXPECT_THAT( + WriteCopy(*writer_, CreateRequest(), + absl::string_view("\xff\xd8\xff\xe0\x00\x02", 6)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("No valid C2PA insertion point found in JPEG"))); +} + +TEST_F(JpegWriterTest, WriteFailsWhenManifestStoreAlreadyExists) { + absl::StatusOr<std::string> result = + WriteCopy(*writer_, CreateRequest(), CreateJpegImage()); + ABSL_ASSERT_OK(result); + + EXPECT_THAT(WriteCopy(*writer_, CreateRequest(), *result), + StatusIs(absl::StatusCode::kAlreadyExists, + HasSubstr("C2PA chunk already exists"))); +} + +TEST_F(JpegWriterTest, ModifyingHashedBytesInvalidatesAsset) { + absl::StatusOr<std::string> result = + WriteCopy(*writer_, CreateRequest(), CreateJpegImage()); + ABSL_ASSERT_OK(result); + + (*result)[ExifOffset(*result) + kExifPayload.size() - 1] ^= 0xff; + absl::StatusOr<ValidationStatusSet> validation = Validate(*result); + ABSL_ASSERT_OK(validation); + EXPECT_THAT( + validation->failures(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.dataHash.mismatch" + )pb")))); +} + +TEST_F(JpegWriterTest, WriteAsync) { + std::string image = CreateJpegImage(); + riegeli::StringReader source(image); + std::string result; + riegeli::StringWriter destination(&result); + + absl::Notification done; + absl::Status write_status; + JpegWriter::OutputMetadata output_metadata; + writer_->Write(CreateRequest(), &source, &destination, &output_metadata, + AsyncContext(), [&](absl::Status status) { + write_status = std::move(status); + done.Notify(); + }); + done.WaitForNotification(); + ABSL_ASSERT_OK(write_status); + ASSERT_TRUE(destination.Close()); + EXPECT_TRUE(output_metadata.timestamp_included); + ExpectValid(result); +} + +TEST_F(JpegWriterTest, WriteInPlaceAsync) { + std::string image = CreateJpegImage(); + riegeli::StringWriter<> asset( + &image, riegeli::StringWriterBase::Options().set_append(true)); + + absl::Notification done; + absl::Status write_status; + writer_->Write(CreateRequest(), &asset, /*output_metadata=*/nullptr, + AsyncContext(), [&](absl::Status status) { + write_status = std::move(status); + done.Notify(); + }); + done.WaitForNotification(); + ABSL_ASSERT_OK(write_status); + ASSERT_TRUE(asset.Close()); + ExpectValid(image); +} + +TEST_F(JpegWriterTest, WriteAsyncReportsErrorThroughCallback) { + GenerateManifestStoreRequest request = CreateRequest(); + request.mutable_hard_binding(); + std::string image = CreateJpegImage(); + riegeli::StringReader source(image); + std::string result; + riegeli::StringWriter destination(&result); + + absl::Notification done; + absl::Status write_status; + writer_->Write(request, &source, &destination, /*output_metadata=*/nullptr, + AsyncContext(), [&](absl::Status status) { + write_status = std::move(status); + done.Notify(); + }); + done.WaitForNotification(); + EXPECT_THAT(write_status, StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST_F(JpegWriterTest, ExclusionProcessorExcludesBytesFromHardBinding) { + std::vector<std::string> visited_segments; + JpegWriter writer( + generator_.get(), + {.exclusion_processor = [&visited_segments]( + riegeli::Reader&, const JpegSegment& segment, + const GenerateManifestStoreRequest& request) + -> absl::StatusOr<std::vector<ByteRange>> { + EXPECT_EQ(request.actions().created().action_metadata().description(), + "test only"); + visited_segments.push_back(segment.marker_label); + if (segment.marker_label != "APP1") return std::vector<ByteRange>(); + return std::vector<ByteRange>{{.offset = segment.payload_offset, + .length = segment.payload_length}}; + }}); + + absl::StatusOr<std::string> result = + WriteCopy(writer, CreateRequest(), CreateJpegImage()); + ABSL_ASSERT_OK(result); + EXPECT_THAT(visited_segments, Contains("APP0")); + EXPECT_THAT(visited_segments, Contains("APP1")); + EXPECT_THAT(visited_segments, Contains("DQT")); + ExpectValid(*result); + + absl::StatusOr<ValidationStatusSet> validation = Validate(*result); + ABSL_ASSERT_OK(validation); + EXPECT_THAT( + validation->informationals(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.dataHash.additionalExclusionsPresent" + )pb")))); + + // Modifying excluded bytes does not invalidate the asset. + (*result)[ExifOffset(*result) + kExifPayload.size() - 1] ^= 0xff; + ExpectValid(*result); +} + +TEST_F(JpegWriterTest, ExclusionProcessorErrorIsPropagated) { + JpegWriter writer( + generator_.get(), + {.exclusion_processor = [](riegeli::Reader&, const JpegSegment&, + const GenerateManifestStoreRequest&) + -> absl::StatusOr<std::vector<ByteRange>> { + return absl::DataLossError("exclusion failure"); + }}); + + EXPECT_THAT(WriteCopy(writer, CreateRequest(), CreateJpegImage()), + StatusIs(absl::StatusCode::kDataLoss, "exclusion failure")); +} + +TEST_F(JpegWriterTest, ExclusionProcessorRangeOutsideSegmentFails) { + JpegWriter writer( + generator_.get(), + {.exclusion_processor = [](riegeli::Reader&, const JpegSegment& segment, + const GenerateManifestStoreRequest&) + -> absl::StatusOr<std::vector<ByteRange>> { + return std::vector<ByteRange>{ + {.offset = segment.offset, .length = segment.length + 1}}; + }}); + + EXPECT_THAT(WriteCopy(writer, CreateRequest(), CreateJpegImage()), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Invalid or overlapping exclusion range"))); +} + +TEST_F(JpegWriterTest, GeneratorErrorIsPropagated) { + FakeManifestStoreGenerator generator; + generator.set_status(absl::UnavailableError("generator failure")); + JpegWriter writer(&generator); + + EXPECT_THAT(WriteCopy(writer, CreateRequest(), CreateJpegImage()), + StatusIs(absl::StatusCode::kUnavailable, "generator failure")); +} + +TEST_F(JpegWriterTest, InPlaceGeneratorErrorLeavesAssetUnchanged) { + FakeManifestStoreGenerator generator; + generator.set_status(absl::UnavailableError("generator failure")); + JpegWriter writer(&generator); + std::string image = CreateJpegImage(); + const std::string original = image; + riegeli::StringWriter<> asset( + &image, riegeli::StringWriterBase::Options().set_append(true)); + + EXPECT_THAT(writer.Write(CreateRequest(), &asset), + StatusIs(absl::StatusCode::kUnavailable, "generator failure")); + ASSERT_TRUE(asset.Close()); + EXPECT_EQ(image, original); +} + +TEST_F(JpegWriterTest, WriteFailsWithEmptyManifestStoreTemplate) { + FakeManifestStoreGenerator generator; + generator.mutable_manifest_store_template()->set_manifest_store_size(0); + JpegWriter writer(&generator); + + EXPECT_THAT(WriteCopy(writer, CreateRequest(), CreateJpegImage()), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("invalid manifest store template size: 0"))); +} + +TEST_F(JpegWriterTest, WriteFailsWithTooLargeManifestStoreTemplate) { + FakeManifestStoreGenerator generator; + generator.mutable_manifest_store_template()->set_manifest_store_size( + 100 * 1024 * 1024 + 1); + JpegWriter writer(&generator); + + EXPECT_THAT(WriteCopy(writer, CreateRequest(), CreateJpegImage()), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("invalid manifest store template size"))); +} + +TEST_F(JpegWriterTest, OutputMetadataReflectsFinalizeResponse) { + FakeManifestStoreGenerator generator(CreateFakeManifestStore()); + JpegWriter writer(&generator); + + JpegWriter::OutputMetadata output_metadata; + generator.mutable_finalize_manifest_store_response()->set_is_timestamped( + true); + ABSL_ASSERT_OK( + WriteCopy(writer, CreateRequest(), CreateJpegImage(), &output_metadata)); + EXPECT_TRUE(output_metadata.timestamp_included); + + output_metadata.timestamp_included = true; + generator.mutable_finalize_manifest_store_response()->set_is_timestamped( + false); + ABSL_ASSERT_OK( + WriteCopy(writer, CreateRequest(), CreateJpegImage(), &output_metadata)); + EXPECT_FALSE(output_metadata.timestamp_included); +} + +TEST_F(JpegWriterTest, LargeManifestStoreIsSplitAcrossApp11Segments) { + // A manifest store larger than a single APP11 segment (64 KiB). + FakeManifestStoreGenerator generator(CreateFakeManifestStore(200 * 1024)); + JpegWriter writer(&generator); + + absl::StatusOr<std::string> result = + WriteCopy(writer, CreateRequest(), CreateJpegImage()); + ABSL_ASSERT_OK(result); + + std::vector<size_t> app11_offsets; + for (size_t pos = result->find("\xff\xeb"); pos != std::string::npos; + pos = result->find("\xff\xeb", pos + 1)) { + app11_offsets.push_back(pos); + } + EXPECT_THAT(app11_offsets, SizeIs(Gt(3))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/pdf/BUILD b/formats/pdf/BUILD index df588d5..63e44de 100644 --- a/formats/pdf/BUILD +++ b/formats/pdf/BUILD
@@ -91,6 +91,7 @@ "@abseil-cpp//absl/container:flat_hash_map", "@abseil-cpp//absl/status", "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", "@abseil-cpp//absl/strings", "@abseil-cpp//absl/strings:string_view", "@googletest//:gtest_main", @@ -113,6 +114,7 @@ hdrs = ["extractor.h"], deps = [ ":constants", + ":manifest_store_merger", ":objects", ":reader", "//constants:labels", @@ -135,9 +137,19 @@ srcs = ["extractor_test.cc"], deps = [ ":extractor", + "//constants:labels", "//formats:byte_range", + "//jumbf:box", + "//jumbf:box_builder", + "//jumbf:constants", + "//jumbf:encode_params", + "//jumbf:parse", + "@abseil-cpp//absl/log:absl_check", "@abseil-cpp//absl/status", "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", "@abseil-cpp//absl/strings:string_view", "@googletest//:gtest_main", "@riegeli//riegeli/bytes:string_reader", @@ -182,3 +194,196 @@ "@abseil-cpp//absl/status:statusor", ], ) + +cc_library( + name = "manifest_store_merger", + srcs = ["manifest_store_merger.cc"], + hdrs = ["manifest_store_merger.h"], + deps = [ + "//constants:labels", + "//jumbf:box", + "//jumbf:box_builder", + "//jumbf:encode_params", + "//jumbf:parse", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + ], +) + +cc_test( + name = "manifest_store_merger_test", + srcs = ["manifest_store_merger_test.cc"], + deps = [ + ":manifest_store_merger", + "//constants:labels", + "//jumbf:box", + "//jumbf:box_builder", + "//jumbf:constants", + "//jumbf:encode_params", + "//jumbf:parse", + "//uuid", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "number_validator", + srcs = ["number_validator.cc"], + hdrs = ["number_validator.h"], + deps = [ + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "number_validator_test", + srcs = ["number_validator_test.cc"], + deps = [ + ":number_validator", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "object_writer", + srcs = ["object_writer.cc"], + hdrs = ["object_writer.h"], + deps = [ + ":number_validator", + ":objects", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_test( + name = "object_writer_test", + srcs = ["object_writer_test.cc"], + deps = [ + ":object_writer", + ":objects", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_library( + name = "embedder", + srcs = ["embedder.cc"], + hdrs = ["embedder.h"], + deps = [ + ":constants", + ":extractor", + ":object_writer", + ":objects", + ":reader", + "@abseil-cpp//absl/algorithm:container", + "@abseil-cpp//absl/container:btree", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/types:span", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_test( + name = "embedder_test", + srcs = ["embedder_test.cc"], + deps = [ + ":constants", + ":embedder", + ":extractor", + ":objects", + ":reader", + "//crypto:algorithms", + "//proto:data_hash_assertion_cc_proto", + "//proto:generator_cc_proto", + "//testing:nonprod_generator", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +) + +cc_library( + name = "writer", + srcs = ["writer.cc"], + hdrs = ["writer.h"], + deps = [ + ":embedder", + "//crypto:algorithms", + "//formats:hasher_data", + "//generator", + "//proto:generator_cc_proto", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:cord_reader", + "@riegeli//riegeli/bytes:cord_writer", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_test( + name = "writer_test", + srcs = ["writer_test.cc"], + deps = [ + ":extractor", + ":writer", + "//formats:byte_range", + "//generator", + "//proto:generator_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:fake_manifest_store_generator", + "//testing:nonprod_generator", + "//testing:nonprod_validator", + "//validator:asset_validator", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +)
diff --git a/formats/pdf/embedder.cc b/formats/pdf/embedder.cc new file mode 100644 index 0000000..d7f704a --- /dev/null +++ b/formats/pdf/embedder.cc
@@ -0,0 +1,554 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/pdf/embedder.h" + +#include <cstdint> +#include <iterator> +#include <optional> +#include <string> +#include <tuple> +#include <utility> +#include <variant> +#include <vector> + +#include "absl/algorithm/container.h" +#include "absl/container/btree_set.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "absl/time/clock.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "formats/pdf/constants.h" +#include "formats/pdf/extractor.h" +#include "formats/pdf/object_writer.h" +#include "formats/pdf/objects.h" +#include "formats/pdf/reader.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { +namespace { + +using Dictionary = Object::Dictionary; + +constexpr absl::string_view kPdfHeader = "%PDF-1.7\n%\x90\x91\xC2\x9A\n"; + +struct XrefTableEntry { + uint32_t object_number; + uint32_t generation_number; + + int64_t offset; + bool in_use; + + friend bool operator<(const XrefTableEntry& lhs, const XrefTableEntry& rhs) { + return std::tie(lhs.object_number, lhs.generation_number, lhs.offset, + lhs.in_use) < std::tie(rhs.object_number, + rhs.generation_number, rhs.offset, + rhs.in_use); + } +}; + +template <typename T> +absl::StatusOr<T> DereferenceObject(const Object::IndirectReference& ref, + PdfReader& reader) { + ABSL_ASSIGN_OR_RETURN( + const auto& indirect_obj, + reader.GetObject(ref.object_number, ref.generation_number)); + return Object::As<T>(indirect_obj.object); +} + +// Using the basic mechanism to form a linked list from the free entries. +// See ISO 32000-2 Section 7.5.4 for more details. +std::vector<XrefTableEntry> ReconstructXrefTableFreeEntries( + const absl::btree_set<Object::IndirectReference>& free_entries) { + if (free_entries.empty()) { + return {}; + } + std::vector<XrefTableEntry> xref_table_entries; + xref_table_entries.reserve(free_entries.size()); + + for (const auto& free_entry : free_entries) { + xref_table_entries.push_back( + XrefTableEntry{.object_number = free_entry.object_number, + .generation_number = free_entry.generation_number, + .offset = 0, + .in_use = false}); + } + + // Set the offset of each free entry to the object number of the next free + // entry. + for (int i = 0; i < xref_table_entries.size() - 1; ++i) { + xref_table_entries[i].offset = xref_table_entries[i + 1].object_number; + } + + // Signify the end of the list by having an offset value of 0. + xref_table_entries.back().offset = 0; + + return xref_table_entries; +} + +// Returns true if the object numbers in the given vector are consecutive, +// starting from 0. Assumes the vector is already sorted by object number. +bool AreObjectNumbersConsecutive( + absl::Span<const XrefTableEntry> xref_table_entries) { + for (auto i = 0; i < xref_table_entries.size(); i++) { + if (xref_table_entries[i].object_number != i) { + return false; + } + } + return true; +} + +absl::Status WritePdfHeader(riegeli::Writer& output) { + if (!output.Write(kPdfHeader)) { + return output.status(); + } + return absl::OkStatus(); +} + +// See ISO 32000-2 Section 7.5.4 for the cross-reference table structure. +absl::Status WriteXrefTable(std::vector<XrefTableEntry>& xref_table_entries, + riegeli::Writer& output) { + // Write the xref header. + if (!output.Write("xref\n")) { + return output.status(); + } + + absl::c_sort(xref_table_entries); + if (!AreObjectNumbersConsecutive(xref_table_entries)) { + return absl::UnimplementedError( + "Non-consecutive object numbers in the xref table are not supported."); + } + + // Write the object count section. + if (!output.Write(absl::StrCat(0, " ", xref_table_entries.size(), "\n"))) { + return output.status(); + } + + // Write the xref table entries. + for (const auto& entry : xref_table_entries) { + if (!output.Write(absl::StrFormat("%010d %05d %c \n", entry.offset, + entry.generation_number, + entry.in_use ? 'n' : 'f'))) { + return output.status(); + } + } + return absl::OkStatus(); +} + +// See ISO 32000-2 Section 7.5.5 for the trailer structure. +absl::Status WriteTrailer(const Dictionary& trailer_dict, + int64_t xref_table_offset, riegeli::Writer& output) { + // Write the trailer header. + if (!output.Write("trailer\n")) { + return output.status(); + } + + // Write the trailer dictionary object. + Object obj = Object{.value = trailer_dict}; + ABSL_RETURN_IF_ERROR(WriteObject(obj, output)); + + // Write the trailer footer and the offset to the last cross-reference table. + if (!output.Write( + absl::StrCat("\nstartxref\n", xref_table_offset, "\n%%EOF"))) { + return output.status(); + } + + return absl::OkStatus(); +} + +absl::Status CopyObjects( + PdfReader& reader, + absl::Span<const std::optional<Object::IndirectReference>> objects_to_skip, + int64_t start_offset, std::vector<XrefTableEntry>& xref_table_entries, + riegeli::Writer& output) { + while (reader.HasNext()) { + ABSL_ASSIGN_OR_RETURN(auto obj, reader.Next()); + + bool should_skip = absl::c_any_of( + objects_to_skip, + [&](const std::optional<Object::IndirectReference>& skip_ref) { + return skip_ref.has_value() && + obj.object_number == skip_ref->object_number && + obj.generation_number == skip_ref->generation_number; + }); + + if (should_skip) continue; + int64_t obj_offset = output.pos(); + ABSL_RETURN_IF_ERROR(WriteIndirectObject(obj, output)); + xref_table_entries.push_back( + XrefTableEntry{.object_number = obj.object_number, + .generation_number = obj.generation_number, + .offset = obj_offset - start_offset, + .in_use = true}); + } + return absl::OkStatus(); +} + +// Creates an indirect stream Object with a zero-filled manifest store. +IndirectObject BuildC2paManifestTemplate(uint32_t object_number, + uint32_t generation_number, + int64_t manifest_store_size) { + IndirectObject store_template; + store_template.object_number = object_number; + store_template.generation_number = generation_number; + + std::string formatted_date = absl::FormatTime( + "D:%Y%m%d%H%M%S+00'00'", absl::Now(), absl::UTCTimeZone()); + Dictionary params_dict{ + .entries = { + {std::string(kPdfSizeKey), + Object{.value = Object::Integer{.value = manifest_store_size}}}, + {std::string(kPdfModDateKey), + Object{.value = Object::LiteralString{.raw_value = formatted_date}}}, + }}; + + store_template.stream_dictionary = Dictionary{ + .entries = { + {"Length", + Object{.value = Object::Integer{.value = manifest_store_size}}}, + {std::string(kPdfSubtypeKey), + Object{.value = Object::Name{.value = std::string( + kPdfC2paManifestMimeType)}}}, + {std::string(kPdfParamsKey), Object{.value = std::move(params_dict)}}, + }}; + store_template.object = + Object{.value = Object::Stream{ + .raw_value = std::string(manifest_store_size, '\0')}}; + return store_template; +} + +IndirectObject BuildC2paManifestFileSpec( + uint32_t object_number, uint32_t generation_number, + const IndirectObject& c2pa_manifest_store_obj) { + IndirectObject file_spec; + file_spec.object_number = object_number; + file_spec.generation_number = generation_number; + file_spec.object = Object{ + .value = Dictionary{ + .entries = { + {std::string(kPdfTypeKey), + Object{.value = Object::Name{.value = std::string( + kPdfFileSpecType)}}}, + {std::string(kPdfAfRelationshipKey), + Object{.value = + Object::Name{.value = std::string( + kPdfC2paManifestAfRelationship)}}}, + {std::string(kPdfFKey), + Object{.value = + Object::LiteralString{ + .raw_value = + std::string(kPdfC2paEmbeddedFileName)}}}, + {std::string(kPdfUfKey), + Object{.value = + Object::LiteralString{ + .raw_value = + std::string(kPdfC2paEmbeddedFileName)}}}, + {std::string(kPdfEfKey), + Object{ + .value = Dictionary{ + .entries = { + {std::string(kPdfFKey), + Object{.value = Object::IndirectReference{ + .object_number = c2pa_manifest_store_obj + .object_number, + .generation_number = + c2pa_manifest_store_obj + .generation_number}}}}}}}}}}; + return file_spec; +} + +absl::Status InsertNameTreePair(absl::string_view key, + const Object::IndirectReference& value, + Object::Array& name_tree_array) { + if (name_tree_array.objects.size() % 2 != 0) { + return absl::InternalError( + "Name tree array must contain an even number of objects."); + } + + // Find the correct position to insert the new key-value pair to maintain + // lexicographical order of keys. The `name_tree_array` stores keys and + // values interleaved, e.g., [key1, value1, key2, value2, ...]. + auto insertion_it = name_tree_array.objects.begin(); + while (insertion_it != name_tree_array.objects.end()) { + // We only need to check the keys, which are at even indices. + ABSL_ASSIGN_OR_RETURN(const auto& current_key, + Object::As<Object::LiteralString>(*insertion_it)); + if (key < current_key.raw_value) { + // Found the position. The new key should be inserted before this one. + break; + } + // Advance the iterator to the next key. + insertion_it += 2; + } + + name_tree_array.objects.insert( + insertion_it, + {Object{.value = Object::LiteralString{.raw_value = std::string(key)}}, + Object{.value = value}}); + + return absl::OkStatus(); +} + +} // namespace + +absl::StatusOr<int64_t> PdfEmbedder::RewriteForEmbedding( + riegeli::Reader& input, int64_t estimated_manifest_store_size, + riegeli::Writer& output) const { + ABSL_ASSIGN_OR_RETURN(auto reader, PdfReader::Create(&input)); + + if (auto manifest_store = PdfExtractor().ExtractManifestStore(input); + manifest_store.ok()) { + return absl::AlreadyExistsError( + "Rewriting failed because a C2PA manifest store already exists "); + } + + // The start offset of the output stream. + int64_t start_offset = output.pos(); + + // Step 1: + // Locate and identify objects that need to be updated/created for C2PA + // embedding. + auto file_trailer_dict = reader->file_trailer_dictionary(); + ABSL_ASSIGN_OR_RETURN( + auto object_count_obj, + FindObjectInDictionary<Object::Integer>(file_trailer_dict, kPdfSizeKey)); + uint32_t next_available_object_number = object_count_obj.value; + + // /Root + ABSL_ASSIGN_OR_RETURN(auto doc_catalog_ref, + FindObjectInDictionary<Object::IndirectReference>( + file_trailer_dict, kPdfRootKey)); + ABSL_ASSIGN_OR_RETURN(auto doc_catalog_dict, DereferenceObject<Dictionary>( + doc_catalog_ref, *reader)); + + // /Root/Names + std::optional<Dictionary> catalog_names = std::nullopt; + std::optional<Object::IndirectReference> catalog_names_ref = std::nullopt; + if (auto it = doc_catalog_dict.entries.find(kPdfNamesKey); + it != doc_catalog_dict.entries.end()) { + if (std::holds_alternative<Dictionary>(it->second.value)) { + // If we find the catalog names dictionary to be a direct object, move it + // into an indirect object then update the document catalog dictionary to + // point to it. + catalog_names = std::move(std::get<Dictionary>(it->second.value)); + catalog_names_ref = Object::IndirectReference{ + .object_number = next_available_object_number++, + .generation_number = 0}; + doc_catalog_dict.entries[kPdfNamesKey] = + Object{.value = *catalog_names_ref}; + } else { + ABSL_ASSIGN_OR_RETURN(catalog_names_ref, + Object::As<Object::IndirectReference>(it->second)); + ABSL_ASSIGN_OR_RETURN( + catalog_names, + DereferenceObject<Dictionary>(catalog_names_ref.value(), *reader)); + } + } + + // /Root/Names/EmbeddedFiles + std::optional<Dictionary> name_tree = std::nullopt; + std::optional<Object::IndirectReference> name_tree_ref = std::nullopt; + if (catalog_names.has_value()) { + if (auto it = catalog_names->entries.find(kPdfEmbeddedFilesKey); + it != catalog_names->entries.end()) { + ABSL_ASSIGN_OR_RETURN(name_tree_ref, + Object::As<Object::IndirectReference>(it->second)); + ABSL_ASSIGN_OR_RETURN(name_tree, DereferenceObject<Dictionary>( + name_tree_ref.value(), *reader)); + } + } + + // Step 2: Write the header. + ABSL_RETURN_IF_ERROR(WritePdfHeader(output)); + + // Step 3: Copy the objects that can be written as-is from the input stream to + // the output stream. + + // A list of xref table entries that will be written to the output stream, + // including both in-use and free entries. In-use entry's offset is relative + // to `start_offset`. + std::vector<XrefTableEntry> xref_table_entries; + ABSL_RETURN_IF_ERROR(CopyObjects( + *reader, + /*objects_to_skip=*/{doc_catalog_ref, catalog_names_ref, name_tree_ref}, + start_offset, xref_table_entries, output)); + + // Step 4.1: Create the embedded file stream object that holds the C2PA + // manifest store. + IndirectObject c2pa_manifest_store_obj = BuildC2paManifestTemplate( + /*object_number=*/next_available_object_number++, + /*generation_number=*/0, estimated_manifest_store_size); + + int64_t c2pa_manifest_store_offset = output.pos(); + ABSL_RETURN_IF_ERROR(WriteIndirectObject(c2pa_manifest_store_obj, output)); + xref_table_entries.push_back(XrefTableEntry{ + .object_number = c2pa_manifest_store_obj.object_number, + .generation_number = c2pa_manifest_store_obj.generation_number, + .offset = c2pa_manifest_store_offset - start_offset, + .in_use = true}); + + // Step 4.2: Create a file specification object for the C2PA manifest + // store. + IndirectObject c2pa_manifest_store_file_spec = BuildC2paManifestFileSpec( + /*object_number=*/next_available_object_number++, + /*generation_number=*/0, c2pa_manifest_store_obj); + + int64_t c2pa_manifest_store_file_spec_offset = output.pos(); + ABSL_RETURN_IF_ERROR( + WriteIndirectObject(c2pa_manifest_store_file_spec, output)); + xref_table_entries.push_back(XrefTableEntry{ + .object_number = c2pa_manifest_store_file_spec.object_number, + .generation_number = c2pa_manifest_store_file_spec.generation_number, + .offset = c2pa_manifest_store_file_spec_offset - start_offset, + .in_use = true}); + + // Step 4.3: Add/Update the embedded files name tree. + if (!name_tree.has_value()) { + // Since we don't find an existing name tree in the input PDF, we can safely + // create a new one. + name_tree = Dictionary{}; + name_tree_ref = Object::IndirectReference{ + .object_number = next_available_object_number++, + .generation_number = 0}; + } + auto name_tree_it = name_tree->entries.find(kPdfNamesKey); + if (name_tree_it == name_tree->entries.end() || + !std::holds_alternative<Object::Array>(name_tree_it->second.value)) { + name_tree_it = + name_tree->entries + .insert_or_assign(kPdfNamesKey, Object{.value = Object::Array{}}) + .first; + } + Object::Array& name_tree_array = + std::get<Object::Array>(name_tree_it->second.value); + ABSL_RETURN_IF_ERROR(InsertNameTreePair( + kPdfC2paEmbeddedFileName, + Object::IndirectReference{ + .object_number = c2pa_manifest_store_file_spec.object_number, + .generation_number = c2pa_manifest_store_file_spec.generation_number}, + name_tree_array)); + + int64_t name_tree_offset = output.pos(); + ABSL_RETURN_IF_ERROR(WriteIndirectObject( + IndirectObject{.object_number = name_tree_ref->object_number, + .generation_number = name_tree_ref->generation_number, + .object = Object{.value = *name_tree}}, + output)); + xref_table_entries.push_back( + XrefTableEntry{.object_number = name_tree_ref->object_number, + .generation_number = name_tree_ref->generation_number, + .offset = name_tree_offset - start_offset, + .in_use = true}); + + // Step 4.4: Add/Update the catalog names dictionary. + if (!catalog_names.has_value()) { + // Since we don't find an existing catalog names dictionary in the input + // PDF, we can safely create a new one. + catalog_names = Dictionary{}; + catalog_names_ref = Object::IndirectReference{ + .object_number = next_available_object_number++, + .generation_number = 0}; + doc_catalog_dict.entries[kPdfNamesKey] = + Object{.value = *catalog_names_ref}; + } + if (!catalog_names->entries.contains(kPdfEmbeddedFilesKey)) { + catalog_names->entries[kPdfEmbeddedFilesKey] = + Object{.value = *name_tree_ref}; + } + + int64_t catalog_names_offset = output.pos(); + ABSL_RETURN_IF_ERROR(WriteIndirectObject( + IndirectObject{.object_number = catalog_names_ref->object_number, + .generation_number = catalog_names_ref->generation_number, + .object = Object{.value = *catalog_names}}, + output)); + xref_table_entries.push_back( + XrefTableEntry{.object_number = catalog_names_ref->object_number, + .generation_number = catalog_names_ref->generation_number, + .offset = catalog_names_offset - start_offset, + .in_use = true}); + + // Step 4.5: Update the document catalog dictionary. + auto af_it = doc_catalog_dict.entries.find(kPdfAfKey); + if (af_it == doc_catalog_dict.entries.end() || + !std::holds_alternative<Object::Array>(af_it->second.value)) { + af_it = doc_catalog_dict.entries + .insert_or_assign(kPdfAfKey, Object{.value = Object::Array{}}) + .first; + } + std::get<Object::Array>(af_it->second.value) + .objects.push_back(Object{ + .value = Object::IndirectReference{ + .object_number = c2pa_manifest_store_file_spec.object_number, + .generation_number = + c2pa_manifest_store_file_spec.generation_number}}); + int64_t doc_catalog_dict_offset = output.pos(); + ABSL_RETURN_IF_ERROR(WriteIndirectObject( + IndirectObject{.object_number = doc_catalog_ref.object_number, + .generation_number = doc_catalog_ref.generation_number, + .object = Object{.value = doc_catalog_dict}}, + output)); + xref_table_entries.push_back( + XrefTableEntry{.object_number = doc_catalog_ref.object_number, + .generation_number = doc_catalog_ref.generation_number, + .offset = doc_catalog_dict_offset - start_offset, + .in_use = true}); + + // Step 5: Compute and write the xref table. + int64_t xref_table_offset = output.pos() - start_offset; + + // Add all existing free entries to the xref table. + auto xref_table_free_entries = + ReconstructXrefTableFreeEntries(reader->free_entries()); + xref_table_entries.insert( + xref_table_entries.end(), + std::make_move_iterator(xref_table_free_entries.begin()), + std::make_move_iterator(xref_table_free_entries.end())); + + ABSL_RETURN_IF_ERROR(WriteXrefTable(xref_table_entries, output)); + + // Step 6: Write the trailer section, including the trailer dictionary and + // startxref pointer. + + // Update the object count in the trailer dictionary. + file_trailer_dict.entries[kPdfSizeKey] = + Object{.value = Object::Integer{ + .value = static_cast<int64_t>(xref_table_entries.size())}}; + ABSL_RETURN_IF_ERROR( + WriteTrailer(file_trailer_dict, xref_table_offset, output)); + + ABSL_ASSIGN_OR_RETURN(int64_t header_len, ComputeIndirectStreamHeaderByteSize( + c2pa_manifest_store_obj)); + return c2pa_manifest_store_offset + header_len; +} + +absl::Status PdfEmbedder::Embed(absl::string_view manifest_store, + int64_t insert_at_offset, + absl::Cord& embeddable_asset) const { + absl::Cord result = embeddable_asset.Subcord(0, insert_at_offset); + result.Append(manifest_store); + result.Append(embeddable_asset.Subcord( + insert_at_offset + manifest_store.size(), embeddable_asset.size())); + embeddable_asset = std::move(result); + return absl::OkStatus(); +} +} // namespace credentio
diff --git a/formats/pdf/embedder.h b/formats/pdf/embedder.h new file mode 100644 index 0000000..079fbfe --- /dev/null +++ b/formats/pdf/embedder.h
@@ -0,0 +1,51 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PDF_EMBEDDER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_EMBEDDER_H_ + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// A utility class for embedding a C2PA manifest store as an embedded file +// stream in a non-C2PA PDF file. +class PdfEmbedder { + public: + explicit PdfEmbedder() = default; + + // Rewrites a PDF file to be C2PA-embeddable, making necessary updates while + // preserving the original structure. Returns the offset of the zero-filled + // manifest store in the output. Fails if the input PDF already contains a + // C2PA manifest store or if rewriting fails. + absl::StatusOr<int64_t> RewriteForEmbedding( + riegeli::Reader& input, int64_t estimated_manifest_store_size, + riegeli::Writer& output) const; + + // Embeds the C2PA manifest store at the insertion point. + absl::Status Embed(absl::string_view manifest_store, int64_t insert_at_offset, + absl::Cord& embeddable_asset) const; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PDF_EMBEDDER_H_
diff --git a/formats/pdf/embedder_test.cc b/formats/pdf/embedder_test.cc new file mode 100644 index 0000000..0315081 --- /dev/null +++ b/formats/pdf/embedder_test.cc
@@ -0,0 +1,338 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/pdf/embedder.h" + +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "formats/pdf/constants.h" +#include "formats/pdf/extractor.h" +#include "formats/pdf/objects.h" +#include "formats/pdf/reader.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "testing/nonprod_generator.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::ElementsAre; +using ::testing::HasSubstr; + +constexpr absl::string_view kTestPdfContent = R"(%PDF-1.7 +%%%%% +1 0 obj +/test_name +endobj +2 0 obj +123.456 +endobj +3 0 obj +<</Type /Catalog>> +endobj +xref +0 4 +0000000000 65535 f +0000000015 00000 n +0000000041 00000 n +0000000064 00000 n +trailer +<</Root 3 0 R /Size 4>> +startxref +98 +%%EOF +)"; + +constexpr absl::string_view kTestPdfContentWithDirectNamesDict = R"(%PDF-1.7 +%%%%% +1 0 obj +/test_name +endobj +2 0 obj +123.456 +endobj +3 0 obj +<</Names <<>> /Type /Catalog>> +endobj +xref +0 4 +0000000000 65535 f +0000000015 00000 n +0000000041 00000 n +0000000064 00000 n +trailer +<</Root 3 0 R /Size 4>> +startxref +110 +%%EOF +)"; + +constexpr absl::string_view kTestPdfContentWithFreeObjectEntries = R"(%PDF-1.7 +%%%%% +1 0 obj +/test_name +endobj +3 0 obj +123.456 +endobj +5 0 obj +<</Type /Catalog>> +endobj +xref +0 6 +0000000002 65535 f +0000000015 00000 n +0000000004 00000 f +0000000041 00000 n +0000000000 00000 f +0000000064 00000 n +trailer +<</Root 5 0 R /Size 6>> +startxref +98 +%%EOF +)"; + +absl::StatusOr<std::string> CreateManifestStore() { + ABSL_ASSIGN_OR_RETURN(auto generator, + CreateNonProdManifestStoreGenerator({})); + GenerateManifestStoreRequest request; + DataHashAssertion* data_hash = + request.mutable_hard_binding()->mutable_data_hash(); + data_hash->set_alg(absl::StrCat(HashAlgorithm::kSha256)); + data_hash->set_hash("data_hash"); + request.mutable_actions()->mutable_created(); + ABSL_ASSIGN_OR_RETURN(auto response, + generator->GenerateManifestStore(request)); + return std::string(response.manifest_store()); +} + +TEST(PdfRewriterTest, RewriteForEmbedding) { + riegeli::StringReader<> input(kTestPdfContent); + std::string output_s; + riegeli::StringWriter<> output(&output_s); + EXPECT_THAT(PdfEmbedder().RewriteForEmbedding( + input, /*estimated_manifest_store_size=*/100, output), + IsOk()); + ASSERT_TRUE(output.Close()); + + riegeli::StringReader<> output_reader(output_s); + EXPECT_THAT(PdfExtractor().ExtractManifestStore(output_reader), + IsOkAndHolds(std::string(100, '\0'))); +} + +TEST(PdfRewriterTest, RewriteForEmbeddingMovesDirectNamesDict) { + riegeli::StringReader<> input(kTestPdfContentWithDirectNamesDict); + std::string output_s; + riegeli::StringWriter<> output(&output_s); + EXPECT_THAT(PdfEmbedder().RewriteForEmbedding( + input, /*estimated_manifest_store_size=*/100, output), + IsOk()); + ASSERT_TRUE(output.Close()); + + riegeli::StringReader<> output_reader(output_s); + auto reader = PdfReader::Create(&output_reader); + ABSL_ASSERT_OK(reader); + auto trailer_dict = (*reader)->file_trailer_dictionary(); + auto doc_catalog_dict_obj = (*reader)->GetObject(3, 0); + ABSL_ASSERT_OK(doc_catalog_dict_obj); + auto doc_catalog_dict = + Object::As<Object::Dictionary>(doc_catalog_dict_obj->object); + ABSL_ASSERT_OK(doc_catalog_dict); + + // Verify that the catalog names dictionary is moved into an indirect object + // and referenced from the document catalog dictionary. + EXPECT_THAT( + (*reader)->GetObject(4, 0), + IsOkAndHolds(IndirectObject{ + .object_number = 4, + .generation_number = 0, + .object = Object{ + .value = Object::Dictionary{ + .entries = {{"EmbeddedFiles", + Object{.value = Object::IndirectReference{ + .object_number = 7, + .generation_number = 0}}}}}}})); + EXPECT_THAT(doc_catalog_dict->entries.at("Names"), + (Object{.value = Object::IndirectReference{ + .object_number = 4, .generation_number = 0}})); +} + +TEST(PdfRewriterTest, RewriteForEmbeddingRespectsFreeObjectEntries) { + riegeli::StringReader<> input(kTestPdfContentWithFreeObjectEntries); + std::string output_s; + riegeli::StringWriter<> output(&output_s); + EXPECT_THAT(PdfEmbedder().RewriteForEmbedding( + input, /*estimated_manifest_store_size=*/100, output), + IsOk()); + ASSERT_TRUE(output.Close()); + + riegeli::StringReader<> output_reader(output_s); + auto reader = PdfReader::Create(&output_reader); + ABSL_ASSERT_OK(reader); + EXPECT_THAT((*reader)->free_entries(), + ElementsAre(Object::IndirectReference{.object_number = 0, + .generation_number = 65535}, + Object::IndirectReference{.object_number = 2, + .generation_number = 0}, + Object::IndirectReference{.object_number = 4, + .generation_number = 0})); +} + +TEST(PdfRewriterTest, RewriteForEmbeddingFailsIfC2paManifestAlreadyExists) { + // Prepare a C2PA-embeddable PDF. + riegeli::StringReader<> input(kTestPdfContent); + std::string output_s; + riegeli::StringWriter<> output(&output_s); + EXPECT_THAT(PdfEmbedder().RewriteForEmbedding( + input, /*estimated_manifest_store_size=*/100, output), + IsOk()); + ASSERT_TRUE(output.Close()); + + // Rewrite the PDF again, it should fail. + riegeli::StringReader<> output_reader(output_s); + std::string output_s2; + riegeli::StringWriter<> output2(&output_s2); + EXPECT_THAT( + PdfEmbedder().RewriteForEmbedding( + output_reader, /*estimated_manifest_store_size=*/100, output2), + StatusIs(absl::StatusCode::kAlreadyExists, + HasSubstr("C2PA manifest store already exists"))); +} + +TEST(PdfRewriterTest, Embed) { + auto manifest_store = CreateManifestStore(); + ABSL_ASSERT_OK(manifest_store); + + // Prepare a C2PA-embeddable PDF. + riegeli::StringReader<> input(kTestPdfContent); + std::string output_s; + riegeli::StringWriter<> output(&output_s); + absl::StatusOr<int64_t> manifest_store_offset = + PdfEmbedder().RewriteForEmbedding(input, manifest_store->size(), output); + ABSL_ASSERT_OK(manifest_store_offset); + ASSERT_TRUE(output.Close()); + + // Embed the manifest store. + absl::Cord embeddable_asset(output_s); + EXPECT_THAT(PdfEmbedder().Embed(*manifest_store, *manifest_store_offset, + embeddable_asset), + IsOk()); + + riegeli::StringReader<> output_reader(embeddable_asset.Flatten()); + EXPECT_THAT(PdfExtractor().ExtractManifestStore(output_reader), + IsOkAndHolds(*manifest_store)); +} + +TEST(PdfRewriterTest, RewriteForEmbeddingCorrectLayout) { + riegeli::StringReader<> input(kTestPdfContent); + std::string output_s; + riegeli::StringWriter<> output(&output_s); + EXPECT_THAT(PdfEmbedder().RewriteForEmbedding( + input, /*estimated_manifest_store_size=*/100, output), + IsOk()); + ASSERT_TRUE(output.Close()); + + riegeli::StringReader<> output_reader(output_s); + auto reader = PdfReader::Create(&output_reader); + ABSL_ASSERT_OK(reader); + + // The catalog should have /AF pointing to the file spec. + auto doc_catalog_dict_obj = (*reader)->GetObject(3, 0); + ABSL_ASSERT_OK(doc_catalog_dict_obj); + auto doc_catalog_dict = + Object::As<Object::Dictionary>(doc_catalog_dict_obj->object); + ABSL_ASSERT_OK(doc_catalog_dict); + auto af_array = + FindObjectInDictionary<Object::Array>(*doc_catalog_dict, kPdfAfKey); + ABSL_ASSERT_OK(af_array); + ASSERT_EQ(af_array->objects.size(), 1); + auto file_spec_ref = + Object::As<Object::IndirectReference>(af_array->objects[0]); + ABSL_ASSERT_OK(file_spec_ref); + + // Inspect the file spec object. + auto file_spec_obj = (*reader)->GetObject(file_spec_ref->object_number, + file_spec_ref->generation_number); + ABSL_ASSERT_OK(file_spec_obj); + auto file_spec_dict = Object::As<Object::Dictionary>(file_spec_obj->object); + ABSL_ASSERT_OK(file_spec_dict); + + // Subtype should NOT be in file spec dictionary. + EXPECT_THAT( + FindObjectInDictionary<Object::Name>(*file_spec_dict, kPdfSubtypeKey), + StatusIs(absl::StatusCode::kNotFound)); + + // AFRelationship should be C2PA_Manifest. + EXPECT_THAT(FindObjectInDictionary<Object::Name>(*file_spec_dict, + kPdfAfRelationshipKey), + IsOkAndHolds(Object::Name{ + .value = std::string(kPdfC2paManifestAfRelationship)})); + + // Get the embedded file stream ref. + auto ef_dict = + FindObjectInDictionary<Object::Dictionary>(*file_spec_dict, kPdfEfKey); + ABSL_ASSERT_OK(ef_dict); + auto stream_ref = + FindObjectInDictionary<Object::IndirectReference>(*ef_dict, kPdfFKey); + ABSL_ASSERT_OK(stream_ref); + + // Inspect the stream object. + auto stream_obj = (*reader)->GetObject(stream_ref->object_number, + stream_ref->generation_number); + ABSL_ASSERT_OK(stream_obj); + ASSERT_TRUE(stream_obj->stream_dictionary.has_value()); + const auto& stream_dict = *stream_obj->stream_dictionary; + + // Subtype should be in stream dictionary. + EXPECT_THAT(FindObjectInDictionary<Object::Name>(stream_dict, kPdfSubtypeKey), + IsOkAndHolds(Object::Name{ + .value = std::string(kPdfC2paManifestMimeType)})); + + // Params should be in stream dictionary. + auto params_dict = + FindObjectInDictionary<Object::Dictionary>(stream_dict, kPdfParamsKey); + ABSL_ASSERT_OK(params_dict); + // Params should contain Size. + EXPECT_THAT( + FindObjectInDictionary<Object::Integer>(*params_dict, kPdfSizeKey), + IsOkAndHolds(Object::Integer{.value = 100})); + + // Params should contain ModDate. + auto mod_date_obj = FindObjectInDictionary<Object::LiteralString>( + *params_dict, kPdfModDateKey); + ABSL_ASSERT_OK(mod_date_obj); + EXPECT_THAT(mod_date_obj->raw_value, + testing::MatchesRegex("D:[0-9]{14}[+]00'00'")); +} + +} // namespace +} // namespace credentio
diff --git a/formats/pdf/extractor.cc b/formats/pdf/extractor.cc index b2e0a9d..4e8b4eb 100644 --- a/formats/pdf/extractor.cc +++ b/formats/pdf/extractor.cc
@@ -19,6 +19,7 @@ #include <string> #include <utility> #include <variant> +#include <vector> #include "absl/algorithm/container.h" #include "absl/status/status.h" @@ -28,6 +29,7 @@ #include "constants/labels.h" #include "formats/byte_range.h" #include "formats/pdf/constants.h" +#include "formats/pdf/manifest_store_merger.h" #include "formats/pdf/objects.h" #include "formats/pdf/reader.h" #include "jumbf/utils.h" @@ -190,6 +192,8 @@ ABSL_ASSIGN_OR_RETURN(auto name_tree_array, GetEmbeddedFilesNameTreeArray(catalog_dict, *reader)); + std::vector<C2paManifestStore> found_manifest_stores; + for (const auto& file_spec_obj : file_spec_array->objects) { Object::Dictionary file_spec_dict; Object::IndirectReference file_spec_ref; @@ -213,6 +217,7 @@ if (!is_c2pa_manifest_file_spec) { continue; } + ABSL_ASSIGN_OR_RETURN(auto embedded_file_ref, GetEmbeddedFileRef(file_spec_dict)); @@ -248,12 +253,28 @@ auto embedded_file_offset, reader->GetObjectOffset(embedded_file_ref.object_number, embedded_file_ref.generation_number)); - return C2paManifestStore{ + found_manifest_stores.push_back(C2paManifestStore{ .location = {.offset = embedded_file_offset, .length = input.pos() - embedded_file_offset}, - .content = embedded_file_stream.raw_value}; + .content = embedded_file_stream.raw_value}); } - return absl::NotFoundError("No manifest store found."); + + if (found_manifest_stores.empty()) { + return absl::NotFoundError("No manifest store found."); + } + + std::vector<absl::string_view> store_contents; + store_contents.reserve(found_manifest_stores.size()); + for (const auto& store : found_manifest_stores) { + store_contents.push_back(store.content); + } + ABSL_ASSIGN_OR_RETURN(std::string merged, + MergeManifestStores(store_contents)); + + return C2paManifestStore{ + .location = found_manifest_stores.back().location, + .content = std::move(merged), + }; } } // namespace
diff --git a/formats/pdf/extractor_test.cc b/formats/pdf/extractor_test.cc index 60ded2f..500f947 100644 --- a/formats/pdf/extractor_test.cc +++ b/formats/pdf/extractor_test.cc
@@ -15,37 +15,45 @@ #include "formats/pdf/extractor.h" +#include <cstddef> +#include <cstdio> +#include <optional> #include <string> +#include <utility> +#include <vector> +#include "absl/log/absl_check.h" #include "absl/status/status.h" #include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" #include "absl/strings/string_view.h" +#include "constants/labels.h" #include "formats/byte_range.h" #include "gmock/gmock.h" #include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/box_builder.h" +#include "jumbf/constants.h" +#include "jumbf/encode_params.h" +#include "jumbf/parse.h" #include "riegeli/bytes/string_reader.h" namespace credentio { namespace { +using ::absl_testing::IsOkAndHolds; using ::absl_testing::StatusIs; +using ::credentio::kManifestStoreLabel; +using ::credentio::kManifestStoreUuid; +using ::credentio::kStandardManifestUuid; +using ::jumbf::DescriptionBoxOptions; +using ::jumbf::kCborBoxType; +using ::jumbf::SuperBoxBuilder; using ::testing::HasSubstr; using ::testing::Optional; -#ifndef ASSERT_OK_AND_ASSIGN -#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y -#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) - -#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ - ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) - -#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ - auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ - ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ - ::absl_testing::IsOk()); \ - lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) -#endif - constexpr absl::string_view kTestPdfContent = "%PDF-1.7\n" "%%%%%\n" @@ -538,25 +546,22 @@ TEST(PdfExtractorTest, ExtractManifestStoreV22EmbeddedFilesDirect) { riegeli::StringReader<> input(kTestPdfContentEmbeddedFilesDirect); PdfExtractor extractor; - ASSERT_OK_AND_ASSIGN(auto manifest_store, - extractor.ExtractManifestStore(input)); - EXPECT_THAT(manifest_store, "test_c2pa_manifest_raw_bytestream"); + EXPECT_THAT(extractor.ExtractManifestStore(input), + IsOkAndHolds("test_c2pa_manifest_raw_bytestream")); } TEST(PdfExtractorTest, ExtractManifestStoreV22) { riegeli::StringReader<> input(kTestPdfContent); PdfExtractor extractor; - ASSERT_OK_AND_ASSIGN(auto manifest_store, - extractor.ExtractManifestStore(input)); - EXPECT_THAT(manifest_store, "test_c2pa_manifest_raw_bytestream"); + EXPECT_THAT(extractor.ExtractManifestStore(input), + IsOkAndHolds("test_c2pa_manifest_raw_bytestream")); } TEST(PdfExtractorTest, ExtractManifestStoreNoSubtype) { riegeli::StringReader<> input(kTestPdfContentNoC2paSubtype); PdfExtractor extractor; - ASSERT_OK_AND_ASSIGN(auto manifest_store, - extractor.ExtractManifestStore(input)); - EXPECT_THAT(manifest_store, "test_c2pa_manifest_raw_bytestream"); + EXPECT_THAT(extractor.ExtractManifestStore(input), + IsOkAndHolds("test_c2pa_manifest_raw_bytestream")); } TEST(PdfExtractorTest, @@ -570,21 +575,17 @@ TEST(PdfExtractorTest, ExtractManifestStoreLocation) { riegeli::StringReader<> input(kTestPdfContent); PdfExtractor extractor; - ASSERT_OK_AND_ASSIGN( - auto manifest_store_location, - extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true})); - EXPECT_THAT(manifest_store_location, - Optional(ByteRange{.offset = 74, .length = 109})); + EXPECT_THAT( + extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true}), + IsOkAndHolds(Optional(ByteRange{.offset = 74, .length = 109}))); } TEST(PdfExtractorTest, ExtractManifestStoreLocationNoSubtype) { riegeli::StringReader<> input(kTestPdfContentNoC2paSubtype); PdfExtractor extractor; - ASSERT_OK_AND_ASSIGN( - auto manifest_store_location, - extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true})); - EXPECT_THAT(manifest_store_location, - Optional(ByteRange{.offset = 74, .length = 80})); + EXPECT_THAT( + extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true}), + IsOkAndHolds(Optional(ByteRange{.offset = 74, .length = 80}))); } TEST(PdfExtractorTest, ExtractManifestStoreFailsWithInvalidNamesType) { @@ -597,6 +598,115 @@ HasSubstr("credentio::Object::")))); } +std::string MakeValidPdfWithMultipleManifests(absl::string_view manifest1, + absl::string_view manifest2) { + std::vector<std::string> objs; + objs.push_back( + "1 0 obj\n<</Length 11>>\nstream\ntest_stream\nendstream\nendobj\n"); + objs.push_back(absl::StrCat("2 0 obj\n<</Length ", manifest1.size(), + " /Subtype /application#2fc2pa>>\nstream\n", + manifest1, "\nendstream\nendobj\n")); + objs.push_back( + "3 0 obj\n<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest " + "/Type /FileSpec>>\nendobj\n"); + objs.push_back(absl::StrCat("4 0 obj\n<</Length ", manifest2.size(), + " /Subtype /application#2fc2pa>>\nstream\n", + manifest2, "\nendstream\nendobj\n")); + objs.push_back( + "5 0 obj\n<</EF <</F 4 0 R>> /AFRelationship /C2PA_Manifest " + "/Type /FileSpec>>\nendobj\n"); + objs.push_back( + "6 0 obj\n<</Names [(manifest1.c2pa) 3 0 R (manifest2.c2pa) 5 0 " + "R]>>\nendobj\n"); + objs.push_back("7 0 obj\n<</EmbeddedFiles 6 0 R>>\nendobj\n"); + objs.push_back( + "8 0 obj\n<</AF [3 0 R 5 0 R] /Type /Catalog /Names 7 0 R>>\nendobj\n"); + + std::string pdf = "%PDF-1.7\n"; + std::vector<size_t> offsets; + for (const auto& obj : objs) { + offsets.push_back(pdf.size()); + pdf += obj; + } + + size_t xref_pos = pdf.size(); + pdf += "xref\n0 9\n0000000000 65535 f \n"; + for (size_t offset : offsets) { + char buf[32]; + snprintf(buf, sizeof(buf), "%010zu 00000 n \n", offset); + pdf += buf; + } + absl::StrAppend(&pdf, "trailer\n<</Root 8 0 R>>\nstartxref\n", xref_pos, + "\n%%EOF"); + return pdf; +} + +TEST(PdfExtractorTest, ExtractManifestStoreSucceedsWithMultipleManifestsInAf) { + SuperBoxBuilder manifest_builder1( + kStandardManifestUuid, + DescriptionBoxOptions{.requestable_iff_label = true, + .label = absl::Cord("manifest_1")}); + ABSL_CHECK_OK( + manifest_builder1.AddContent(kCborBoxType, absl::Cord("data_1"))); + absl::StatusOr<jumbf::BuiltSuperBox> manifest_box1 = + std::move(manifest_builder1).Finalize(); + ABSL_ASSERT_OK(manifest_box1); + + SuperBoxBuilder store_builder1( + kManifestStoreUuid, + DescriptionBoxOptions{.requestable_iff_label = true, + .label = absl::Cord(kManifestStoreLabel)}); + ABSL_CHECK_OK(store_builder1.AddChild(*std::move(manifest_box1))); + absl::StatusOr<jumbf::BuiltSuperBox> store_box1 = + std::move(store_builder1).Finalize(); + ABSL_ASSERT_OK(store_box1); + std::string store1 = std::string(std::move(*store_box1).AsCord()); + + SuperBoxBuilder manifest_builder2( + kStandardManifestUuid, + DescriptionBoxOptions{.requestable_iff_label = true, + .label = absl::Cord("manifest_2")}); + ABSL_CHECK_OK( + manifest_builder2.AddContent(kCborBoxType, absl::Cord("data_2"))); + absl::StatusOr<jumbf::BuiltSuperBox> manifest_box2 = + std::move(manifest_builder2).Finalize(); + ABSL_ASSERT_OK(manifest_box2); + + SuperBoxBuilder store_builder2( + kManifestStoreUuid, + DescriptionBoxOptions{.requestable_iff_label = true, + .label = absl::Cord(kManifestStoreLabel)}); + ABSL_CHECK_OK(store_builder2.AddChild(*std::move(manifest_box2))); + absl::StatusOr<jumbf::BuiltSuperBox> store_box2 = + std::move(store_builder2).Finalize(); + ABSL_ASSERT_OK(store_box2); + std::string store2 = std::string(std::move(*store_box2).AsCord()); + + std::string pdf = MakeValidPdfWithMultipleManifests(store1, store2); + + riegeli::StringReader<> input(pdf); + PdfExtractor extractor; + absl::StatusOr<std::string> merged = extractor.ExtractManifestStore(input); + ABSL_ASSERT_OK(merged); + absl::string_view view = *merged; + absl::StatusOr<jumbf::SuperBox> superbox = + jumbf::ConsumeSuperBox(&view, /*recursion_limit=*/9); + ABSL_ASSERT_OK(superbox); + ASSERT_EQ(superbox->contents.size(), 2); + EXPECT_EQ(superbox->contents.back().Get<jumbf::SuperBox>().description.label, + "manifest_2"); + + // Verify location points to the active (second) manifest store stream. + absl::StatusOr<std::optional<ByteRange>> manifest_store_location = + extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true}); + ABSL_ASSERT_OK(manifest_store_location); + ASSERT_TRUE(manifest_store_location->has_value()); + absl::string_view active_stream = absl::string_view(pdf).substr( + (*manifest_store_location)->offset, (*manifest_store_location)->length); + EXPECT_THAT(active_stream, HasSubstr("4 0 obj")); + EXPECT_THAT(active_stream, HasSubstr(store2)); +} + TEST(PdfExtractorTest, ExtractBoxesUnimplemented) { riegeli::StringReader<> input("test_content"); PdfExtractor extractor;
diff --git a/formats/pdf/manifest_store_merger.cc b/formats/pdf/manifest_store_merger.cc new file mode 100644 index 0000000..f67680e --- /dev/null +++ b/formats/pdf/manifest_store_merger.cc
@@ -0,0 +1,77 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/pdf/manifest_store_merger.h" + +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "constants/labels.h" +#include "jumbf/box.h" +#include "jumbf/box_builder.h" +#include "jumbf/encode_params.h" +#include "jumbf/parse.h" + +namespace credentio { + +using ::jumbf::DescriptionBoxOptions; +using ::jumbf::SuperBox; +using ::jumbf::SuperBoxBuilder; + +absl::StatusOr<std::string> MergeManifestStores( + absl::Span<const absl::string_view> stores) { + if (stores.empty()) { + return absl::InvalidArgumentError("No manifest stores provided to merge."); + } + if (stores.size() == 1) { + return std::string(stores.front()); + } + + SuperBoxBuilder builder(credentio::kManifestStoreUuid, + DescriptionBoxOptions{ + .requestable_iff_label = true, + .label = absl::Cord(kManifestStoreLabel), + }); + + // SuperBoxBuilder uses a CordBackwardWriter (LIFO insertion order). + // To ensure the newest PDF increment's manifest ends up at the BACK of the + // content list, we iterate through stores in REVERSE chronological order + // (stores.rbegin() to stores.rend()). + for (auto store_it = stores.rbegin(); store_it != stores.rend(); ++store_it) { + absl::string_view store_view = *store_it; + ABSL_ASSIGN_OR_RETURN( + SuperBox ms_superbox, + jumbf::ConsumeSuperBox(&store_view, /*recursion_limit=*/9)); + + // Iterate contents in REVERSE order so LIFO AddChild restores the exact + // original order of children inside each store. + for (auto box_it = ms_superbox.contents.rbegin(); + box_it != ms_superbox.contents.rend(); ++box_it) { + ABSL_RETURN_IF_ERROR(builder.AddChild(absl::Cord(box_it->raw_bytes))); + } + } + + ABSL_ASSIGN_OR_RETURN(auto built_box, std::move(builder).Finalize()); + return std::string(std::move(built_box).AsCord()); +} + +} // namespace credentio
diff --git a/formats/pdf/manifest_store_merger.h b/formats/pdf/manifest_store_merger.h new file mode 100644 index 0000000..420ef24 --- /dev/null +++ b/formats/pdf/manifest_store_merger.h
@@ -0,0 +1,38 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PDF_MANIFEST_STORE_MERGER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_MANIFEST_STORE_MERGER_H_ + +#include <string> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" + +namespace credentio { + +// Merges one or more serialized C2PA Manifest Store SuperBoxes ('c2pa') into +// a single unified Manifest Store SuperBox. +// +// In accordance with C2PA Spec 2.2 §10.4.3, §15.5.1, and §A.4.2.1, child boxes +// are ordered such that the manifest from the newest PDF increment (the active +// manifest in stores.back()) is placed last in the resulting SuperBox. +absl::StatusOr<std::string> MergeManifestStores( + absl::Span<const absl::string_view> stores); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PDF_MANIFEST_STORE_MERGER_H_
diff --git a/formats/pdf/manifest_store_merger_test.cc b/formats/pdf/manifest_store_merger_test.cc new file mode 100644 index 0000000..6e493d0 --- /dev/null +++ b/formats/pdf/manifest_store_merger_test.cc
@@ -0,0 +1,121 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/pdf/manifest_store_merger.h" + +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/absl_check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/box_builder.h" +#include "jumbf/constants.h" +#include "jumbf/encode_params.h" +#include "jumbf/parse.h" +#include "uuid/uuid.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio::kManifestStoreLabel; +using ::credentio::kManifestStoreUuid; +using ::credentio::kStandardManifestUuid; +using ::jumbf::DescriptionBoxOptions; +using ::jumbf::kCborBoxType; +using ::jumbf::SuperBox; +using ::jumbf::SuperBoxBuilder; + +std::string CreateTestManifestStore(absl::string_view manifest_label, + absl::string_view payload_data) { + SuperBoxBuilder manifest_builder( + kStandardManifestUuid, + DescriptionBoxOptions{.requestable_iff_label = true, + .label = absl::Cord(manifest_label)}); + ABSL_CHECK_OK( + manifest_builder.AddContent(kCborBoxType, absl::Cord(payload_data))); + absl::StatusOr<jumbf::BuiltSuperBox> manifest_box = + std::move(manifest_builder).Finalize(); + ABSL_CHECK_OK(manifest_box); + + SuperBoxBuilder store_builder( + kManifestStoreUuid, + DescriptionBoxOptions{.requestable_iff_label = true, + .label = absl::Cord(kManifestStoreLabel)}); + ABSL_CHECK_OK(store_builder.AddChild(*std::move(manifest_box))); + absl::StatusOr<jumbf::BuiltSuperBox> store_box = + std::move(store_builder).Finalize(); + ABSL_CHECK_OK(store_box); + return std::string(std::move(*store_box).AsCord()); +} + +TEST(ManifestStoreMergerTest, EmptyStoresListReturnsError) { + EXPECT_THAT(MergeManifestStores({}), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ManifestStoreMergerTest, SingleStoreReturnedUnmodified) { + std::string store = CreateTestManifestStore("test_manifest", "data"); + EXPECT_THAT(MergeManifestStores({store}), IsOkAndHolds(store)); +} + +TEST(ManifestStoreMergerTest, MultipleStoresMergedInCorrectOrder) { + std::string store1 = CreateTestManifestStore("manifest_1", "data_1"); + std::string store2 = CreateTestManifestStore("manifest_2", "data_2"); + + absl::StatusOr<std::string> merged = MergeManifestStores({store1, store2}); + ABSL_ASSERT_OK(merged); + + absl::string_view view = *merged; + absl::StatusOr<SuperBox> superbox = + jumbf::ConsumeSuperBox(&view, /*recursion_limit=*/9); + ABSL_ASSERT_OK(superbox); + + EXPECT_TRUE(view.empty()); // Entire buffer consumed. + EXPECT_EQ(superbox->description.type_uuid, kManifestStoreUuid); + ASSERT_EQ(superbox->contents.size(), 2); + + // Active manifest from store2 should be placed LAST in contents. + const auto& box2 = superbox->contents.back(); + EXPECT_TRUE(box2.Holds<SuperBox>()); + EXPECT_EQ(box2.Get<SuperBox>().description.type_uuid, kStandardManifestUuid); + EXPECT_EQ(box2.Get<SuperBox>().description.label, "manifest_2"); + + // Initial manifest from store1 should be placed FIRST in contents. + const auto& box1 = superbox->contents.front(); + EXPECT_TRUE(box1.Holds<SuperBox>()); + EXPECT_EQ(box1.Get<SuperBox>().description.type_uuid, kStandardManifestUuid); + EXPECT_EQ(box1.Get<SuperBox>().description.label, "manifest_1"); +} + +TEST(ManifestStoreMergerTest, MalformedStoreReturnsError) { + std::string valid_store = CreateTestManifestStore("manifest_1", "data_1"); + std::string malformed_store = "not a jumbf box"; + + EXPECT_FALSE(MergeManifestStores({valid_store, malformed_store}).ok()); +} + +} // namespace +} // namespace credentio
diff --git a/formats/pdf/number_validator.cc b/formats/pdf/number_validator.cc new file mode 100644 index 0000000..ec7f355 --- /dev/null +++ b/formats/pdf/number_validator.cc
@@ -0,0 +1,64 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/pdf/number_validator.h" + +#include <cstddef> + +#include "absl/strings/string_view.h" + +namespace credentio { + +bool IsValidRealNumber(absl::string_view s) { + if (s.empty()) return false; + bool seen_decimal = false; + bool seen_e = false; + for (size_t i = 0; i < s.size(); ++i) { + const char c = s[i]; + if (c == '+' || c == '-') { + if (i != 0 && s[i - 1] != 'e' && s[i - 1] != 'E') return false; + if (i == s.size() - 1) return false; + } else if (c == '.') { + if (seen_decimal || seen_e) return false; + seen_decimal = true; + } else if (c == 'e' || c == 'E') { + if (seen_e || i == 0 || i == s.size() - 1) return false; + seen_e = true; + } else if (c < '0' || c > '9') { + return false; + } + } + bool has_digit = false; + for (const char c : s) { + if (c >= '0' && c <= '9') { + has_digit = true; + break; + } + } + return has_digit; +} + +bool IsValidHexadecimalString(absl::string_view s) { + for (const char c : s) { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F') || c == ' ' || c == '\t' || c == '\r' || + c == '\n' || c == '\f' || c == '\0')) { + return false; + } + } + return true; +} + +} // namespace credentio
diff --git a/formats/pdf/number_validator.h b/formats/pdf/number_validator.h new file mode 100644 index 0000000..975f3b3 --- /dev/null +++ b/formats/pdf/number_validator.h
@@ -0,0 +1,35 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PDF_NUMBER_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_NUMBER_VALIDATOR_H_ + +#include "absl/strings/string_view.h" + +namespace credentio { + +// Returns true if the string is a valid representation of a real number in PDF. +// This supports typical real numbers conformant to PDF specifications, with +// optional decimal point, signs, and scientific notation (e.g. 1.23e-4 or +// -12.34E+2). +bool IsValidRealNumber(absl::string_view s); + +// Returns true if the string is a valid representation of a hexadecimal string +// in PDF. Allowed characters include hex digits, whitespaces, and null chars. +bool IsValidHexadecimalString(absl::string_view s); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PDF_NUMBER_VALIDATOR_H_
diff --git a/formats/pdf/number_validator_test.cc b/formats/pdf/number_validator_test.cc new file mode 100644 index 0000000..4ee9f61 --- /dev/null +++ b/formats/pdf/number_validator_test.cc
@@ -0,0 +1,122 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/pdf/number_validator.h" + +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +TEST(NumberValidatorTest, ValidRealNumbers) { + EXPECT_TRUE(IsValidRealNumber("1.23")); + EXPECT_TRUE(IsValidRealNumber("0.0")); + EXPECT_TRUE(IsValidRealNumber("-0.0")); + EXPECT_TRUE(IsValidRealNumber("+1.5")); + EXPECT_TRUE(IsValidRealNumber(".5")); + EXPECT_TRUE(IsValidRealNumber("+.5")); + EXPECT_TRUE(IsValidRealNumber("-5.")); + EXPECT_TRUE(IsValidRealNumber(".0")); + EXPECT_TRUE(IsValidRealNumber("-.0")); + EXPECT_TRUE(IsValidRealNumber("+.0")); +} + +TEST(NumberValidatorTest, ValidIntegers) { + EXPECT_TRUE(IsValidRealNumber("0")); + EXPECT_TRUE(IsValidRealNumber("123")); + EXPECT_TRUE(IsValidRealNumber("-456")); + EXPECT_TRUE(IsValidRealNumber("+789")); +} + +TEST(NumberValidatorTest, ValidScientificNotation) { + EXPECT_TRUE(IsValidRealNumber("1e5")); + EXPECT_TRUE(IsValidRealNumber("1E5")); + EXPECT_TRUE(IsValidRealNumber("1.2e-3")); + EXPECT_TRUE(IsValidRealNumber("+1.23E+4")); + EXPECT_TRUE(IsValidRealNumber("-1.2e3")); + EXPECT_TRUE(IsValidRealNumber(".5e+2")); + EXPECT_TRUE(IsValidRealNumber("5.e-2")); +} + +TEST(NumberValidatorTest, InvalidEmptyOrNoDigits) { + EXPECT_FALSE(IsValidRealNumber("")); + EXPECT_FALSE(IsValidRealNumber("+")); + EXPECT_FALSE(IsValidRealNumber("-")); + EXPECT_FALSE(IsValidRealNumber(".")); + EXPECT_FALSE(IsValidRealNumber("e")); + EXPECT_FALSE(IsValidRealNumber("E")); + EXPECT_FALSE(IsValidRealNumber("+e")); + EXPECT_FALSE(IsValidRealNumber(".e")); + EXPECT_FALSE(IsValidRealNumber("+.")); + EXPECT_FALSE(IsValidRealNumber("+e-")); + EXPECT_FALSE(IsValidRealNumber(".e+")); +} + +TEST(NumberValidatorTest, InvalidCharacters) { + EXPECT_FALSE(IsValidRealNumber("abc")); + EXPECT_FALSE(IsValidRealNumber("1.2a")); + EXPECT_FALSE(IsValidRealNumber(" 1.2")); + EXPECT_FALSE(IsValidRealNumber("1.2 ")); + EXPECT_FALSE(IsValidRealNumber("1e 5")); + EXPECT_FALSE(IsValidRealNumber("0x123")); + EXPECT_FALSE(IsValidRealNumber("NaN")); + EXPECT_FALSE(IsValidRealNumber("inf")); + EXPECT_FALSE(IsValidRealNumber("infinity")); +} + +TEST(NumberValidatorTest, InvalidMultipleSymbols) { + EXPECT_FALSE(IsValidRealNumber("1..2")); + EXPECT_FALSE(IsValidRealNumber("1ee5")); + EXPECT_FALSE(IsValidRealNumber("1e-5e-6")); + EXPECT_FALSE(IsValidRealNumber("--5")); + EXPECT_FALSE(IsValidRealNumber("++5")); + EXPECT_FALSE(IsValidRealNumber("1e+-5")); + EXPECT_FALSE(IsValidRealNumber("1e--5")); + EXPECT_FALSE(IsValidRealNumber("1e++5")); +} + +TEST(NumberValidatorTest, InvalidSymbolPositions) { + EXPECT_FALSE(IsValidRealNumber("1+")); + EXPECT_FALSE(IsValidRealNumber("1-")); + EXPECT_FALSE(IsValidRealNumber("1e5+")); + EXPECT_FALSE(IsValidRealNumber("1e5-")); + EXPECT_FALSE(IsValidRealNumber("e1")); + EXPECT_FALSE(IsValidRealNumber("E12")); +} + +TEST(NumberValidatorTest, InvalidDecimalAfterExponent) { + EXPECT_FALSE(IsValidRealNumber("1e5.5")); + EXPECT_FALSE(IsValidRealNumber("1.2e3.4")); + EXPECT_FALSE(IsValidRealNumber(".5e2.")); +} + +TEST(HexadecimalStringValidatorTest, ValidHexadecimalStrings) { + EXPECT_TRUE(IsValidHexadecimalString("")); + EXPECT_TRUE(IsValidHexadecimalString("4E6F76")); + EXPECT_TRUE(IsValidHexadecimalString("4e6f76")); + EXPECT_TRUE(IsValidHexadecimalString("0123456789ABCDEFabcdef")); + EXPECT_TRUE(IsValidHexadecimalString("4E 6F 76")); + EXPECT_TRUE(IsValidHexadecimalString("4E\t6F\r76\n")); +} + +TEST(HexadecimalStringValidatorTest, InvalidHexadecimalStrings) { + EXPECT_FALSE(IsValidHexadecimalString("4E6F76g")); + EXPECT_FALSE(IsValidHexadecimalString("4e6f76-")); + EXPECT_FALSE(IsValidHexadecimalString("4E6F76+")); + EXPECT_FALSE(IsValidHexadecimalString("abc/def")); +} + +} // namespace +} // namespace credentio
diff --git a/formats/pdf/object_reader_test.cc b/formats/pdf/object_reader_test.cc index 97b21b8..bc9a513 100644 --- a/formats/pdf/object_reader_test.cc +++ b/formats/pdf/object_reader_test.cc
@@ -54,10 +54,10 @@ std::string content = absl::StrCat("true false", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Boolean>(Object::Boolean{.value = true})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Boolean>(Object::Boolean{.value = false})); } @@ -77,19 +77,19 @@ absl::StrCat("123 43445 +17 -98 0", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Integer>(Object::Integer{.value = 123})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Integer>(Object::Integer{.value = 43445})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Integer>(Object::Integer{.value = 17})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Integer>(Object::Integer{.value = -98})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Integer>(Object::Integer{.value = 0})); } @@ -108,7 +108,7 @@ std::string content = absl::StrCat("123(", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Integer>(Object::Integer{.value = 123})); } @@ -117,7 +117,7 @@ std::string content = absl::StrCat("123\t", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Integer>(Object::Integer{.value = 123})); } @@ -127,22 +127,22 @@ absl::StrCat("34.5 -3.62 +123.6 4. -.002 0.0", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>( Object::RealNumber{.raw_value = "34.5"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>( Object::RealNumber{.raw_value = "-3.62"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>( Object::RealNumber{.raw_value = "+123.6"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>( Object::RealNumber{.raw_value = "4."})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>( Object::RealNumber{.raw_value = "-.002"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>( Object::RealNumber{.raw_value = "0.0"})); } @@ -158,28 +158,28 @@ (It has zero (0) length.))"; riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::LiteralString>( Object::LiteralString{.raw_value = R"(This is a string)"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::LiteralString>(Object::LiteralString{ .raw_value = R"(Strings can contain newlines and such.)"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::LiteralString>(Object::LiteralString{ .raw_value = R"(Strings can contain balanced parentheses () and special characters ( * ! & } ^ %and so on) .)"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::LiteralString>(Object::LiteralString{ .raw_value = R"(The following is an empty string .)"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::LiteralString>( Object::LiteralString{.raw_value = R"()"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::LiteralString>(Object::LiteralString{ .raw_value = R"(It has zero (0) length.)"})); @@ -189,14 +189,14 @@ std::string content = "<4E6F762073686D6F7A206B6120706F702E> <901FA3> <901FA>"; riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::HexadecimalString>(Object::HexadecimalString{ .raw_value = "4E6F762073686D6F7A206B6120706F702E"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::HexadecimalString>( Object::HexadecimalString{.raw_value = "901FA3"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::HexadecimalString>( Object::HexadecimalString{.raw_value = "901FA"})); } @@ -210,37 +210,37 @@ kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{.value = "Name1"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>( Object::Name{.value = "ASomewhatLongerName"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{ .value = "A;Name_With-Various***Characters?"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{.value = "1.2"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{.value = "$$"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{.value = "@pattern"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{.value = ".notdef"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{.value = "Lime Green"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>( Object::Name{.value = "paired()parentheses"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>( Object::Name{.value = "The_Key_of_F#_Minor"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{.value = "AB"})); } @@ -249,7 +249,7 @@ std::string content = absl::StrCat("/Name]", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{.value = "Name"})); } @@ -258,7 +258,7 @@ std::string content = absl::StrCat("/Name\n", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{.value = "Name"})); } @@ -268,7 +268,7 @@ absl::StrCat("[549 3.14 false (Ralph) /SomeName]", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT( obj.value, VariantWith<Object::Array>(Object::Array{ @@ -284,7 +284,7 @@ std::string content = absl::StrCat("[549 [3.14]]", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT( obj.value, VariantWith<Object::Array>(Object::Array{ @@ -303,7 +303,7 @@ absl::StrCat("[549 %comment \n 3.14 % comment \n]", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT( obj.value, VariantWith<Object::Array>(Object::Array{ @@ -317,7 +317,7 @@ absl::StrCat("<</Type /Example /Subtype /Table>>", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); Object expected_obj; Object::Dictionary& dict = expected_obj.value.emplace<Object::Dictionary>(); @@ -331,7 +331,7 @@ "<</Type /Example %comment \n /Subtype /Table>>", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); Object expected_obj; Object::Dictionary& dict = expected_obj.value.emplace<Object::Dictionary>(); @@ -346,11 +346,11 @@ kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{.value = "abc"})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Integer>(Object::Integer{.value = 123})); } @@ -359,7 +359,7 @@ std::string content = absl::StrCat("<<>>", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); Object expected_obj = Object{.value = Object::Dictionary{}}; EXPECT_EQ(obj, expected_obj); @@ -370,7 +370,7 @@ absl::StrCat("<</Subdictionary << /Item1 0.4 >> >>", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); Object expected_obj; Object::Dictionary& dict = expected_obj.value.emplace<Object::Dictionary>(); @@ -386,7 +386,7 @@ std::string content = "null"; riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Null>(Object::Null{})); } @@ -403,7 +403,7 @@ std::string content = absl::StrCat("123 321 R", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::IndirectReference>(Object::IndirectReference{ .object_number = 123, .generation_number = 321})); @@ -413,12 +413,12 @@ std::string content = absl::StrCat("123 /Root 1 2 R", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, /*obj_offset=*/0, obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, /*obj_offset=*/0, obj)); // "123 /Root 1 2 R ...." should not be matched as an indirect reference, the // preceding integer should be read instead. EXPECT_THAT(obj.value, VariantWith<Object::Integer>(Object::Integer{.value = 123})); - ASSERT_THAT(ReadObject(input, /*obj_offset=*/10, obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, /*obj_offset=*/10, obj)); // "1 2 R ..." should be matched as an indirect reference. EXPECT_THAT(obj.value, VariantWith<Object::IndirectReference>(Object::IndirectReference{ @@ -429,11 +429,11 @@ std::string content = absl::StrCat("123 321 R /Name", kContentPadding); riegeli::StringReader<> input(content); Object obj; - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::IndirectReference>(Object::IndirectReference{ .object_number = 123, .generation_number = 321})); - ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj)); EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{.value = "Name"})); } @@ -465,8 +465,7 @@ endobj)"; riegeli::StringReader<> input(content); IndirectObject obj; - ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj), - IsOk()); + ABSL_ASSERT_OK(ReadIndirectObject(input, input.pos(), MockResolver(), obj)); EXPECT_EQ(obj.object_number, 12); EXPECT_EQ(obj.generation_number, 0); EXPECT_THAT(obj.object.value, @@ -519,8 +518,7 @@ "1 2 obj <</Length 10>> stream\r\n1234567890\r\nendstream endobj"; riegeli::StringReader<> input(content); IndirectObject obj; - ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj), - IsOk()); + ABSL_ASSERT_OK(ReadIndirectObject(input, input.pos(), MockResolver(), obj)); EXPECT_EQ(obj.object_number, 1); EXPECT_EQ(obj.generation_number, 2); EXPECT_THAT(obj.stream_dictionary.value().entries, @@ -535,8 +533,7 @@ "1 2 obj <</Length 10>> stream\n1234567890\nendstream endobj"; riegeli::StringReader<> input(content); IndirectObject obj; - ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj), - IsOk()); + ABSL_ASSERT_OK(ReadIndirectObject(input, input.pos(), MockResolver(), obj)); EXPECT_EQ(obj.object_number, 1); EXPECT_EQ(obj.generation_number, 2); EXPECT_THAT(obj.stream_dictionary.value().entries, @@ -575,8 +572,7 @@ "m endobj"; riegeli::StringReader<> input(content); IndirectObject obj; - ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj), - IsOk()); + ABSL_ASSERT_OK(ReadIndirectObject(input, input.pos(), MockResolver(), obj)); EXPECT_EQ(obj.object_number, 1); EXPECT_EQ(obj.generation_number, 2); EXPECT_THAT(obj.stream_dictionary.value().entries, @@ -609,9 +605,8 @@ .generation_number = 4, .object = Object{.value = Object::Integer{.value = 10}}}); IndirectObject obj; - ASSERT_THAT( - ReadIndirectObject(input, input.pos(), std::move(mock_resolver), obj), - IsOk()); + ABSL_ASSERT_OK( + ReadIndirectObject(input, input.pos(), std::move(mock_resolver), obj)); EXPECT_EQ(obj.object_number, 1); EXPECT_EQ(obj.generation_number, 2); EXPECT_THAT(
diff --git a/formats/pdf/object_writer.cc b/formats/pdf/object_writer.cc new file mode 100644 index 0000000..e54b754 --- /dev/null +++ b/formats/pdf/object_writer.cc
@@ -0,0 +1,314 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/pdf/object_writer.h" + +#include <cstddef> +#include <cstdint> +#include <string> +#include <variant> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "formats/pdf/number_validator.h" +#include "formats/pdf/objects.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { +namespace { + +constexpr absl::string_view kTrue = "true"; +constexpr absl::string_view kFalse = "false"; +constexpr absl::string_view kNull = "null"; + +absl::Status WriteObject(const Object::Boolean& obj, riegeli::Writer& output) { + std::string buffer = obj.value ? std::string(kTrue) : std::string(kFalse); + if (!output.Write(buffer)) { + return output.status(); + } + return absl::OkStatus(); +} + +absl::Status WriteObject(const Object::Integer& obj, riegeli::Writer& output) { + std::string buffer = absl::StrCat(obj.value); + if (!output.Write(buffer)) { + return output.status(); + } + return absl::OkStatus(); +} + +absl::Status WriteObject(const Object::RealNumber& obj, + riegeli::Writer& output) { + if (!IsValidRealNumber(obj.raw_value)) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid real number: ", obj.raw_value)); + } + std::string buffer = obj.raw_value; + if (!output.Write(buffer)) { + return output.status(); + } + return absl::OkStatus(); +} + +absl::Status WriteObject(const Object::LiteralString& obj, + riegeli::Writer& output) { + std::string buffer = "("; + for (const char c : obj.raw_value) { + if (c == '(' || c == ')' || c == '\\') { + buffer += '\\'; + buffer += c; + } else { + buffer += c; + } + } + buffer += ')'; + if (!output.Write(buffer)) { + return output.status(); + } + return absl::OkStatus(); +} + +absl::Status WriteObject(const Object::HexadecimalString& obj, + riegeli::Writer& output) { + if (!IsValidHexadecimalString(obj.raw_value)) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid hexadecimal string: ", obj.raw_value)); + } + std::string buffer = absl::StrCat("<", obj.raw_value, ">"); + if (!output.Write(buffer)) { + return output.status(); + } + return absl::OkStatus(); +} + +absl::Status WriteObject(const Object::Name& obj, riegeli::Writer& output) { + std::string buffer = "/"; + for (const char c : obj.value) { + // Escape control characters, non-ASCII, slash, and any PDF delimiter + // character. Delimiters include: (, ), <, >, [, ], {, }, %, #, / + if (c < 0x21 || c > 0x7e || c == 0x2f || c == '(' || c == ')' || c == '<' || + c == '>' || c == '[' || c == ']' || c == '{' || c == '}' || c == '%' || + c == '#') { + absl::StrAppendFormat(&buffer, "#%02x", static_cast<unsigned char>(c)); + } else { + buffer += c; + } + } + if (!output.Write(buffer)) { + return output.status(); + } + return absl::OkStatus(); +} + +absl::Status WriteObject(const Object::Array& obj, riegeli::Writer& output) { + // Write the open bracket. + std::string delimiter = "["; + if (!output.Write(delimiter)) { + return output.status(); + } + + // Write the elements separated by spaces. + std::string space = " "; + for (auto i = 0; i < obj.objects.size(); ++i) { + ABSL_RETURN_IF_ERROR(WriteObject(obj.objects[i], output)); + // Write the space as a delimiter when necessary. + if (i < obj.objects.size() - 1) { + if (!output.Write(space)) { + return output.status(); + } + } + } + // Write the close bracket. + delimiter = "]"; + if (!output.Write(delimiter)) { + return output.status(); + } + return absl::OkStatus(); +} + +absl::Status WriteObject(const Object::Dictionary& obj, + riegeli::Writer& output) { + // Write the open brackets. + std::string delimiter = "<<"; + if (!output.Write(delimiter)) { + return output.status(); + } + + // Write the entries separated by spaces. + std::string space = " "; + int entries_written = 0; + for (const auto& [key, value] : obj.entries) { + // Write the key as a Name object. + auto key_obj = Object{.value = Object::Name{.value = key}}; + ABSL_RETURN_IF_ERROR(WriteObject(key_obj, output)); + + // Write the space as a delimiter. + if (!output.Write(space)) { + return output.status(); + } + + // Write the value object. + ABSL_RETURN_IF_ERROR(WriteObject(value, output)); + + // Write the space as a delimiter when necessary. + if (entries_written < obj.entries.size() - 1) { + if (!output.Write(space)) { + return output.status(); + } + } + ++entries_written; + } + delimiter = ">>"; + if (!output.Write(delimiter)) { + return output.status(); + } + return absl::OkStatus(); +} + +absl::Status WriteObject(const Object::Stream& obj, riegeli::Writer& output) { + std::string buffer = "stream\n"; + if (!output.Write(buffer)) { + return output.status(); + } + + buffer = obj.raw_value; + if (!output.Write(buffer)) { + return output.status(); + } + + buffer = "\nendstream"; + if (!output.Write(buffer)) { + return output.status(); + } + return absl::OkStatus(); +} + +absl::Status WriteObject(const Object::Null& obj, riegeli::Writer& output) { + std::string buffer = std::string(kNull); + if (!output.Write(buffer)) { + return output.status(); + } + return absl::OkStatus(); +} + +absl::Status WriteObject(const Object::IndirectReference& obj, + riegeli::Writer& output) { + std::string buffer = + absl::StrCat(obj.object_number, " ", obj.generation_number, " R"); + if (!output.Write(buffer)) { + return output.status(); + } + return absl::OkStatus(); +} + +} // namespace + +absl::Status WriteObject(const Object& obj, riegeli::Writer& output) { + if (std::holds_alternative<Object::Stream>(obj.value)) { + return absl::InvalidArgumentError( + "Stream must be written as an indirect object. Call " + "WriteIndirectObject() instead."); + } + return std::visit( + [&output](const auto& obj) { return WriteObject(obj, output); }, + obj.value); +} + +absl::Status WriteIndirectObject(const IndirectObject& obj, + riegeli::Writer& output, bool is_measurement) { + // Write the object header. + std::string buffer = + absl::StrCat(obj.object_number, " ", obj.generation_number, " obj\n"); + if (!output.Write(buffer)) { + return output.status(); + } + if (obj.stream_dictionary.has_value()) { + ABSL_ASSIGN_OR_RETURN(auto obj_stream, + Object::As<Object::Stream>(obj.object)); + Object::Dictionary serialized_dict = *obj.stream_dictionary; + + if (!obj_stream.raw_value.empty()) { + serialized_dict.entries["Length"] = + Object{.value = Object::Integer{.value = static_cast<int64_t>( + obj_stream.raw_value.length())}}; + } else if (!is_measurement) { + // Safely lock genuinely empty streams to /Length 0 to prevent smuggling. + serialized_dict.entries["Length"] = + Object{.value = Object::Integer{.value = 0}}; + } + ABSL_RETURN_IF_ERROR(WriteObject(Object{.value = serialized_dict}, output)); + + // Write a newline to separate the stream dictionary from the stream. + buffer = "\n"; + if (!output.Write(buffer)) { + return output.status(); + } + ABSL_RETURN_IF_ERROR(WriteObject(obj_stream, output)); + } else { + ABSL_RETURN_IF_ERROR(WriteObject(obj.object, output)); + } + + // Write the end-of-object marker. + buffer = "\nendobj\n"; + if (!output.Write(buffer)) { + return output.status(); + } + return absl::OkStatus(); +} + +absl::StatusOr<int64_t> ComputeIndirectStreamHeaderByteSize( + const IndirectObject& indirect_stream_obj) { + if (!std::holds_alternative<Object::Stream>( + indirect_stream_obj.object.value)) { + return absl::InvalidArgumentError( + "The object is not an indirect stream object."); + } + // Create a copy of the indirect stream object with only the header info to + // avoid serializing the entire stream. + IndirectObject indirect_stream_obj_copy; + indirect_stream_obj_copy.object_number = indirect_stream_obj.object_number; + indirect_stream_obj_copy.generation_number = + indirect_stream_obj.generation_number; + indirect_stream_obj_copy.stream_dictionary = + indirect_stream_obj.stream_dictionary; + indirect_stream_obj_copy.object = Object{.value = Object::Stream{}}; + + std::string serialized_obj; + riegeli::StringWriter<> output(&serialized_obj); + ABSL_RETURN_IF_ERROR(WriteIndirectObject(indirect_stream_obj_copy, output, + /*is_measurement=*/true)); + if (!output.Close()) { + return output.status(); + } + + size_t pos = serialized_obj.length(); + while ((pos = serialized_obj.rfind("stream\n", pos)) != std::string::npos) { + if (pos >= 3 && serialized_obj.substr(pos - 3, 3) == "end") { + if (pos == 0) break; + pos--; + continue; + } + return pos + 7; + } + + return absl::InternalError( + "Failed to find the stream marker in the serialized object."); +} +} // namespace credentio
diff --git a/formats/pdf/object_writer.h b/formats/pdf/object_writer.h new file mode 100644 index 0000000..a827666 --- /dev/null +++ b/formats/pdf/object_writer.h
@@ -0,0 +1,44 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECT_WRITER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECT_WRITER_H_ + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "formats/pdf/objects.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// Writes the given object to the output at the current position. +absl::Status WriteObject(const Object& obj, riegeli::Writer& output); + +// Writes the given indirect object to the output at the current position. +absl::Status WriteIndirectObject(const IndirectObject& obj, + riegeli::Writer& output, + bool is_measurement = false); + +// Computes the size of the "header" of an indirect stream object in bytes. The +// "header" refers to all the content written for an indirect stream object +// before the actual stream data begins. +absl::StatusOr<int64_t> ComputeIndirectStreamHeaderByteSize( + const IndirectObject& indirect_stream_obj); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECT_WRITER_H_
diff --git a/formats/pdf/object_writer_test.cc b/formats/pdf/object_writer_test.cc new file mode 100644 index 0000000..957eff1 --- /dev/null +++ b/formats/pdf/object_writer_test.cc
@@ -0,0 +1,570 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/pdf/object_writer.h" + +#include <algorithm> +#include <cstddef> +#include <ostream> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "formats/pdf/objects.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +class FailingWriter : public riegeli::Writer { + public: + explicit FailingWriter(absl::Status status) { Fail(status); } + + bool PushSlow(size_t min_length, size_t recommended_length) override { + return false; + } +}; + +class StepFailingWriter : public riegeli::Writer { + public: + explicit StepFailingWriter(int fail_after_bytes, absl::Status status) + : fail_after_bytes_(fail_after_bytes), status_(status) { + set_buffer(buffer_, 1); + } + + bool PushSlow(size_t min_length, size_t recommended_length) override { + if (fail_after_bytes_ <= 0) { + Fail(status_); + return false; + } + size_t write_len = + std::min(min_length, static_cast<size_t>(fail_after_bytes_)); + fail_after_bytes_ -= write_len; + if (fail_after_bytes_ <= 0) { + Fail(status_); + return false; + } + set_buffer(buffer_, 1); + return true; + } + + private: + int fail_after_bytes_; + absl::Status status_; + char buffer_[1]; +}; + +TEST(WriteObjectTest, WriteBoolean) { + Object obj = {.value = Object::Boolean{.value = true}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, "true"); +} + +TEST(WriteObjectTest, WriteInteger) { + Object obj = {.value = Object::Integer{.value = 123}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, "123"); +} + +TEST(WriteObjectTest, WriteIntegerPreservesNegativeSign) { + Object obj = {.value = Object::Integer{.value = -123}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, "-123"); +} + +TEST(WriteObjectTest, WriteRealNumber) { + Object obj = {.value = Object::RealNumber{.raw_value = "1.23"}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, "1.23"); +} + +TEST(WriteObjectTest, WriteLiteralString) { + Object obj = {.value = Object::LiteralString{.raw_value = "abc"}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, "(abc)"); +} + +TEST(WriteObjectTest, WriteHexadecimalString) { + Object obj = {.value = Object::HexadecimalString{.raw_value = "4E6F76"}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, "<4E6F76>"); +} + +TEST(WriteObjectTest, WriteName) { + Object obj = {.value = Object::Name{.value = "Catalog"}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, "/Catalog"); +} + +TEST(WriteObjectTest, WriteNameWithEscapedCharacters) { + Object obj = {.value = Object::Name{.value = "/\x20\x80"}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, "/#2f#20#80"); +} + +TEST(WriteObjectTest, WriteArray) { + Object obj = { + .value = Object::Array{ + .objects = { + Object{.value = Object::LiteralString{.raw_value = "abc"}}, + Object{.value = Object::LiteralString{.raw_value = "def"}}}}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, "[(abc) (def)]"); +} + +TEST(WriteObjectTest, WriteDictionary) { + Object obj = { + .value = Object::Dictionary{ + .entries = { + {"key_1", Object{.value = Object::Name{.value = "name"}}}, + {"key_2", Object{.value = Object::Integer{.value = 123}}}}}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s.substr(0, 2), "<<"); + // The order of the entries is non-deterministic, so we check the substrings + // instead of the entire string. + EXPECT_THAT(output_s, HasSubstr("/key_1 /name")); + EXPECT_THAT(output_s, HasSubstr("/key_2 123")); + EXPECT_EQ(output_s.substr(output_s.size() - 2, 2), ">>"); +} + +TEST(WriteObjectTest, WriteStreamFailure) { + Object obj = {.value = Object::Stream{.raw_value = "1234567890"}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + + EXPECT_THAT( + WriteObject(obj, output), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Stream must be written as an indirect " + "object. Call WriteIndirectObject() instead."))); +} + +TEST(WriteObjectTest, WriteNull) { + Object obj = {.value = Object::Null{}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, "null"); +} + +TEST(WriteIndirectObjectTest, WriteStream) { + IndirectObject indirect_obj = { + .object_number = 1, + .generation_number = 2, + .stream_dictionary = + Object::Dictionary{ + .entries = {{"Length", + Object{.value = Object::Integer{.value = 10}}}}}, + .object = {.value = Object::Stream{.raw_value = "1234567890"}}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteIndirectObject(indirect_obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, R"(1 2 obj +<</Length 10>> +stream +1234567890 +endstream +endobj +)"); +} + +TEST(WriteIndirectObjectTest, WriteName) { + IndirectObject indirect_obj = { + .object_number = 1, + .generation_number = 2, + .object = {.value = Object::Name{.value = "Catalog"}}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteIndirectObject(indirect_obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, R"(1 2 obj +/Catalog +endobj +)"); +} + +TEST(ComputeIndirectStreamHeaderLengthTest, ComputeHeaderLengthCorrectly) { + IndirectObject indirect_obj = { + .object_number = 1, + .generation_number = 2, + .stream_dictionary = + Object::Dictionary{ + .entries = {{"Length", + Object{.value = Object::Integer{.value = 10}}}}}, + .object = {.value = Object::Stream{.raw_value = "1234567890"}}}; + + auto header_len = ComputeIndirectStreamHeaderByteSize(indirect_obj); + ABSL_ASSERT_OK(header_len); + EXPECT_EQ(*header_len, 30); +} + +TEST(ComputeIndirectStreamHeaderLengthTest, FailsForNonStreamObject) { + IndirectObject indirect_obj = { + .object_number = 1, + .generation_number = 2, + .object = {.value = Object::LiteralString{.raw_value = "abc"}}}; + + EXPECT_THAT( + ComputeIndirectStreamHeaderByteSize(indirect_obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("The object is not an indirect stream object"))); +} + +TEST(ComputeIndirectStreamHeaderLengthTest, + HandlesStreamSubstringInDictionaryField) { + IndirectObject indirect_obj = { + .object_number = 1, + .generation_number = 2, + .stream_dictionary = + Object::Dictionary{ + .entries = {{"Title", + Object{.value = + Object::LiteralString{ + .raw_value = "my stream\n title"}}}}}, + .object = {.value = Object::Stream{.raw_value = "1234567890"}}}; + + auto header_len = ComputeIndirectStreamHeaderByteSize(indirect_obj); + ABSL_ASSERT_OK(header_len); + EXPECT_EQ(*header_len, 45); +} + +TEST(WriteObjectTest, InjectionBlocked) { + // 1. LiteralString Injection is escaped safely + { + Object obj = {.value = Object::LiteralString{ + .raw_value = "payload) /Injected /Dictionary ("}}; + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + EXPECT_EQ(output_s, "(payload\\) /Injected /Dictionary \\()"); + } + + // 2. HexadecimalString Injection is rejected as invalid hex + { + Object obj = {.value = Object::HexadecimalString{ + .raw_value = "4E6F76> /Injected /Dictionary <"}}; + std::string output_s; + riegeli::StringWriter<> output(&output_s); + EXPECT_THAT(WriteObject(obj, output), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Invalid hexadecimal string"))); + } + + // 3. RealNumber Injection is rejected as invalid real number + { + Object obj = {.value = Object::RealNumber{.raw_value = "1.23 /Injected"}}; + std::string output_s; + riegeli::StringWriter<> output(&output_s); + EXPECT_THAT(WriteObject(obj, output), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Invalid real number"))); + } + + // 4. Name Injection is escaped safely + { + Object obj = {.value = Object::Name{.value = "MyName)(InjectedString)"}}; + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteObject(obj, output)); + ASSERT_TRUE(output.Close()); + EXPECT_EQ(output_s, "/MyName#29#28InjectedString#29"); + } +} + +TEST(WriteIndirectObjectTest, EmptyStreamLengthSynchronization) { + // 1. Genuinely empty stream (not C2PA template) with non-zero /Length should + // have its /Length forced to 0. + { + IndirectObject indirect_obj = { + .object_number = 1, + .generation_number = 2, + .stream_dictionary = + Object::Dictionary{ + .entries = {{"Length", + Object{.value = Object::Integer{.value = 10}}}}}, + .object = {.value = Object::Stream{.raw_value = ""}}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK(WriteIndirectObject(indirect_obj, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_EQ(output_s, R"(1 2 obj +<</Length 0>> +stream + +endstream +endobj +)"); + } + + // 2. C2PA manifest store template stream during measurement (empty raw_value) + // with is_measurement = true should preserve its original /Length. + { + IndirectObject indirect_obj = { + .object_number = 1, + .generation_number = 2, + .stream_dictionary = + Object::Dictionary{ + .entries = + {{"Length", Object{.value = Object::Integer{.value = 100}}}, + {"Subtype", + Object{.value = + Object::Name{.value = "application/c2pa"}}}}}, + .object = {.value = Object::Stream{.raw_value = ""}}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK( + WriteIndirectObject(indirect_obj, output, /*is_measurement=*/true)); + ASSERT_TRUE(output.Close()); + + // Substrings checked as dict serialization order is non-deterministic. + EXPECT_THAT(output_s, HasSubstr("/Length 100")); + EXPECT_THAT(output_s, HasSubstr("/Subtype /application#2fc2pa")); + EXPECT_THAT(output_s, HasSubstr("stream\n\nendstream")); + } + + // 3. Attack vector: Malicious stream dictionary containing + // '_IsMeasurementCopy' key + // with is_measurement = false should still have its /Length forced to 0. + { + IndirectObject indirect_obj = { + .object_number = 1, + .generation_number = 2, + .stream_dictionary = + Object::Dictionary{ + .entries = {{"Length", + Object{.value = Object::Integer{.value = 100}}}, + {"_IsMeasurementCopy", + Object{.value = Object::Boolean{.value = true}}}}}, + .object = {.value = Object::Stream{.raw_value = ""}}}; + + std::string output_s; + riegeli::StringWriter<> output(&output_s); + ABSL_ASSERT_OK( + WriteIndirectObject(indirect_obj, output, /*is_measurement=*/false)); + ASSERT_TRUE(output.Close()); + + EXPECT_THAT(output_s, HasSubstr("/Length 0")); + EXPECT_THAT(output_s, HasSubstr("/_IsMeasurementCopy true")); + } +} + +struct FailureTestCase { + Object obj; + std::string name; + + friend std::ostream& operator<<(std::ostream& os, const FailureTestCase& tc) { + return os << tc.name; + } +}; + +class WriteObjectFailureTest + : public ::testing::TestWithParam<FailureTestCase> {}; + +TEST_P(WriteObjectFailureTest, WriteFailsAndReturnsWriterStatus) { + const FailureTestCase& param = GetParam(); + absl::Status expected_status = absl::InternalError("Simulated write failure"); + FailingWriter writer(expected_status); + + absl::Status status = WriteObject(param.obj, writer); + + EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal, + HasSubstr("Simulated write failure"))); +} + +INSTANTIATE_TEST_SUITE_P( + WriteObjectFailureTests, WriteObjectFailureTest, + ::testing::Values( + FailureTestCase{.obj = Object{.value = Object::Boolean{.value = true}}, + .name = "Boolean"}, + FailureTestCase{.obj = Object{.value = Object::Integer{.value = 42}}, + .name = "Integer"}, + FailureTestCase{ + .obj = Object{.value = Object::RealNumber{.raw_value = "3.14"}}, + .name = "RealNumber"}, + FailureTestCase{ + .obj = Object{.value = Object::LiteralString{.raw_value = "test"}}, + .name = "LiteralString"}, + FailureTestCase{ + .obj = + Object{.value = Object::HexadecimalString{.raw_value = "AB"}}, + .name = "HexadecimalString"}, + FailureTestCase{.obj = Object{.value = Object::Name{.value = "test"}}, + .name = "Name"}, + FailureTestCase{.obj = Object{.value = Object::Array{.objects = {}}}, + .name = "Array"}, + FailureTestCase{ + .obj = Object{.value = Object::Dictionary{.entries = {}}}, + .name = "Dictionary"}, + FailureTestCase{.obj = Object{.value = Object::Null{}}, .name = "Null"}, + FailureTestCase{ + .obj = Object{.value = + Object::IndirectReference{ + .object_number = 1, .generation_number = 0}}, + .name = "IndirectReference"}), + [](const ::testing::TestParamInfo<WriteObjectFailureTest::ParamType>& + info) { return info.param.name; }); + +TEST(WriteIndirectObjectTest, WriteFailsAndReturnsWriterStatus) { + IndirectObject indirect_obj = { + .object_number = 1, + .generation_number = 2, + .object = {.value = Object::Name{.value = "Catalog"}}}; + absl::Status expected_status = + absl::InternalError("Simulated indirect write failure"); + FailingWriter writer(expected_status); + + absl::Status status = WriteIndirectObject(indirect_obj, writer); + + EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal, + HasSubstr("Simulated indirect write failure"))); +} + +TEST(WriteObjectTest, ArraySpaceWriteFailure) { + Object obj = {.value = Object::Array{ + .objects = {Object{.value = Object::Integer{.value = 1}}, + Object{.value = Object::Integer{.value = 2}}}}}; + StepFailingWriter writer(2, absl::InternalError("Simulated space failure")); + + absl::Status status = WriteObject(obj, writer); + + EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal, + HasSubstr("Simulated space failure"))); +} + +TEST(WriteObjectTest, ArrayCloseBracketWriteFailure) { + Object obj = {.value = Object::Array{ + .objects = {Object{.value = Object::Integer{.value = 1}}}}}; + StepFailingWriter writer( + 2, absl::InternalError("Simulated close bracket failure")); + + absl::Status status = WriteObject(obj, writer); + + EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal, + HasSubstr("Simulated close bracket failure"))); +} + +TEST(WriteObjectTest, DictionarySpaceWriteFailure) { + Object obj = { + .value = Object::Dictionary{ + .entries = {{"K", Object{.value = Object::Integer{.value = 1}}}}}}; + StepFailingWriter writer(4, absl::InternalError("Simulated space failure")); + + absl::Status status = WriteObject(obj, writer); + + EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal, + HasSubstr("Simulated space failure"))); +} + +TEST(WriteObjectTest, DictionaryCloseBracketsWriteFailure) { + Object obj = { + .value = Object::Dictionary{ + .entries = {{"K", Object{.value = Object::Integer{.value = 1}}}}}}; + StepFailingWriter writer( + 6, absl::InternalError("Simulated close brackets failure")); + + absl::Status status = WriteObject(obj, writer); + + EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal, + HasSubstr("Simulated close brackets failure"))); +} + +TEST(WriteIndirectObjectTest, WriteStreamObjectFailure) { + IndirectObject indirect_obj = { + .object_number = 1, + .generation_number = 2, + .stream_dictionary = + Object::Dictionary{ + .entries = {{"Length", + Object{.value = Object::Integer{.value = 10}}}}}, + .object = {.value = Object::Stream{.raw_value = "1234567890"}}}; + StepFailingWriter writer(23, absl::InternalError("Simulated stream failure")); + + absl::Status status = WriteIndirectObject(indirect_obj, writer); + + EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal, + HasSubstr("Simulated stream failure"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/pdf/reader.cc b/formats/pdf/reader.cc index dbe3973..6c635a0 100644 --- a/formats/pdf/reader.cc +++ b/formats/pdf/reader.cc
@@ -202,17 +202,18 @@ ABSL_ASSIGN_OR_RETURN(prev_xref_offset, GetPrevXrefTableOffset(prev_file_trailer_dict)); - xref_table.merge(prev_xref_table); - if (!prev_xref_table.empty()) { - return absl::InvalidArgumentError( - "Initialization failed: Collision detected in cross-reference " - "table for in-use entries."); + // Xref tables are traversed backwards (newest to oldest via /Prev), + // entries encountered earlier represent newer updates and override older + // ones. + for (const auto& [ref, offset] : prev_xref_table) { + if (!free_entries.contains(ref)) { + xref_table.try_emplace(ref, offset); + } } - free_entries.merge(prev_free_entries); - if (!prev_free_entries.empty()) { - return absl::InvalidArgumentError( - "Initialization failed: Collision detected in cross-reference " - "table for free entries."); + for (const auto& ref : prev_free_entries) { + if (!xref_table.contains(ref)) { + free_entries.insert(ref); + } } } auto reader = absl::WrapUnique(new PdfReader(input, std::move(xref_table),
diff --git a/formats/pdf/reader_test.cc b/formats/pdf/reader_test.cc index 75dbbb7..cd9cb71 100644 --- a/formats/pdf/reader_test.cc +++ b/formats/pdf/reader_test.cc
@@ -24,6 +24,7 @@ #include "absl/container/flat_hash_map.h" #include "absl/status/status.h" #include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" #include "absl/strings/str_replace.h" #include "absl/strings/string_view.h" #include "formats/pdf/objects.h" @@ -42,20 +43,6 @@ using ::testing::Pair; using ::testing::UnorderedElementsAre; -#ifndef ASSERT_OK_AND_ASSIGN -#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y -#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) - -#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ - ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) - -#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ - auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ - ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ - ::absl_testing::IsOk()); \ - lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) -#endif - Object CreateName(absl::string_view value) { return Object{.value = Object::Name{.value = std::string(value)}}; } @@ -115,12 +102,14 @@ TEST(PdfReaderTest, ReadsAllObjects) { std::string content = GetTestPdfContent(); riegeli::StringReader<> input(content); - ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); std::vector<IndirectObject> objects; - while (reader->HasNext()) { - ASSERT_OK_AND_ASSIGN(auto obj, reader->Next()); - objects.push_back(std::move(obj)); + while ((*reader)->HasNext()) { + absl::StatusOr<IndirectObject> obj = (*reader)->Next(); + ABSL_ASSERT_OK(obj); + objects.push_back(*std::move(obj)); } EXPECT_THAT( @@ -140,26 +129,29 @@ TEST(PdfReaderTest, FailsWhenReadingPastAllObjects) { std::string content = GetTestPdfContent(); riegeli::StringReader<> input(content); - ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); int64_t count = 0; - while (reader->HasNext()) { - ASSERT_OK_AND_ASSIGN(auto obj, reader->Next()); + while ((*reader)->HasNext()) { + ABSL_ASSERT_OK((*reader)->Next()); ++count; } EXPECT_EQ(count, 3); - EXPECT_THAT(reader->Next(), StatusIs(absl::StatusCode::kOutOfRange, - HasSubstr("No more objects to read"))); + EXPECT_THAT((*reader)->Next(), + StatusIs(absl::StatusCode::kOutOfRange, + HasSubstr("No more objects to read"))); } TEST(PdfReaderTest, GetFileTrailerDictionary) { std::string content = GetTestPdfContent(); riegeli::StringReader<> input(content); - ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); - auto file_trailer_dict = reader->file_trailer_dictionary(); + auto file_trailer_dict = (*reader)->file_trailer_dictionary(); EXPECT_THAT(file_trailer_dict.entries, UnorderedElementsAre(Pair("Root", CreateIndirectRef(3, 0)), Pair("Size", CreateInteger(3)))); @@ -184,8 +176,9 @@ %%EOF)"; content = absl::StrReplaceAll(content, {{"@", " "}}); riegeli::StringReader<> input(content); - ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); - EXPECT_THAT(reader->free_entries(), + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); + EXPECT_THAT((*reader)->free_entries(), ElementsAre(Object::IndirectReference{.object_number = 0, .generation_number = 65535}, Object::IndirectReference{.object_number = 3, @@ -197,41 +190,47 @@ TEST(PdfReaderTest, GetObjectOffset) { std::string content = GetTestPdfContent(); riegeli::StringReader<> input(content); - ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); EXPECT_THAT( - reader->GetObjectOffset(/*object_number=*/2, /*generation_number=*/0), + (*reader)->GetObjectOffset(/*object_number=*/2, /*generation_number=*/0), IsOkAndHolds(41)); } TEST(PdfReaderTest, GetNonExistentObjectOffsetFailure) { std::string content = GetTestPdfContent(); riegeli::StringReader<> input(content); - ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); EXPECT_THAT( - reader->GetObjectOffset(/*object_number=*/10, /*generation_number=*/0), + (*reader)->GetObjectOffset(/*object_number=*/10, /*generation_number=*/0), StatusIs(absl::StatusCode::kNotFound, HasSubstr("No offset found"))); } TEST(PdfReaderTest, GetSpecificObject) { std::string content = GetTestPdfContent(); riegeli::StringReader<> input(content); - ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); - ASSERT_OK_AND_ASSIGN(auto obj, reader->GetObject(2, 0)); - EXPECT_THAT(obj, (IndirectObject{.object_number = 2, - .generation_number = 0, - .object = CreateRealNumber("123.456")})); + EXPECT_THAT( + (*reader)->GetObject(2, 0), + IsOkAndHolds(IndirectObject{.object_number = 2, + .generation_number = 0, + .object = CreateRealNumber("123.456")})); } TEST(PdfReaderTest, GetNonExistentObjectFailure) { std::string content = GetTestPdfContent(); riegeli::StringReader<> input(content); - ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); - EXPECT_THAT(reader->GetObject(10, 0), StatusIs(absl::StatusCode::kNotFound, - HasSubstr("No offset found"))); + EXPECT_THAT( + (*reader)->GetObject(10, 0), + StatusIs(absl::StatusCode::kNotFound, HasSubstr("No offset found"))); } TEST(PdfReaderTest, ShortFileCausesInitializationFailure) { @@ -391,12 +390,14 @@ content = absl::StrReplaceAll(content, {{"@", " "}}); riegeli::StringReader<> input(content); - ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); std::vector<IndirectObject> objects; - while (reader->HasNext()) { - ASSERT_OK_AND_ASSIGN(auto obj, reader->Next()); - objects.push_back(std::move(obj)); + while ((*reader)->HasNext()) { + absl::StatusOr<IndirectObject> obj = (*reader)->Next(); + ABSL_ASSERT_OK(obj); + objects.push_back(*std::move(obj)); } ASSERT_THAT( @@ -566,7 +567,7 @@ HasSubstr("Circular cross-reference table detected"))); } -TEST(PdfReaderTest, CollisionInUseEntriesFailure) { +TEST(PdfReaderTest, IncrementalUpdateOverlappingXrefEntriesSuccess) { std::string content = R"(%PDF-1.7 1 0 obj << /Type /Catalog @@ -648,10 +649,80 @@ %%EOF)"; content = absl::StrReplaceAll(content, {{"@", " "}}); riegeli::StringReader<> input(content); - EXPECT_THAT(PdfReader::Create(&input), - StatusIs(absl::StatusCode::kInvalidArgument, - HasSubstr("Collision detected in cross-reference table " - "for in-use entries"))); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); + + // Object 1 was redefined in the second xref section, so it should have the + // newer offset. + EXPECT_THAT((*reader)->GetObjectOffset(1, 0), IsOkAndHolds(450)); + // Object 2 was only defined in the first xref section. + EXPECT_THAT((*reader)->GetObjectOffset(2, 0), IsOkAndHolds(58)); +} + +TEST(PdfReaderTest, IncrementalUpdateReDeclaresObject) { + // This test uses exact offsets generated by a custom script to ensure + // GetObject can actually read the objects. + std::string content = "%PDF-1.7\n%\xe2\xe3\xcf\xd3\n" + std::string(R"(1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >> +endobj +xref +0 4 +0000000000 65535 f@ +0000000015 00000 n@ +0000000064 00000 n@ +0000000121 00000 n@ +trailer +<< /Size 4 /Root 1 0 R >> +startxref +192 +%%EOF +1 0 obj +<< /Type /Catalog /Pages 2 0 R /Note (updated) >> +endobj +xref +1 1 +0000000335 00000 n@ +trailer +<< /Size 4 /Root 1 0 R /Prev 192 >> +startxref +400 +%%EOF)"); + + content = absl::StrReplaceAll(content, {{"@", " "}}); + riegeli::StringReader<> input(content); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); + + // Object 1 should be the updated one. + absl::StatusOr<IndirectObject> obj1 = (*reader)->GetObject(1, 0); + ABSL_ASSERT_OK(obj1); + + // Verify it is a dictionary and contains the Note key. + absl::StatusOr<Object::Dictionary> dict = + Object::As<Object::Dictionary>(obj1->object); + ABSL_ASSERT_OK(dict); + EXPECT_TRUE(dict->entries.contains("Note")); + + // Verify the value of Note. + auto note_val = dict->entries.at("Note"); + absl::StatusOr<Object::LiteralString> note_str = + Object::As<Object::LiteralString>(note_val); + ABSL_ASSERT_OK(note_str); + EXPECT_EQ(note_str->raw_value, "updated"); + + // Object 3 should be the original one from base. + absl::StatusOr<IndirectObject> obj3 = (*reader)->GetObject(3, 0); + ABSL_ASSERT_OK(obj3); + absl::StatusOr<Object::Dictionary> dict3 = + Object::As<Object::Dictionary>(obj3->object); + ABSL_ASSERT_OK(dict3); + EXPECT_TRUE(dict3->entries.contains("MediaBox")); } TEST(PdfReaderTest, CyclicStreamLengthFailure) { @@ -674,8 +745,9 @@ %%EOF)"; content = absl::StrReplaceAll(content, {{"@", " "}}); riegeli::StringReader<> input(content); - ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); - EXPECT_THAT(reader->GetObject(1, 0), + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); + EXPECT_THAT((*reader)->GetObject(1, 0), StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("Circular reference detected"))); }
diff --git a/formats/pdf/writer.cc b/formats/pdf/writer.cc new file mode 100644 index 0000000..8797136 --- /dev/null +++ b/formats/pdf/writer.cc
@@ -0,0 +1,122 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/pdf/writer.h" + +#include <cstdint> +#include <string> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "formats/hasher_data.h" +#include "formats/pdf/embedder.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/cord_reader.h" +#include "riegeli/bytes/cord_writer.h" + +namespace credentio { +namespace { + +absl::StatusOr<int64_t> RewriteToCordForEmbedding( + riegeli::Reader& input, int64_t estimated_manifest_store_size, + absl::Cord* result) { + if (!input.Seek(0)) { + return input.StatusOrAnnotate( + absl::DataLossError("failed to seek to start of input")); + } + riegeli::CordWriter output(result); + PdfEmbedder embedder; + ABSL_ASSIGN_OR_RETURN(int64_t manifest_store_offset, + embedder.RewriteForEmbedding( + input, estimated_manifest_store_size, output)); + if (!output.Close()) { + return output.status(); + } + return manifest_store_offset; +} +} // namespace + +absl::Status PdfWriter::Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination) { + if (request.has_hard_binding()) { + return absl::InvalidArgumentError( + "request must not include `hard_binding` (binding is computed by " + "PdfWriter)"); + } + + // The two-phase approach is required for PDF: The sections to be hashed can't + // be finalized until the manifest store size is known, because the manifest + // size affects the offsets of objects in the cross-reference table. + + // Phase 1: Rewrite the asset to enable embedding, compute preliminary hard + // binding, produce a manifest store template, and embed a zero-filled + // placeholder in the asset. + HashAlgorithm alg = HashAlgorithm::kSha256; + ABSL_ASSIGN_OR_RETURN(*request.mutable_hard_binding()->mutable_data_hash(), + DataHashTemplate(alg)); + ABSL_ASSIGN_OR_RETURN(ManifestStoreTemplate manifest_store_template, + generator_.GenerateManifestStoreTemplate(request)); + constexpr int64_t kMaxManifestStoreSizeBytes = 100 * 1024 * 1024; // 100 MiB + if (manifest_store_template.manifest_store_size() <= 0 || + manifest_store_template.manifest_store_size() > + kMaxManifestStoreSizeBytes) { + return absl::InvalidArgumentError( + absl::StrCat("invalid manifest store template size: ", + manifest_store_template.manifest_store_size())); + } + absl::Cord phase1_result; + ABSL_ASSIGN_OR_RETURN( + int64_t manifest_store_offset, + RewriteToCordForEmbedding(*source, + /*estimated_manifest_store_size=*/ + manifest_store_template.manifest_store_size(), + &phase1_result), + _ << "embedding manifest store template"); + + // Phase 2: Compute final hard binding based on the updated content, finalize + // the manifest store, and embed the final manifest store in the asset. + riegeli::CordReader phase2_reader(&phase1_result); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + std::move(manifest_store_template); + ABSL_ASSIGN_OR_RETURN( + *finalize_request.mutable_hard_binding()->mutable_data_hash(), + DigestDataHash( + phase2_reader, HashAlgorithm::kSha256, + /*manifest_store_offset=*/manifest_store_offset, + /*manifest_store_length=*/ + finalize_request.manifest_store_template().manifest_store_size())); + ABSL_ASSIGN_OR_RETURN(auto response, + generator_.FinalizeManifestStore(finalize_request)); + if (response.manifest_store().size() != + finalize_request.manifest_store_template().manifest_store_size()) { + return absl::InternalError("manifest store size does not match template"); + } + ABSL_RETURN_IF_ERROR(PdfEmbedder().Embed( + response.manifest_store(), manifest_store_offset, phase1_result)); + if (!destination->Write(phase1_result)) { + return absl::InternalError("failed to write result"); + } + return absl::OkStatus(); +} +} // namespace credentio
diff --git a/formats/pdf/writer.h b/formats/pdf/writer.h new file mode 100644 index 0000000..74b9ab0 --- /dev/null +++ b/formats/pdf/writer.h
@@ -0,0 +1,52 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PDF_WRITER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_WRITER_H_ + +#include "absl/base/nullability.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "generator/generator.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// Writer of C2PA manifest stores into PDF assets. +class PdfWriter { + public: + // Creates a PDF writer. + // + // The generator must outlive the writer. + explicit PdfWriter(const ManifestStoreGenerator* absl_nonnull generator) + : generator_(*ABSL_DIE_IF_NULL(generator)) {} + + // Generates a C2PA manifest store and embeds it into a copy of the given + // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the + // same underlying data. The request must not include `hard_binding`. + + // Fails if C2PA metadata already exists. + absl::Status Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination); + + private: + const ManifestStoreGenerator& generator_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PDF_WRITER_H_
diff --git a/formats/pdf/writer_test.cc b/formats/pdf/writer_test.cc new file mode 100644 index 0000000..9656960 --- /dev/null +++ b/formats/pdf/writer_test.cc
@@ -0,0 +1,197 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/pdf/writer.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <utility> + +#include "absl/log/absl_check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "formats/byte_range.h" +#include "formats/pdf/extractor.h" +#include "generator/generator.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/generator.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_status.pb.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "testing/fake_manifest_store_generator.h" +#include "testing/nonprod_generator.h" +#include "testing/nonprod_validator.h" +#include "validator/asset_validator.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::AllOf; +using ::testing::Field; +using ::testing::Gt; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::Optional; + +constexpr absl::string_view kTestPdfContent = R"(%PDF-1.7 +%%%%% +1 0 obj +/test_name +endobj +2 0 obj +123.456 +endobj +3 0 obj +<</Type /Catalog>> +endobj +xref +0 4 +0000000000 65535 f +0000000015 00000 n +0000000041 00000 n +0000000064 00000 n +trailer +<</Root 3 0 R /Size 4>> +startxref +98 +%%EOF +)"; + +class PdfWriterTest : public ::testing::Test { + protected: + void SetUp() override { + auto generator = CreateNonProdManifestStoreGenerator({}); + ABSL_CHECK_OK(generator); + generator_ = std::move(*generator); + writer_ = std::make_unique<PdfWriter>(generator_.get()); + auto validator = CreateNonProdValidator({}); + ABSL_CHECK_OK(validator); + validator_ = std::move(*validator); + } + std::unique_ptr<ManifestStoreGenerator> generator_; + std::unique_ptr<PdfWriter> writer_; + std::unique_ptr<AssetValidator> validator_; +}; + +TEST_F(PdfWriterTest, Write) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + riegeli::StringReader source(kTestPdfContent); + ABSL_ASSERT_OK(source.status()); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + ABSL_ASSERT_OK(writer_->Write(request, &source, &destination)); + ASSERT_TRUE(source.Close()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader result_reader(result); + ABSL_ASSERT_OK(result_reader.status()); + + EXPECT_THAT(PdfExtractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)), + Field(&ByteRange::length, Gt(0)))))); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validation_result = + validator_->Validate(result_reader, "application/pdf"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST_F(PdfWriterTest, WriteTwiceFails) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + + riegeli::StringReader source1(kTestPdfContent); + ABSL_ASSERT_OK(source1.status()); + std::string result1; + riegeli::StringWriter destination1(&result1); + ABSL_ASSERT_OK(destination1.status()); + ABSL_ASSERT_OK(writer_->Write(request, &source1, &destination1)); + ASSERT_TRUE(source1.Close()); + ASSERT_TRUE(destination1.Close()); + + riegeli::StringReader source2(result1); + ABSL_ASSERT_OK(source2.status()); + std::string result2; + riegeli::StringWriter destination2(&result2); + ABSL_ASSERT_OK(destination2.status()); + EXPECT_THAT( + writer_->Write(request, &source2, &destination2), + StatusIs(absl::StatusCode::kAlreadyExists, HasSubstr("already exists"))); +} + +TEST_F(PdfWriterTest, WriteWithHardBindingFails) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + request.mutable_hard_binding(); + + riegeli::StringReader source(kTestPdfContent); + ABSL_ASSERT_OK(source.status()); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + EXPECT_THAT(writer_->Write(request, &source, &destination), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("must not include `hard_binding`"))); +} + +class PdfWriterInvalidSizeTest : public ::testing::TestWithParam<int64_t> {}; + +TEST_P(PdfWriterInvalidSizeTest, RejectsInvalidManifestStoreSize) { + int64_t invalid_size = GetParam(); + FakeManifestStoreGenerator generator; + generator.mutable_manifest_store_template()->set_manifest_store_size( + invalid_size); + PdfWriter writer(&generator); + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + riegeli::StringReader source(kTestPdfContent); + ABSL_ASSERT_OK(source.status()); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + + EXPECT_THAT(writer.Write(request, &source, &destination), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("invalid manifest store template size:"))); +} + +INSTANTIATE_TEST_SUITE_P(PdfWriterInvalidSizeTests, PdfWriterInvalidSizeTest, + ::testing::Values(-5, 0, 100LL * 1024 * 1024 + 1)); + +} // namespace +} // namespace credentio
diff --git a/formats/png/BUILD b/formats/png/BUILD index 493902a..0fa09aa 100644 --- a/formats/png/BUILD +++ b/formats/png/BUILD
@@ -175,3 +175,126 @@ "@abseil-cpp//absl/status:statusor", ], ) + +cc_library( + name = "hasher", + srcs = ["hasher.cc"], + hdrs = ["hasher.h"], + deps = [ + ":constants", + ":reader", + "//bindings:input_hasher", + "//crypto:algorithms", + "//formats:byte_range", + "//proto:data_hash_assertion_cc_proto", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "embedder", + srcs = ["embedder.cc"], + hdrs = ["embedder.h"], + deps = [ + ":constants", + ":crc", + ":hasher", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:copy_all", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "embedder_test", + srcs = ["embedder_test.cc"], + deps = [ + ":embedder", + ":extractor", + ":hasher", + ":test_utils", + "//formats:asset_byte_info", + "//formats:byte_range", + "//formats:extractor_result", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +) + +cc_library( + name = "writer", + srcs = ["writer.cc"], + hdrs = ["writer.h"], + deps = [ + ":constants", + ":embedder", + ":hasher", + ":reader", + "//async:async_context", + "//crypto:algorithms", + "//formats:byte_range", + "//generator", + "//proto:generator_cc_proto", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_test( + name = "writer_test", + srcs = ["writer_test.cc"], + deps = [ + ":extractor", + ":reader", + ":test_utils", + ":writer", + "//async:async_context", + "//formats:byte_range", + "//generator", + "//proto:generator_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:ingredient_validation_result_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:nonprod_generator", + "//testing:nonprod_validator", + "//validator:asset_validator", + "//validator:ingredient_result", + "//validator:ingredient_result_converter", + "//validator:result", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +)
diff --git a/formats/png/embedder.cc b/formats/png/embedder.cc new file mode 100644 index 0000000..bdb7ff0 --- /dev/null +++ b/formats/png/embedder.cc
@@ -0,0 +1,85 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/png/embedder.h" + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/strings/string_view.h" +#include "formats/png/constants.h" +#include "formats/png/crc.h" +#include "formats/png/hasher.h" +#include "riegeli/bytes/copy_all.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { +namespace { + +// Returns the offset of the data. +absl::Status WriteChunk(absl::string_view type, absl::string_view data, + riegeli::Writer& writer) { + if (!riegeli::WriteBigEndian<uint32_t>(data.size(), writer)) { + return writer.StatusOrAnnotate( + absl::InternalError("Failed to write chunk size")); + } + if (!writer.Write(type)) { + return writer.StatusOrAnnotate( + absl::InternalError("Failed to write chunk type")); + } + if (!data.empty() && !writer.Write(data)) { + return writer.StatusOrAnnotate( + absl::InternalError("Failed to write chunk data")); + } + uint32_t crc = PngChunkCrc(type, data); + if (!riegeli::WriteBigEndian<uint32_t>(crc, writer)) { + return writer.StatusOrAnnotate( + absl::InternalError("Failed to write chunk CRC")); + } + return absl::OkStatus(); +} + +} // namespace + +absl::Status PngEmbedder::CopyAndEmbed(riegeli::Reader& reader, + absl::string_view manifest_store, + const DigestPngResult& digest_result, + riegeli::Writer& writer) const { + if (!reader.Seek(0) || reader.pos() != 0) { + return reader.StatusOrAnnotate( + absl::InternalError("failed to seek to beginning of reader")); + } + + // Write everything up to the insertion offset. + if (!reader.Copy(digest_result.insertion_offset, writer)) { + return reader.StatusOrAnnotate( + absl::InternalError("Failed to copy data up to insertion offset")); + } + + // Write the manifest store. + ABSL_RETURN_IF_ERROR(WriteChunk(kPngChunkTypeC2pa, manifest_store, writer)); + if (!reader.Skip(digest_result.existing_c2pa_length.value_or(0))) { + return reader.StatusOrAnnotate( + absl::InternalError("Failed to skip existing C2PA chunk")); + } + + // Write the rest of the file. + return riegeli::CopyAll(reader, writer); +} + +} // namespace credentio
diff --git a/formats/png/embedder.h b/formats/png/embedder.h new file mode 100644 index 0000000..a6cd6d9 --- /dev/null +++ b/formats/png/embedder.h
@@ -0,0 +1,51 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PNG_EMBEDDER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_EMBEDDER_H_ + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "formats/png/hasher.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// A utility class for embedding a C2PA manifest store as a `caBX` chunk in a +// non-C2PA PNG file. +class PngEmbedder { + public: + PngEmbedder() = default; + + // Copies the PNG read from `reader` to `writer`, inserting `manifest_store` + // as a `caBX` chunk at `digest_result.insertion_offset`, the offset + // `DigestPng()` reserved for it: the start of the first `IDAT` chunk, or of + // an already existing `caBX` chunk. + // + // An existing `caBX` chunk, whose size `digest_result` reports as + // `existing_c2pa_length`, is replaced by the new one. + // + // NOTE: Input and output must be disjoint, i.e., in-place embedding is not + // supported. + absl::Status CopyAndEmbed(riegeli::Reader& reader, + absl::string_view manifest_store, + const DigestPngResult& digest_result, + riegeli::Writer& writer) const; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PNG_EMBEDDER_H_
diff --git a/formats/png/embedder_test.cc b/formats/png/embedder_test.cc new file mode 100644 index 0000000..f57d6b1 --- /dev/null +++ b/formats/png/embedder_test.cc
@@ -0,0 +1,233 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/png/embedder.h" + +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_byte_info.h" +#include "formats/byte_range.h" +#include "formats/extractor_result.h" +#include "formats/png/extractor.h" +#include "formats/png/hasher.h" +#include "formats/png/test_utils.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::ElementsAre; +using ::testing::Eq; +using ::testing::HasSubstr; +using ::testing::SizeIs; + +constexpr int64_t kChunkHeaderSize = 8; + +absl::StatusOr<ExtractorResult> Extract(absl::string_view png) { + riegeli::StringReader input(png); + PngExtractor extractor; + ExtractorResult result = {.manifest_store = "", .asset_byte_info = {}}; + + ABSL_ASSIGN_OR_RETURN(result.manifest_store, + extractor.ExtractManifestStore(input)); + + if (!input.Seek(0)) { + return input.StatusOrAnnotate(absl::DataLossError("Failure to seek")); + } + ABSL_ASSIGN_OR_RETURN(std::optional<ByteRange> manifest_store_location, + extractor.ExtractManifestStoreLocation(input, {})); + result.asset_byte_info.manifest_store_location = + manifest_store_location.value_or(ByteRange{.offset = 0, .length = 0}); + + if (!input.Seek(0)) { + return input.StatusOrAnnotate(absl::DataLossError("Failure to seek")); + } + ABSL_ASSIGN_OR_RETURN(result.asset_byte_info.boxes, + extractor.ExtractBoxes(input, {})); + return result; +} + +std::vector<std::string> GetBoxNames(const ExtractorResult& result) { + std::vector<std::string> box_names; + for (const auto& box : result.asset_byte_info.boxes) { + box_names.push_back(box.identifier); + } + return box_names; +} + +TEST(PngEmbedderTest, CopyAndEmbedBeforeIdat) { + absl::StatusOr<std::string> input_s = + CreatePng({{.type = 'ABCD', .payload = "first chunk"}, + {.type = 'EFGH', .payload = "second chunk"}, + {.type = 'IDAT', .payload = "third chunk"}, + {.type = 'XYZW', .payload = "fourth chunk"}}); + ABSL_ASSERT_OK(input_s); + constexpr int64_t kInsertionOffset = 55; + constexpr absl::string_view kManifestStore = "C2PA manifest store bytes"; + riegeli::StringReader input(*input_s); + std::string output_s; + riegeli::StringWriter output(&output_s); + + PngEmbedder embedder; + ABSL_ASSERT_OK(embedder.CopyAndEmbed( + input, kManifestStore, DigestPngResult{.insertion_offset = 55}, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_THAT(output_s.substr(kInsertionOffset + kChunkHeaderSize, + kManifestStore.size()), + Eq(kManifestStore)); + EXPECT_THAT(output_s, SizeIs(input_s->size() + 12 + kManifestStore.size())); + absl::StatusOr<ExtractorResult> extracted = Extract(output_s); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(extracted->manifest_store, Eq(kManifestStore)); + EXPECT_THAT( + GetBoxNames(*extracted), + ElementsAre("PNGh", "ABCD", "EFGH", "C2PA", "IDAT", "XYZW", "IEND")); +} + +TEST(PngEmbedderTest, SeeksToBeginningBeforeCopying) { + absl::StatusOr<std::string> input_s = + CreatePng({{.type = 'ABCD', .payload = "first chunk"}, + {.type = 'EFGH', .payload = "second chunk"}, + {.type = 'IDAT', .payload = "third chunk"}, + {.type = 'XYZW', .payload = "fourth chunk"}}); + ABSL_ASSERT_OK(input_s); + constexpr int64_t kInsertionOffset = 55; + constexpr absl::string_view kManifestStore = "C2PA manifest store bytes"; + riegeli::StringReader input(*input_s); + std::string output_s; + riegeli::StringWriter output(&output_s); + + // Seek to a non-zero offset. + ASSERT_TRUE(input.Seek(10)); + + PngEmbedder embedder; + ABSL_ASSERT_OK(embedder.CopyAndEmbed( + input, kManifestStore, DigestPngResult{.insertion_offset = 55}, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_THAT(output_s.substr(kInsertionOffset + kChunkHeaderSize, + kManifestStore.size()), + Eq(kManifestStore)); + EXPECT_THAT(output_s, SizeIs(input_s->size() + 12 + kManifestStore.size())); + absl::StatusOr<ExtractorResult> extracted = Extract(output_s); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(extracted->manifest_store, Eq(kManifestStore)); + EXPECT_THAT( + GetBoxNames(*extracted), + ElementsAre("PNGh", "ABCD", "EFGH", "C2PA", "IDAT", "XYZW", "IEND")); +} + +TEST(PngEmbedderTest, CopyAndEmbedBeforeIend) { + absl::StatusOr<std::string> input_s = + CreatePng({{.type = 'ABCD', .payload = "first chunk"}, + {.type = 'EFGH', .payload = "second chunk"}, + {.type = 'XYZW', .payload = "third chunk"}}); + ABSL_ASSERT_OK(input_s); + constexpr int64_t kInsertionOffset = 78; + constexpr absl::string_view kManifestStore = "C2PA manifest store bytes"; + riegeli::StringReader input(*input_s); + std::string output_s; + riegeli::StringWriter output(&output_s); + + PngEmbedder embedder; + ABSL_ASSERT_OK(embedder.CopyAndEmbed( + input, kManifestStore, + DigestPngResult{.insertion_offset = kInsertionOffset}, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_THAT(output_s.substr(kInsertionOffset + kChunkHeaderSize, + kManifestStore.size()), + Eq(kManifestStore)); + EXPECT_THAT(output_s, SizeIs(input_s->size() + 12 + kManifestStore.size())); + absl::StatusOr<ExtractorResult> extracted = Extract(output_s); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(extracted->manifest_store, Eq(kManifestStore)); + EXPECT_THAT(GetBoxNames(*extracted), + ElementsAre("PNGh", "ABCD", "EFGH", "XYZW", "C2PA", "IEND")); +} + +TEST(PngEmbedderTest, CopyAndEmbedC2paReplaceBeforeIdat) { + absl::StatusOr<std::string> input_s = + CreatePng({{.type = 'ABCD', .payload = "first chunk"}, + {.type = 'caBX', .payload = "C2PA chunk"}, + {.type = 'IDAT', .payload = "some data"}}); + ABSL_ASSERT_OK(input_s); + constexpr int64_t kInsertionOffset = 31; + constexpr int64_t kExistingC2paLength = 22; + constexpr absl::string_view kManifestStore = "C2PA manifest store bytes"; + riegeli::StringReader input(*input_s); + std::string output_s; + riegeli::StringWriter output(&output_s); + PngEmbedder embedder; + ABSL_ASSERT_OK(embedder.CopyAndEmbed( + input, kManifestStore, + DigestPngResult{.insertion_offset = kInsertionOffset, + .existing_c2pa_length = kExistingC2paLength}, + output)); + ASSERT_TRUE(output.Close()); + + EXPECT_THAT(output_s.substr(kInsertionOffset + kChunkHeaderSize, + kManifestStore.size()), + Eq(kManifestStore)); + absl::StatusOr<ExtractorResult> extracted = Extract(output_s); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(extracted->manifest_store, Eq(kManifestStore)); + EXPECT_THAT(GetBoxNames(*extracted), + ElementsAre("PNGh", "ABCD", "C2PA", "IDAT", "IEND")); +} + +TEST(PngEmbedderTest, CopyAndEmbedC2paAlreadyExistsAfterIdat_Replace) { + // Note that the digest operation would fail because there is a C2PA chunk + // after the IDAT so this scenario would never actually occur. + absl::StatusOr<std::string> input_s = CreatePng({ + {.type = 'ABCD', .payload = "first chunk"}, + {.type = 'IDAT', .payload = "some data"}, + {.type = 'caBX', .payload = "C2PA chunk"}, + }); + ABSL_ASSERT_OK(input_s); + constexpr int64_t kInsertionOffset = 31; + constexpr absl::string_view kManifestStore = "C2PA manifest store bytes"; + riegeli::StringReader input(*input_s); + std::string output_s; + riegeli::StringWriter output(&output_s); + PngEmbedder embedder; + ABSL_ASSERT_OK(embedder.CopyAndEmbed( + input, kManifestStore, + DigestPngResult{.insertion_offset = kInsertionOffset}, output)); + ASSERT_TRUE(output.Close()); + + EXPECT_THAT(output_s.substr(kInsertionOffset + kChunkHeaderSize, + kManifestStore.size()), + Eq(kManifestStore)); + EXPECT_THAT(Extract(output_s), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("Multiple manifest stores found"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/png/extractor_test.cc b/formats/png/extractor_test.cc index faff925..323701c 100644 --- a/formats/png/extractor_test.cc +++ b/formats/png/extractor_test.cc
@@ -387,7 +387,7 @@ {.type = 'IDAT', .payload = "ignored IDAT chunk"}, {.type = 'caBX', .payload = "A C2PA manifest store", .crc = 0xdb50c4b5}, }); - ASSERT_THAT(png, ::absl_testing::IsOk()); + ABSL_ASSERT_OK(png); riegeli::StringReader<> input(*png); // Set end_offset to 38 (right before caBX).
diff --git a/formats/png/hasher.cc b/formats/png/hasher.cc new file mode 100644 index 0000000..d1d3482 --- /dev/null +++ b/formats/png/hasher.cc
@@ -0,0 +1,145 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/png/hasher.h" + +#include <algorithm> +#include <cstddef> +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "bindings/input_hasher.h" +#include "crypto/algorithms.h" +#include "formats/byte_range.h" +#include "formats/png/constants.h" +#include "formats/png/reader.h" +#include "proto/data_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +namespace { + +constexpr uint64_t kMaxChunkBytes = 1024 * 1024; // 1 MiB + +} // namespace + +absl::StatusOr<DigestPngResult> DigestPng( + riegeli::Reader& input, HashAlgorithm alg, + std::optional<PngExclusionProcessor> exclusion_processor) { + DigestPngResult result; + result.assertion.set_alg(absl::StrCat(alg)); + + std::unique_ptr<InputHasher> hasher; + ABSL_ASSIGN_OR_RETURN(hasher, InputHasher::Create(alg)); + + std::optional<int64_t> insertion_offset = std::nullopt; + + ABSL_RETURN_IF_ERROR(IterateOverPngChunks( + input, + [&result, &input, &hasher, &insertion_offset, + &exclusion_processor](const PngChunk& chunk) -> absl::Status { + if (chunk.type == kPngChunkTypeC2pa) { + if (insertion_offset.has_value()) { + // Multiple C2PA chunks, or C2PA chunk after IDAT chunk. + return absl::InvalidArgumentError("Unexpected C2PA chunk found"); + } + // Since there is an existing C2PA chunk, overwrite it with the new + // one. + insertion_offset = chunk.offset; + result.existing_c2pa_length = chunk.length; + DataRange* exclusion = result.assertion.add_exclusions(); + exclusion->set_start(chunk.offset); + exclusion->set_length(0); + return absl::OkStatus(); + } + + if (!insertion_offset.has_value() && chunk.type == kPngChunkTypeIdat) { + // Insert the manifest store immediately before the first IDAT chunk + // unless there is an existing C2PA chunk. + insertion_offset = chunk.offset; + DataRange* exclusion = result.assertion.add_exclusions(); + exclusion->set_start(chunk.offset); + exclusion->set_length(0); + } + + if (exclusion_processor.has_value()) { + ABSL_ASSIGN_OR_RETURN(std::vector<ByteRange> exclusions, + (*exclusion_processor)(input, chunk)); + if (!exclusions.empty()) { + int64_t next_idx_to_hash = chunk.offset; + for (const auto& ex : exclusions) { + if (ex.offset < next_idx_to_hash || + ex.offset + ex.length > chunk.offset + chunk.length) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid or out-of-order exclusion range [", + ex.offset, ", ", ex.offset + ex.length, + ") for chunk at ", chunk.offset)); + } + + if (next_idx_to_hash < ex.offset) { + ABSL_RETURN_IF_ERROR(hasher->Update( + input, next_idx_to_hash, ex.offset - next_idx_to_hash)); + } + + DataRange* exclusion = result.assertion.add_exclusions(); + exclusion->set_start(ex.offset); + exclusion->set_length(ex.length); + + next_idx_to_hash = ex.offset + ex.length; + } + // Hash remaining bytes (includes CRC) + if (next_idx_to_hash < (chunk.offset + chunk.length)) { + ABSL_RETURN_IF_ERROR(hasher->Update( + input, next_idx_to_hash, + chunk.length - (next_idx_to_hash - chunk.offset))); + } + return absl::OkStatus(); + } + } + + if (!input.Seek(chunk.offset)) { + return absl::InternalError("failed to seek to chunk offset"); + } + uint64_t remaining_bytes = chunk.length; + while (remaining_bytes > 0) { + size_t chunk_size = std::min(remaining_bytes, kMaxChunkBytes); + std::string chunk_data; + if (!input.Read(chunk_size, chunk_data)) { + return absl::DataLossError("failed to read box data"); + } + hasher->Update(chunk_data); + remaining_bytes -= chunk_size; + } + return absl::OkStatus(); + })); + + if (!insertion_offset.has_value()) { + return absl::InvalidArgumentError("No IDAT or C2PA chunk found"); + } + result.insertion_offset = *insertion_offset; + // Complete the entry for the post-C2PA boxes. + result.assertion.set_hash(hasher->Digest()); + return result; +} + +} // namespace credentio
diff --git a/formats/png/hasher.h b/formats/png/hasher.h new file mode 100644 index 0000000..275e6b9 --- /dev/null +++ b/formats/png/hasher.h
@@ -0,0 +1,58 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PNG_HASHER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_HASHER_H_ + +#include <cstdint> +#include <optional> +#include <vector> + +#include "absl/functional/function_ref.h" +#include "absl/status/statusor.h" +#include "crypto/algorithms.h" +#include "formats/byte_range.h" +#include "formats/png/reader.h" +#include "proto/data_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +struct DigestPngResult { + DataHashAssertion assertion; + int64_t insertion_offset; + std::optional<int64_t> existing_c2pa_length; +}; + +// Determines byte ranges within a given PNG chunk to be excluded from hashing. +// Returned ByteRange offsets must be absolute file offsets within +// [chunk.offset, chunk.offset + chunk.length). +using PngExclusionProcessor = + absl::FunctionRef<absl::StatusOr<std::vector<ByteRange>>( + riegeli::Reader& reader, const PngChunk& chunk)>; + +// Digests the given PNG image and returns DigestPngResult that can be used to +// generate a C2PA manifest store. It is assumed that a C2PA manifest store will +// be inserted immediately before the first IDAT chunk or already appears +// somewhere before the first IDAT chunk. +// +// If `exclusion_processor` is provided, it is invoked for chunks to identify +// byte ranges to exclude from hashing. +absl::StatusOr<DigestPngResult> DigestPng( + riegeli::Reader& input, HashAlgorithm alg, + std::optional<PngExclusionProcessor> exclusion_processor = std::nullopt); +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PNG_HASHER_H_
diff --git a/formats/png/writer.cc b/formats/png/writer.cc new file mode 100644 index 0000000..ce513f6 --- /dev/null +++ b/formats/png/writer.cc
@@ -0,0 +1,181 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/png/writer.h" + +#include <cstdint> +#include <optional> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "async/async_context.h" +#include "crypto/algorithms.h" +#include "formats/png/constants.h" +#include "formats/png/embedder.h" +#include "formats/png/hasher.h" +#include "formats/png/reader.h" +#include "generator/generator.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +PngWriter::PngWriter(const ManifestStoreGenerator* absl_nonnull generator, + Options options) + : generator_(*ABSL_DIE_IF_NULL(generator)), options_(std::move(options)) {} + +absl::Status PngWriter::Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata) { + absl::Notification done; + absl::Status status; + Write(std::move(request), source, destination, output_metadata, + AsyncContext(), [&](absl::Status s) { + status = std::move(s); + done.Notify(); + }); + done.WaitForNotification(); + return status; +} + +void PngWriter::Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata, + AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb) { + AsyncResolver<void> resolver(context, std::move(cb)); + if (request.has_hard_binding()) { + resolver.Return(absl::InvalidArgumentError( + "request must not include `hard_binding` (binding is computed by " + "PngWriter)")); + return; + } + + // DigestPng() takes an optional processor over (reader, chunk), so bind the + // request, which stays alive for the duration of the digest below. + auto exclusion_fn = [this, &request](riegeli::Reader& reader, + const PngChunk& chunk) { + return options_.exclusion_processor(reader, chunk, request); + }; + std::optional<PngExclusionProcessor> exclusion_processor = std::nullopt; + if (options_.exclusion_processor != nullptr) { + exclusion_processor.emplace(std::move(exclusion_fn)); + } + // Hashes the source and reports where the manifest store chunk goes. The + // hash covers the asset as it will look once that chunk is in place: the + // chunk itself is recorded as a zero-length exclusion for now, and is sized + // below once the manifest store template tells us how big it will be. + auto digest_result = + DigestPng(*source, HashAlgorithm::kSha256, exclusion_processor); + if (!digest_result.ok()) { + resolver.Return(digest_result.status()); + return; + } + + if (digest_result->existing_c2pa_length.has_value() && + (!request.has_actions() || !request.actions().has_opened())) { + resolver.Return(absl::AlreadyExistsError( + "C2PA chunk already exists, but no `opened` action was provided")); + return; + } + + *request.mutable_hard_binding()->mutable_data_hash() = + digest_result->assertion; + + // The manifest store covers its own size, so it is generated in two phases: + // a template reserves the space, then the exclusions are fixed up to account + // for it, and only then is the manifest store finalized and embedded. + generator_.GenerateManifestStoreTemplate( + request, context, + [this, source, request, digest_result = std::move(*digest_result), + destination, output_metadata, context, resolver = std::move(resolver)]( + absl::StatusOr<ManifestStoreTemplate> template_response) mutable { + if (!template_response.ok()) { + resolver.Return(template_response.status()); + return; + } + + constexpr int64_t kMaxManifestStoreSizeBytes = + 100 * 1024 * 1024; // 100 MiB + if (template_response->manifest_store_size() <= 0 || + template_response->manifest_store_size() > + kMaxManifestStoreSizeBytes) { + resolver.Return(absl::InvalidArgumentError( + absl::StrCat("invalid manifest store template size: ", + template_response->manifest_store_size()))); + return; + } + + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + std::move(*template_response); + *finalize_request.mutable_hard_binding()->mutable_data_hash() = + request.hard_binding().data_hash(); + + // Size of the whole `caBX` chunk record that will be inserted. + int64_t embedded_size = + finalize_request.manifest_store_template().manifest_store_size() + + kPngChunkLengthSize + kPngChunkTypeC2pa.size() + kPngCrcSize; + // The exclusions were recorded against the source asset, but they must + // describe the output asset. Inserting the chunk shifts everything + // after the insertion point by the size it adds (the size of the new + // chunk, less any existing `caBX` chunk it replaces), and the + // placeholder exclusion at the insertion point becomes the chunk. + for (auto& exclusion : *finalize_request.mutable_hard_binding() + ->mutable_data_hash() + ->mutable_exclusions()) { + if (exclusion.start() > digest_result.insertion_offset) { + exclusion.set_start(exclusion.start() + embedded_size - + digest_result.existing_c2pa_length.value_or(0)); + } else if (exclusion.start() == digest_result.insertion_offset) { + exclusion.set_length(embedded_size); + } + } + + generator_.FinalizeManifestStore( + finalize_request, context, + [source, digest_result = std::move(digest_result), destination, + output_metadata, resolver = std::move(resolver)]( + absl::StatusOr<FinalizeManifestStoreResponse> + finalize_response) mutable { + if (!finalize_response.ok()) { + resolver.Return(finalize_response.status()); + return; + } + + if (output_metadata != nullptr) { + output_metadata->timestamp_included = + finalize_response->is_timestamped(); + } + + PngEmbedder embedder; + auto status = embedder.CopyAndEmbed( + *source, finalize_response->manifest_store(), digest_result, + *destination); + resolver.Return(status); + }); + }); +} + +} // namespace credentio
diff --git a/formats/png/writer.h b/formats/png/writer.h new file mode 100644 index 0000000..d9be3d1 --- /dev/null +++ b/formats/png/writer.h
@@ -0,0 +1,125 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PNG_WRITER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_WRITER_H_ + +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "async/async_context.h" +#include "formats/byte_range.h" +#include "formats/png/reader.h" +#include "generator/generator.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// Writes C2PA manifest stores into PNG images. +// +// The manifest store is embedded as a `caBX` chunk immediately before the +// first `IDAT` chunk. If the source already contains a `caBX` chunk, that +// chunk is replaced. +// +// The writer applies no exclusion policy of its own: unless an +// `ExclusionProcessor` is supplied, every byte of the source, other than the +// manifest store chunk itself, is covered by the hard binding hash. +// +// `Write()` does not mutate the writer, so a single instance may serve +// concurrent calls as long as the exclusion processor, if any, is thread-safe. +class PngWriter { + public: + // Information about the C2PA manifest store embedded in the output asset. + struct OutputMetadata { + // Whether the C2PA active manifest embedded in the output asset has a + // timestamp. + bool timestamp_included = false; + }; + + // A function that determines byte ranges within a given PNG chunk to be + // excluded from the hard binding hash. + // + // It is invoked once per chunk of the source asset, in file order, for every + // chunk except an already existing `caBX` chunk. The reader is positioned + // arbitrarily and may be freely sought by the processor. + // + // The returned ByteRanges are absolute file offsets. They must be sorted by + // offset, must not overlap, and must lie within the chunk, that is within + // [chunk.offset, chunk.offset + chunk.length), which spans the whole chunk + // record including its length, type and CRC fields. Returning ranges that + // violate these rules fails the write with `kInvalidArgument`. Return an + // empty vector to hash the chunk in full. + using ExclusionProcessor = + absl::AnyInvocable<absl::StatusOr<std::vector<ByteRange>>( + riegeli::Reader&, const PngChunk&, + const GenerateManifestStoreRequest&) const>; + + struct Options { + // If null, no bytes, other than the manifest store chunk itself, are + // excluded from the hard binding hash. + ExclusionProcessor exclusion_processor = nullptr; + }; + + // Creates a PNG writer. + // + // The generator must outlive the writer. + explicit PngWriter(const ManifestStoreGenerator* absl_nonnull generator) + : PngWriter(generator, Options()) {} + PngWriter(const ManifestStoreGenerator* absl_nonnull generator, + Options options); + + // Generates a C2PA manifest store for `source` and writes a copy of `source` + // with the manifest store embedded to `destination`. + // + // `source` and `destination` must not be backed by the same data; embedding + // in place is not supported. `request` must not include `hard_binding`; the + // hard binding is computed here over the whole asset, minus the manifest + // store chunk and any ranges reported by the exclusion processor. + // + // If `output_metadata` is not null, it is populated before `cb` runs, and it + // must stay alive until then. + // + // Returns (via `cb`): + // - `kInvalidArgument` if `request` carries a `hard_binding`, if the + // source is not a PNG holding an `IDAT` or `caBX` chunk, or if the + // exclusion processor reports unusable ranges. + // - `kAlreadyExists` if the source already holds a `caBX` chunk and + // `request` carries no `opened` action. + // - whatever the generator or the underlying reader and writer report. + void Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata, + AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb); + // Blocking version. Blocks until the write completes. + absl::Status Write(GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata); + + private: + const ManifestStoreGenerator& generator_; + Options options_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PNG_WRITER_H_
diff --git a/formats/png/writer_test.cc b/formats/png/writer_test.cc new file mode 100644 index 0000000..f65cbc6 --- /dev/null +++ b/formats/png/writer_test.cc
@@ -0,0 +1,260 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/png/writer.h" + +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/absl_check.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "async/async_context.h" +#include "formats/byte_range.h" +#include "formats/png/extractor.h" +#include "formats/png/reader.h" +#include "formats/png/test_utils.h" +#include "generator/generator.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/generator.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_status.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "testing/nonprod_generator.h" +#include "testing/nonprod_validator.h" +#include "validator/asset_validator.h" +#include "validator/ingredient_result.h" +#include "validator/ingredient_result_converter.h" +#include "validator/result.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::AllOf; +using ::testing::Field; +using ::testing::Gt; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::Optional; + +// Returns a minimal 13x17 RGB PNG image: signature, IHDR, IDAT, IEND. +// +// The IDAT payload is not a valid zlib stream; C2PA processing never decodes +// the pixel data. +absl::StatusOr<std::string> CreatePngImage() { + return CreatePng({ + {.type = 'IHDR', + .payload = std::string("\x00\x00\x00\x0d" // Width: 13. + "\x00\x00\x00\x11" // Height: 17. + "\x08\x02\x00\x00\x00", // 8-bit RGB. + 13)}, + {.type = 'IDAT', .payload = std::string(64, '\x40')}, + }); +} + +GenerateManifestStoreRequest CreateRequest() { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + return request; +} + +class PngWriterTest : public ::testing::Test { + protected: + void SetUp() override { + auto generator = CreateNonProdManifestStoreGenerator(); + ABSL_CHECK_OK(generator); + generator_ = *std::move(generator); + writer_ = std::make_unique<PngWriter>(generator_.get()); + + auto validator = CreateNonProdValidator(); + ABSL_CHECK_OK(validator); + validator_ = *std::move(validator); + } + + // Validates `asset` and returns the ingredient validation result describing + // it, as needed to reference it as an ingredient. + absl::StatusOr<std::unique_ptr<IngredientResult>> ValidateIngredient( + absl::string_view asset) { + riegeli::StringReader reader(asset); + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<ValidationResult> result, + validator_->Validate(reader, "image/png")); + return IngredientResultConverter().Convert(std::move(*result)); + } + + // Writes a manifest store into a copy of `image` using `writer`. + static absl::StatusOr<std::string> WriteCopy( + PngWriter& writer, GenerateManifestStoreRequest request, + absl::string_view image, + PngWriter::OutputMetadata* output_metadata = nullptr) { + riegeli::StringReader source(image); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_RETURN_IF_ERROR(writer.Write(std::move(request), &source, &destination, + output_metadata)); + if (!destination.Close()) return destination.status(); + return result; + } + + std::unique_ptr<ManifestStoreGenerator> generator_; + std::unique_ptr<PngWriter> writer_; + std::unique_ptr<AssetValidator> validator_; +}; + +TEST_F(PngWriterTest, Write) { + absl::StatusOr<std::string> image = CreatePngImage(); + ABSL_ASSERT_OK(image); + PngWriter::OutputMetadata output_metadata; + absl::StatusOr<std::string> result = + WriteCopy(*writer_, CreateRequest(), *image, &output_metadata); + ABSL_ASSERT_OK(result); + EXPECT_TRUE(output_metadata.timestamp_included); + + riegeli::StringReader result_reader(*result); + EXPECT_THAT(PngExtractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)), + Field(&ByteRange::length, Gt(0)))))); +} + +TEST_F(PngWriterTest, WriteWithoutTimestamp) { + auto generator = + CreateNonProdManifestStoreGenerator({.disable_timestamping = true}); + ABSL_ASSERT_OK(generator); + PngWriter writer(generator->get()); + absl::StatusOr<std::string> image = CreatePngImage(); + ABSL_ASSERT_OK(image); + + PngWriter::OutputMetadata output_metadata; + ABSL_ASSERT_OK(WriteCopy(writer, CreateRequest(), *image, &output_metadata)); + EXPECT_FALSE(output_metadata.timestamp_included); +} + +TEST_F(PngWriterTest, ExistingC2paWithoutOpenedActionFails) { + absl::StatusOr<std::string> image = CreatePngImage(); + ABSL_ASSERT_OK(image); + absl::StatusOr<std::string> png_with_c2pa = + WriteCopy(*writer_, CreateRequest(), *image); + ABSL_ASSERT_OK(png_with_c2pa); + + EXPECT_THAT( + WriteCopy(*writer_, CreateRequest(), *png_with_c2pa), + StatusIs(absl::StatusCode::kAlreadyExists, HasSubstr("already exists"))); +} + +TEST_F(PngWriterTest, ExistingC2paWithOpenedActionSucceeds) { + absl::StatusOr<std::string> image = CreatePngImage(); + ABSL_ASSERT_OK(image); + absl::StatusOr<std::string> png_with_c2pa = + WriteCopy(*writer_, CreateRequest(), *image); + ABSL_ASSERT_OK(png_with_c2pa); + + absl::StatusOr<std::unique_ptr<IngredientResult>> + png_with_c2pa_validation_results = ValidateIngredient(*png_with_c2pa); + ABSL_ASSERT_OK(png_with_c2pa_validation_results); + EXPECT_THAT((*png_with_c2pa_validation_results) + ->proto() + .ingredient_validation_results() + .active_manifest() + .failures(), + IsEmpty()); + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_opened()->set_ingredient_index(0); + *request.add_ingredients()->mutable_ingredient_validation_results() = + (*png_with_c2pa_validation_results)->proto(); + absl::StatusOr<std::string> final_png = + WriteCopy(*writer_, std::move(request), *png_with_c2pa); + ABSL_ASSERT_OK(final_png); + + riegeli::StringReader source(*final_png); + absl::StatusOr<std::unique_ptr<ValidationResult>> result = + validator_->Validate(source, "image/png"); + ABSL_ASSERT_OK(result); + EXPECT_THAT((*result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST_F(PngWriterTest, WriteWithHardBindingFails) { + GenerateManifestStoreRequest request = CreateRequest(); + request.mutable_hard_binding(); + absl::StatusOr<std::string> image = CreatePngImage(); + ABSL_ASSERT_OK(image); + EXPECT_THAT(WriteCopy(*writer_, std::move(request), *image), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("must not include `hard_binding`"))); +} + +TEST_F(PngWriterTest, WriteAsync) { + absl::StatusOr<std::string> image = CreatePngImage(); + ABSL_ASSERT_OK(image); + riegeli::StringReader source(*image); + std::string result; + riegeli::StringWriter destination(&result); + + absl::Notification done; + absl::Status write_status; + PngWriter::OutputMetadata output_metadata; + writer_->Write(CreateRequest(), &source, &destination, &output_metadata, + AsyncContext(), [&](absl::Status status) { + write_status = std::move(status); + done.Notify(); + }); + done.WaitForNotification(); + ABSL_ASSERT_OK(write_status); + ASSERT_TRUE(source.Close()); + ASSERT_TRUE(destination.Close()); + EXPECT_TRUE(output_metadata.timestamp_included); + + riegeli::StringReader result_reader(result); + EXPECT_THAT(PngExtractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)), + Field(&ByteRange::length, Gt(0)))))); +} + +TEST_F(PngWriterTest, WriteWithCustomExclusionProcessor) { + int custom_processor_call_count = 0; + PngWriter writer( + generator_.get(), + {.exclusion_processor = [&custom_processor_call_count]( + riegeli::Reader&, const PngChunk&, + const GenerateManifestStoreRequest&) + -> absl::StatusOr<std::vector<ByteRange>> { + ++custom_processor_call_count; + return std::vector<ByteRange>(); + }}); + absl::StatusOr<std::string> image = CreatePngImage(); + ABSL_ASSERT_OK(image); + + ABSL_ASSERT_OK(WriteCopy(writer, CreateRequest(), *image)); + EXPECT_GT(custom_processor_call_count, 0); +} + +} // namespace +} // namespace credentio
diff --git a/formats/riff/BUILD b/formats/riff/BUILD index df9c29a..e6778c4 100644 --- a/formats/riff/BUILD +++ b/formats/riff/BUILD
@@ -179,3 +179,92 @@ "@abseil-cpp//absl/status:statusor", ], ) + +cc_library( + name = "hasher_box", + srcs = ["hasher_box.cc"], + hdrs = ["hasher_box.h"], + deps = [ + ":extractor", + "//bindings:input_hasher", + "//crypto:algorithms", + "//formats:asset_box", + "//proto:boxes_hash_assertion_cc_proto", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "hasher_box_test", + srcs = ["hasher_box_test.cc"], + deps = [ + ":create_riff", + ":hasher_box", + "//crypto:algorithms", + "//proto:boxes_hash_assertion_cc_proto", + "//testing:proto_test_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "writer", + srcs = ["writer.cc"], + hdrs = ["writer.h"], + deps = [ + ":constants", + ":reader", + "//bindings:input_hasher", + "//crypto:algorithms", + "//formats:hasher_data", + "//generator", + "//proto:generator_cc_proto", + "//utils:byte_instruction", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/types:span", + "@riegeli//riegeli/base:byte_fill", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "writer_test", + srcs = ["writer_test.cc"], + deps = [ + ":create_riff", + ":extractor", + ":writer", + "//formats:byte_range", + "//generator", + "//proto:generator_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:nonprod_generator", + "//testing:nonprod_validator", + "//testing:proto_test_utils", + "//validator:asset_validator", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +)
diff --git a/formats/riff/extractor_test.cc b/formats/riff/extractor_test.cc index ff469c2..7102147 100644 --- a/formats/riff/extractor_test.cc +++ b/formats/riff/extractor_test.cc
@@ -38,7 +38,6 @@ namespace credentio { namespace { -using ::absl_testing::IsOk; using ::absl_testing::IsOkAndHolds; using ::absl_testing::StatusIs; using ::credentio_riff::Chunk; @@ -190,7 +189,7 @@ const auto& expected_result = test_case.result; if (expected_result.ok()) { - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_EQ(*result, expected_result->manifest_store); } else { EXPECT_THAT(result.status(), @@ -210,7 +209,7 @@ const auto& expected_result = test_case.result; if (expected_result.ok()) { - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_EQ(*result, expected_result->manifest_store); } else { EXPECT_THAT(result.status(), @@ -228,7 +227,7 @@ const auto& expected_result = test_case.result; if (expected_result.ok()) { - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_EQ(*result, expected_result->asset_byte_info.manifest_store_location); } else { @@ -247,7 +246,7 @@ const auto& expected_result = test_case.result; if (expected_result.ok()) { - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_THAT(*result, expected_result->asset_byte_info.boxes); } else { EXPECT_THAT(result.status(), @@ -267,7 +266,7 @@ const auto& expected_result = test_case.result; if (expected_result.ok()) { - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_EQ(*result, expected_result->asset_byte_info.manifest_store_location); } else { @@ -287,7 +286,7 @@ const auto& expected_result = test_case.result; if (expected_result.ok()) { - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_THAT(*result, expected_result->asset_byte_info.boxes); } else { EXPECT_THAT(result.status(), @@ -328,7 +327,7 @@ riegeli::StringReader<> input(contents); auto boxes_or = RiffExtractor().ExtractBoxes(input, {.requires_c2pa = false}); - ASSERT_THAT(boxes_or.status(), ::absl_testing::IsOk()); + ABSL_ASSERT_OK(boxes_or.status()); std::vector<AssetBox> boxes = *boxes_or; std::vector<std::string> box_names; @@ -372,7 +371,7 @@ riegeli::StringReader<> input(contents); auto boxes_or = RiffExtractor().ExtractBoxes(input, {.requires_c2pa = false}); - ASSERT_THAT(boxes_or.status(), ::absl_testing::IsOk()); + ABSL_ASSERT_OK(boxes_or.status()); std::vector<AssetBox> boxes = *boxes_or; std::vector<std::string> box_names;
diff --git a/formats/riff/hasher_box.cc b/formats/riff/hasher_box.cc new file mode 100644 index 0000000..f6727c4 --- /dev/null +++ b/formats/riff/hasher_box.cc
@@ -0,0 +1,142 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/riff/hasher_box.h" + +#include <cstddef> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/substitute.h" +#include "bindings/input_hasher.h" +#include "crypto/algorithms.h" +#include "formats/asset_box.h" +#include "formats/riff/extractor.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +namespace { + +// Creator of a single BoxHash entry for a set of boxes. +// This isn't RIFF-specific; could move to a common location. +class BoxHasher { + public: + static absl::StatusOr<BoxHasher> Create(HashAlgorithm alg, + BoxesHashAssertion* assertion) { + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<InputHasher> hasher, + InputHasher::Create(alg)); + return BoxHasher(std::move(hasher), assertion->add_boxes()); + } + + absl::Status HashBox(riegeli::Reader& input, const AssetBox& box) { + entry_->add_names(box.identifier); + ABSL_RETURN_IF_ERROR( + hasher_->Update(input, box.byte_range.offset, box.byte_range.length)); + return absl::OkStatus(); + } + + void Finish() { entry_->set_hash(hasher_->Digest()); } + + private: + BoxHasher(std::unique_ptr<InputHasher> hasher, BoxHash* entry) + : hasher_(std::move(hasher)), entry_(entry) {} + std::unique_ptr<InputHasher> hasher_; + BoxHash* entry_; +}; + +void AddC2paEntry(BoxesHashAssertion& assertion) { + BoxHash* entry = assertion.add_boxes(); + entry->add_names("C2PA"); + entry->set_hash(std::string(1, '\0')); +} + +} // namespace + +absl::StatusOr<BoxesHashAssertion> DigestRiffBoxHash(riegeli::Reader& input, + HashAlgorithm alg) { + BoxesHashAssertion assertion; + assertion.set_alg(absl::StrCat(alg)); + ABSL_ASSIGN_OR_RETURN( + std::vector<AssetBox> boxes, + RiffExtractor().ExtractBoxes(input, {.requires_c2pa = false})); + // Produce hashes for the boxes prior to the (existing or expected) C2PA box. + std::optional<BoxHasher> hasher; + size_t b = 0; + for (; b < boxes.size(); ++b) { + const auto& box = boxes[b]; + if (b == 0) { + if (box.identifier != "RIFF") { + return absl::InvalidArgumentError(absl::Substitute( + "First box has ID '$0', expected 'RIFF'", box.identifier)); + } + } else { + if (box.identifier == "RIFF") { + // Second RIFF box -- insert placeholder C2PA box first. + break; + } + } + if (box.identifier == "C2PA") { + ++b; + break; + } + if (!hasher) { + ABSL_ASSIGN_OR_RETURN(hasher, BoxHasher::Create(alg, &assertion)); + } + ABSL_RETURN_IF_ERROR(hasher->HashBox(input, box)); + } + if (hasher) { + hasher->Finish(); + hasher = std::nullopt; + } + if (b == 0) { + return absl::InvalidArgumentError("RIFF chunk not found"); + } + + AddC2paEntry(assertion); + + // C2PA chunk should be the last box in the first RIFF chunk, so the next + // chunk, if any, should be another top-level RIFF chunk. + if (b < boxes.size() && boxes[b].identifier != "RIFF") { + return absl::InvalidArgumentError(absl::Substitute( + "Expected RIFF chunk, found '$0'", boxes[b].identifier)); + } + + // Produce hashes for any remaining boxes (after the C2PA chunk). + for (; b < boxes.size(); ++b) { + const auto& box = boxes[b]; + if (box.identifier == "C2PA") { + return absl::InvalidArgumentError("Unexpected C2PA chunk found"); + } + if (!hasher) { + ABSL_ASSIGN_OR_RETURN(hasher, BoxHasher::Create(alg, &assertion)); + } + ABSL_RETURN_IF_ERROR(hasher->HashBox(input, box)); + } + if (hasher) { + hasher->Finish(); + hasher = std::nullopt; + } + return assertion; +} + +} // namespace credentio
diff --git a/formats/riff/hasher_box.h b/formats/riff/hasher_box.h new file mode 100644 index 0000000..0c55aa9 --- /dev/null +++ b/formats/riff/hasher_box.h
@@ -0,0 +1,33 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_RIFF_HASHER_BOX_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_HASHER_BOX_H_ + +#include "absl/status/statusor.h" +#include "crypto/algorithms.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +// Digests the given RIFF asset and returns a BoxesHashAssertion that can be +// used to generate a C2PA manifest store. If no C2PA chunk is present, it is +// assumed that a C2PA chunk will be inserted at the end of the first +// RIFF chunk. +absl::StatusOr<BoxesHashAssertion> DigestRiffBoxHash(riegeli::Reader& input, + HashAlgorithm alg); +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_HASHER_BOX_H_
diff --git a/formats/riff/hasher_box_test.cc b/formats/riff/hasher_box_test.cc new file mode 100644 index 0000000..9b9a290 --- /dev/null +++ b/formats/riff/hasher_box_test.cc
@@ -0,0 +1,142 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/riff/hasher_box.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "crypto/algorithms.h" +#include "formats/riff/create_riff.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "riegeli/bytes/string_reader.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio_riff::Chunk; +using ::credentio_riff::RiffChunk; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::HasSubstr; + +TEST(DigestRiffBoxHashTest, NoC2pa) { + std::string contents = RiffChunk("WEBP", Chunk("VP8 ", "aaaaaaaa")); + riegeli::StringReader<> input(contents); + BoxesHashAssertion expected = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + alg: "sha256" + boxes { + names: "RIFF" + names: "VP8 " + hash: "\227\216\222\217z)\256\345\271\257\273\013*;\264^e\t\2507I\340\022A\333\212\203%\210\rtc" + } + # C2PA placeholder is inserted at the end of the RIFF chunk. + boxes { names: "C2PA" hash: "\000" } + )pb"); + EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256), + IsOkAndHolds(EqualsProto(expected))); +} + +TEST(DigestRiffBoxHashTest, WithC2pa) { + std::string contents = RiffChunk( + "WEBP", + absl::StrCat(Chunk("VP8 ", "aaaaaaaa"), Chunk("C2PA", "manifest store"))); + riegeli::StringReader<> input(contents); + BoxesHashAssertion expected = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + alg: "sha256" + boxes { + names: "RIFF" + names: "VP8 " + hash: "V\216\345\371\376\247\347Dl\221\234W\264%\016\233\023d\342i\205\004\3021@\035\273\234\337\271)\343" + } + boxes { names: "C2PA" hash: "\000" } + )pb"); + EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256), + IsOkAndHolds(EqualsProto(expected))); +} + +TEST(DigestRiffBoxHashTest, EmptyRiff) { + riegeli::StringReader<> input(""); + EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("RIFF chunk not found"))); +} + +TEST(DigestRiffBoxHashTest, FirstChunkNotRiff) { + std::string contents = Chunk("ABCD", "aaaaaaaa"); + riegeli::StringReader<> input(contents); + EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("RIFF chunk not found"))); +} + +TEST(DigestRiffBoxHashTest, MultipleRiffChunks) { + std::string contents = + absl::StrCat(RiffChunk("WEBP", absl::StrCat(Chunk("ABCD", "aaaaaaaa"), + Chunk("EFGH", "bbbbbbbb"))), + RiffChunk("BLAH", Chunk("IJKL", "cccccccccccc"))); + riegeli::StringReader<> input(contents); + BoxesHashAssertion expected = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + alg: "sha256" + boxes { + names: "RIFF" + names: "ABCD" + names: "EFGH" + hash: "\323\224Q\237\376a@\373\230_f\354\204\376\255}\016\366\2724\377\200\304\260\002\373\264\332S\300\337\375" + } + boxes { names: "C2PA" hash: "\000" } + boxes { + names: "RIFF" + names: "IJKL" + hash: "\375\032\251\2252\227\265\201_\351\240M\236\307a8\303\245,\037w\362\271\276\024j{HJ\323q\314" + } + )pb"); + EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256), + IsOkAndHolds(EqualsProto(expected))); +} + +TEST(DigestRiffBoxHashTest, C2paInSecondRiffChunk) { + std::string contents = absl::StrCat( + RiffChunk("WEBP", absl::StrCat(Chunk("ABCD", "aaaaaaaa"), + Chunk("EFGH", "bbbbbbbb"))), + RiffChunk("BLAH", Chunk("C2PA", + "C2PA Manifest Store that should have been in " + "the first RIFF chunk"))); + riegeli::StringReader<> input(contents); + EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Unexpected C2PA chunk found"))); +} + +TEST(DigestRiffBoxHashTest, C2paInMiddleOfRiffChunk) { + std::string contents = + RiffChunk("WEBP", absl::StrCat(Chunk("ABCD", "aaaaaaaa"), + Chunk("C2PA", "manifest store"), + Chunk("EFGH", "bbbbbbbb"))); + riegeli::StringReader<> input(contents); + EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Expected RIFF chunk, found 'EFGH'"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/riff/reader_test.cc b/formats/riff/reader_test.cc index 28bc933..6afca16 100644 --- a/formats/riff/reader_test.cc +++ b/formats/riff/reader_test.cc
@@ -82,7 +82,7 @@ credentio_riff::Chunk("MNOP", "fourth chunk"))); riegeli::StringReader<> input(contents); auto chunks_or = GetChunks(input); - ASSERT_THAT(chunks_or.status(), IsOk()); + ABSL_ASSERT_OK(chunks_or.status()); auto chunks = *chunks_or; ASSERT_THAT(chunks, ElementsAre( RiffChunk{ @@ -269,7 +269,7 @@ riegeli::StringReader<> input(contents); auto chunks_or = GetChunks(input); - ASSERT_THAT(chunks_or.status(), IsOk()); + ABSL_ASSERT_OK(chunks_or.status()); auto chunks = *chunks_or; ASSERT_THAT(chunks, ElementsAre( RiffChunk{
diff --git a/formats/riff/writer.cc b/formats/riff/writer.cc new file mode 100644 index 0000000..5973965 --- /dev/null +++ b/formats/riff/writer.cc
@@ -0,0 +1,271 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/riff/writer.h" + +#include <cstdint> +#include <limits> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/types/span.h" +#include "bindings/input_hasher.h" +#include "crypto/algorithms.h" +#include "formats/hasher_data.h" +#include "formats/riff/constants.h" +#include "formats/riff/reader.h" +#include "proto/generator.pb.h" +#include "riegeli/base/byte_fill.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_writing.h" +#include "utils/byte_instruction.h" + +namespace credentio { +namespace { + +absl::StatusOr<uint64_t> FindC2paInsertionPoint(riegeli::Reader& input) { + uint64_t c2pa_insertion_point = 0; + + bool first = true; + ABSL_RETURN_IF_ERROR(IterateOverRiffChunks( + input, + [&c2pa_insertion_point, + &first](const RiffChunk& chunk) -> absl::StatusOr<bool> { + if (first) { + // Verify that the input starts with RIFF chunk. + if (chunk.id != kRiffChunkIdRiff) { + return absl::InvalidArgumentError( + "Input does not start with RIFF chunk"); + } + first = false; + c2pa_insertion_point = chunk.offset + chunk.length; + return true; + } + if (chunk.id == kRiffChunkIdC2pa) { + return absl::AlreadyExistsError("C2PA chunk already exists"); + } + if (chunk.offset >= c2pa_insertion_point) { + return false; // Stop iterating, we finished the first RIFF chunk + } + return true; + })); + if (first) { + return absl::InvalidArgumentError("Input is empty"); + } + return c2pa_insertion_point; +} + +absl::Status AddRiffSizeInstruction( + uint32_t riff_size, std::vector<ByteInstruction>& byte_instructions) { + byte_instructions.push_back({ + .operation = ByteInstruction::Operation::kReplace, + .offset = 4, + .bytes = "", + }); + riegeli::StringWriter size_writer(&byte_instructions.back().bytes); + if (!riegeli::WriteLittleEndian<uint32_t>(riff_size, size_writer)) { + return absl::InternalError("failed to create size writer"); + } + if (!size_writer.Close()) { + return absl::InternalError("failed to write updated RIFF size"); + } + return absl::OkStatus(); +} + +absl::Status AddC2paManifestInstructionPlaceholder( + uint64_t c2pa_insertion_point, uint64_t manifest_store_size, + uint64_t pad_size, std::vector<ByteInstruction>& byte_instructions) { + byte_instructions.push_back({ + .operation = ByteInstruction::Operation::kInsert, + .offset = c2pa_insertion_point, + .bytes = "", + }); + riegeli::StringWriter c2pa_writer(&byte_instructions.back().bytes); + if (!c2pa_writer.Write(kRiffChunkIdC2pa)) { + return absl::InternalError("failed to write C2PA chunk ID"); + } + if (!riegeli::WriteLittleEndian<uint32_t>(manifest_store_size + pad_size, + c2pa_writer)) { + return absl::InternalError("failed to write C2PA chunk size"); + } + if (pad_size > 0) { + if (!c2pa_writer.Write(riegeli::ByteFill(pad_size, '\0'))) { + return absl::InternalError("failed to write padding"); + } + } + if (!c2pa_writer.Close()) { + return absl::InternalError("failed to close c2pa writer"); + } + return absl::OkStatus(); +} + +absl::StatusOr<std::string> ComputeHash( + riegeli::Reader& source, uint64_t file_size, + absl::Span<const ByteInstruction> instructions) { + if (!source.Seek(0) || source.pos() != 0) { + return absl::InternalError("failed to seek to start of source"); + } + + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<InputHasher> hasher, + InputHasher::Create(HashAlgorithm::kSha256)); + // Hash the RIFF header. + ABSL_RETURN_IF_ERROR(hasher->Update(source, 0, instructions[0].offset)); + // Hash the updated RIFF size. + hasher->Update(instructions[0].bytes); + // Hash up to the C2PA chunk insertion point. + ABSL_RETURN_IF_ERROR(hasher->Update(source, 8, instructions[1].offset - 8)); + + // Hash the C2PA chunk placeholder. + hasher->Update(instructions[1].bytes); + + // Hash the remainder of the file. + ABSL_RETURN_IF_ERROR(hasher->Update(source, instructions[1].offset, + file_size - instructions[1].offset)); + return hasher->Digest(); +} + +} // namespace + +absl::StatusOr<std::vector<ByteInstruction>> RiffWriter::WriteInternal( + GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source, + OutputMetadata* absl_nullable output_metadata) const { + if (request.has_hard_binding()) { + return absl::InvalidArgumentError( + "request must not include `hard_binding` (binding is computed by " + "RiffWriter)"); + } + uint64_t file_size = source->Size().value_or(0); + if (file_size == 0) { + return absl::InvalidArgumentError("source is empty"); + } + + // The two-phase approach is required for RIFF: The box hash can't be computed + // correctly until the manifest store size is known, because the manifest size + // affects the top-level RIFF chunk size. + + // Phase 1: Compute preliminary hard binding, produce a manifest store + // template, and embed a zero-filled placeholder in the asset. + ABSL_ASSIGN_OR_RETURN(*request.mutable_hard_binding()->mutable_data_hash(), + DataHashTemplate(HashAlgorithm::kSha256)); + ABSL_ASSIGN_OR_RETURN(ManifestStoreTemplate manifest_store_template, + generator_.GenerateManifestStoreTemplate(request)); + constexpr int64_t kMaxManifestStoreSizeBytes = 100 * 1024 * 1024; // 100 MiB + if (manifest_store_template.manifest_store_size() <= 0 || + manifest_store_template.manifest_store_size() > + kMaxManifestStoreSizeBytes) { + return absl::InvalidArgumentError( + absl::StrCat("invalid manifest store template size: ", + manifest_store_template.manifest_store_size())); + } + + // The insertion point is the end of the first RIFF chunk, or an error if the + // input is not a RIFF file or if a C2PA chunk already exists. + ABSL_ASSIGN_OR_RETURN(uint64_t c2pa_insertion_point, + FindC2paInsertionPoint(*source)); + + uint64_t manifest_store_size = manifest_store_template.manifest_store_size(); + uint64_t pad_size = (manifest_store_size % 2 == 1) ? 1 : 0; + + uint64_t updated_riff_size = + c2pa_insertion_point + manifest_store_size + pad_size; + if (updated_riff_size > std::numeric_limits<uint32_t>::max()) { + return absl::InvalidArgumentError("chunk size overflow"); + } + + std::vector<ByteInstruction> byte_instructions; + ABSL_RETURN_IF_ERROR( + AddRiffSizeInstruction(updated_riff_size, byte_instructions)); + ABSL_RETURN_IF_ERROR(AddC2paManifestInstructionPlaceholder( + c2pa_insertion_point, manifest_store_size, pad_size, byte_instructions)); + + ABSL_ASSIGN_OR_RETURN(std::string hash, + ComputeHash(*source, file_size, byte_instructions)); + + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + std::move(manifest_store_template); + *finalize_request.mutable_hard_binding() = std::move(request.hard_binding()); + finalize_request.mutable_hard_binding() + ->mutable_data_hash() + ->mutable_exclusions(0) + ->set_start(c2pa_insertion_point + 8); + finalize_request.mutable_hard_binding() + ->mutable_data_hash() + ->mutable_exclusions(0) + ->set_length(manifest_store_size); + finalize_request.mutable_hard_binding()->mutable_data_hash()->set_hash( + std::move(hash)); + + ABSL_ASSIGN_OR_RETURN(auto response, + generator_.FinalizeManifestStore(finalize_request)); + if (output_metadata != nullptr) { + output_metadata->timestamp_included = response.is_timestamped(); + } + if (response.manifest_store().size() != + finalize_request.manifest_store_template().manifest_store_size()) { + return absl::InternalError("manifest store size does not match template"); + } + + // Update the C2PA chunk with the manifest store and padding. + byte_instructions.back().bytes = absl::StrCat( + byte_instructions.back().bytes.substr(0, 8), response.manifest_store(), + pad_size > 0 ? std::string(pad_size, '\0') : ""); + + return std::move(byte_instructions); +} + +absl::Status RiffWriter::Write( + GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata) const { + ABSL_ASSIGN_OR_RETURN( + std::vector<ByteInstruction> byte_instructions, + WriteInternal(std::move(request), source, output_metadata)); + return ApplyByteInstructions(source, byte_instructions, destination); +} + +absl::Status RiffWriter::Write( + GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset, + OutputMetadata* absl_nullable output_metadata) const { + if (!asset->SupportsReadMode()) { + return absl::InvalidArgumentError("asset does not support read mode"); + } + + riegeli::Reader* source = asset->ReadMode(0); + if (source == nullptr) { + return asset->ok() ? absl::InternalError("failed to create source reader") + : asset->status(); + } + if (!source->ok()) { + return source->status(); + } + + ABSL_ASSIGN_OR_RETURN( + std::vector<ByteInstruction> byte_instructions, + WriteInternal(std::move(request), source, output_metadata)); + + return ApplyByteInstructions(byte_instructions, asset); +} + +} // namespace credentio
diff --git a/formats/riff/writer.h b/formats/riff/writer.h new file mode 100644 index 0000000..747a54e --- /dev/null +++ b/formats/riff/writer.h
@@ -0,0 +1,74 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_RIFF_WRITER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_WRITER_H_ + +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "generator/generator.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "utils/byte_instruction.h" + +namespace credentio { + +// Writer of C2PA manifest stores into RIFF assets. +class RiffWriter { + public: + // Information about the C2PA manifest store embedded in the output asset. + struct OutputMetadata { + // Whether the C2PA active manifest embedded in the output asset has a + // timestamp. + bool timestamp_included = false; + }; + + // Creates a RIFF writer. + // + // The generator must outlive the writer. + explicit RiffWriter(const ManifestStoreGenerator* absl_nonnull generator) + : generator_(*ABSL_DIE_IF_NULL(generator)) {} + + // Generates a C2PA manifest store and embeds it into a copy of the given + // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the + // same underlying data. The request must not include `hard_binding`. + // Fails if C2PA metadata already exists. + absl::Status Write( + GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata = nullptr) const; + + absl::Status Write( + GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset, + OutputMetadata* absl_nullable output_metadata = nullptr) const; + + private: + const ManifestStoreGenerator& generator_; + + absl::StatusOr<std::vector<ByteInstruction>> WriteInternal( + GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + OutputMetadata* absl_nullable output_metadata) const; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_WRITER_H_
diff --git a/formats/riff/writer_test.cc b/formats/riff/writer_test.cc new file mode 100644 index 0000000..3b94c24 --- /dev/null +++ b/formats/riff/writer_test.cc
@@ -0,0 +1,351 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/riff/writer.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <utility> + +#include "absl/log/absl_check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "formats/byte_range.h" +#include "formats/riff/create_riff.h" +#include "formats/riff/extractor.h" +#include "generator/generator.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/generator.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_status.pb.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "testing/nonprod_generator.h" +#include "testing/nonprod_validator.h" +#include "testing/proto_test_utils.h" +#include "validator/asset_validator.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio_testing::ParseTextProtoOrDie; +using ::credentio_testing::PartiallyEqualsProto; +using ::testing::AllOf; +using ::testing::Contains; +using ::testing::Field; +using ::testing::Gt; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::Not; +using ::testing::Optional; + +std::string CreateWebPImage() { + return credentio_riff::RiffChunk("WEBP", + credentio_riff::Chunk("VP8 ", "image_data")); +} + +std::string CreateAviVideo() { + return credentio_riff::RiffChunk( + "AVI ", + absl::StrCat( + credentio_riff::ListChunk( + "hdrl", credentio_riff::Chunk("avih", std::string(56, '\0'))), + credentio_riff::ListChunk( + "movi", credentio_riff::Chunk("00dc", "fake video frame data")))); +} + +class RiffWriterTest : public ::testing::Test { + protected: + void SetUp() override { + auto generator = CreateNonProdManifestStoreGenerator({}); + ABSL_CHECK_OK(generator); + generator_ = *std::move(generator); + writer_ = std::make_unique<RiffWriter>(generator_.get()); + + auto validator = CreateNonProdValidator({}); + ABSL_CHECK_OK(validator); + validator_ = *std::move(validator); + } + + std::unique_ptr<ManifestStoreGenerator> generator_; + std::unique_ptr<RiffWriter> writer_; + std::unique_ptr<AssetValidator> validator_; +}; + +TEST_F(RiffWriterTest, Write) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + + std::string image = CreateWebPImage(); + riegeli::StringReader source(image); + ABSL_ASSERT_OK(source.status()); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + RiffWriter::OutputMetadata output_metadata; + ABSL_ASSERT_OK( + writer_->Write(request, &source, &destination, &output_metadata)); + ASSERT_TRUE(source.Close()); + ASSERT_TRUE(destination.Close()); + EXPECT_TRUE(output_metadata.timestamp_included); + + riegeli::StringReader result_reader(result); + ABSL_ASSERT_OK(result_reader.status()); + + EXPECT_THAT(RiffExtractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)), + Field(&ByteRange::length, Gt(0)))))); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validation_result = validator_->Validate(result_reader, "image/webp"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); + EXPECT_THAT( + (*validation_result) + ->proto() + .active_manifest() + .validation() + .informationals(), + Not(Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>( + R"pb( + code: "assertion.dataHash.additionalExclusionsPresent" + )pb"))))); +} + +TEST_F(RiffWriterTest, WriteInPlace) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + + std::string image = CreateWebPImage(); + riegeli::StringWriter<> asset( + &image, riegeli::StringWriterBase::Options().set_append(true)); + ABSL_ASSERT_OK(asset.status()); + + RiffWriter::OutputMetadata output_metadata; + ABSL_ASSERT_OK(writer_->Write(request, &asset, &output_metadata)); + ASSERT_TRUE(asset.Close()); + EXPECT_TRUE(output_metadata.timestamp_included); + + riegeli::StringReader<> result_reader(image); + ABSL_ASSERT_OK(result_reader.status()); + + EXPECT_THAT(RiffExtractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)), + Field(&ByteRange::length, Gt(0)))))); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validation_result = validator_->Validate(result_reader, "image/webp"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); + EXPECT_THAT( + (*validation_result) + ->proto() + .active_manifest() + .validation() + .informationals(), + Not(Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>( + R"pb( + code: "assertion.dataHash.additionalExclusionsPresent" + )pb"))))); +} + +TEST_F(RiffWriterTest, WriteAvi) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test avi"); + + std::string video = CreateAviVideo(); + riegeli::StringReader source(video); + ABSL_ASSERT_OK(source.status()); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + RiffWriter::OutputMetadata output_metadata; + ABSL_ASSERT_OK( + writer_->Write(request, &source, &destination, &output_metadata)); + ASSERT_TRUE(source.Close()); + ASSERT_TRUE(destination.Close()); + EXPECT_TRUE(output_metadata.timestamp_included); + + riegeli::StringReader result_reader(result); + ABSL_ASSERT_OK(result_reader.status()); + + EXPECT_THAT(RiffExtractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)), + Field(&ByteRange::length, Gt(0)))))); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validation_result = + validator_->Validate(result_reader, "video/x-msvideo"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); + EXPECT_THAT( + (*validation_result) + ->proto() + .active_manifest() + .validation() + .informationals(), + Not(Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>( + R"pb( + code: "assertion.dataHash.additionalExclusionsPresent" + )pb"))))); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validation_result_no_hint = + validator_->Validate(result_reader, /*media_type=*/{}); + ABSL_ASSERT_OK(validation_result_no_hint); + EXPECT_THAT((*validation_result_no_hint) + ->proto() + .active_manifest() + .validation() + .failures(), + IsEmpty()); +} + +TEST_F(RiffWriterTest, WriteWithOutputMetadataNoTimestamp) { + auto generator = + CreateNonProdManifestStoreGenerator({.disable_timestamping = true}); + ABSL_ASSERT_OK(generator); + auto writer = std::make_unique<RiffWriter>(generator->get()); + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + std::string image = CreateWebPImage(); + riegeli::StringReader source(image); + ABSL_ASSERT_OK(source.status()); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + RiffWriter::OutputMetadata output_metadata; + ABSL_ASSERT_OK( + writer->Write(request, &source, &destination, &output_metadata)); + ASSERT_TRUE(source.Close()); + ASSERT_TRUE(destination.Close()); + EXPECT_FALSE(output_metadata.timestamp_included); +} + +TEST_F(RiffWriterTest, WriteTwiceFails) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + + std::string image = CreateWebPImage(); + riegeli::StringReader source1(image); + ABSL_ASSERT_OK(source1.status()); + std::string result1; + riegeli::StringWriter destination1(&result1); + ABSL_ASSERT_OK(destination1.status()); + ABSL_ASSERT_OK(writer_->Write(request, &source1, &destination1)); + ASSERT_TRUE(source1.Close()); + ASSERT_TRUE(destination1.Close()); + + riegeli::StringReader source2(result1); + ABSL_ASSERT_OK(source2.status()); + std::string result2; + riegeli::StringWriter destination2(&result2); + ABSL_ASSERT_OK(destination2.status()); + EXPECT_THAT( + writer_->Write(request, &source2, &destination2), + StatusIs(absl::StatusCode::kAlreadyExists, HasSubstr("already exists"))); +} + +TEST_F(RiffWriterTest, WriteWithHardBindingFails) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + request.mutable_hard_binding(); + std::string image = CreateWebPImage(); + riegeli::StringReader source(image); + ABSL_ASSERT_OK(source.status()); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + EXPECT_THAT(writer_->Write(request, &source, &destination), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("must not include `hard_binding`"))); +} + +TEST_F(RiffWriterTest, DemonstratesUnboundedMemoryBufferingDoS) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("demonstrate unbounded buffering DoS"); + + constexpr uint32_t kJunkPayloadSize = 10 * 1024 * 1024; // 10 MiB + constexpr uint32_t kTotalRiffSize = 4 + 8 + kJunkPayloadSize; + + std::string large_riff_data; + large_riff_data.reserve(8 + kTotalRiffSize); + + // RIFF Header: "RIFF" <total_riff_size> "WEBP" + large_riff_data.append("RIFF", 4); + large_riff_data.push_back(static_cast<char>(kTotalRiffSize & 0xFF)); + large_riff_data.push_back(static_cast<char>((kTotalRiffSize >> 8) & 0xFF)); + large_riff_data.push_back(static_cast<char>((kTotalRiffSize >> 16) & 0xFF)); + large_riff_data.push_back(static_cast<char>((kTotalRiffSize >> 24) & 0xFF)); + large_riff_data.append("WEBP", 4); + + // JUNK Subchunk: "JUNK" <junk_payload_size> <junk_payload> + large_riff_data.append("JUNK", 4); + large_riff_data.push_back(static_cast<char>(kJunkPayloadSize & 0xFF)); + large_riff_data.push_back(static_cast<char>((kJunkPayloadSize >> 8) & 0xFF)); + large_riff_data.push_back(static_cast<char>((kJunkPayloadSize >> 16) & 0xFF)); + large_riff_data.push_back(static_cast<char>((kJunkPayloadSize >> 24) & 0xFF)); + large_riff_data.append(kJunkPayloadSize, 'A'); + + riegeli::StringReader source(large_riff_data); + ABSL_ASSERT_OK(source.status()); + + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + + ABSL_ASSERT_OK(writer_->Write(request, &source, &destination)); + ASSERT_TRUE(source.Close()); + ASSERT_TRUE(destination.Close()); + + // Verify that the output was successfully generated and contains the 10MB + // media payload + EXPECT_GT(result.size(), kJunkPayloadSize); +} + +} // namespace +} // namespace credentio
diff --git a/formats/tiff/BUILD b/formats/tiff/BUILD index ddc69bc..17cdcee 100644 --- a/formats/tiff/BUILD +++ b/formats/tiff/BUILD
@@ -24,6 +24,46 @@ deps = ["@abseil-cpp//absl/strings:string_view"], ) +cc_test( + name = "constants_test", + srcs = ["constants_test.cc"], + deps = [ + ":constants", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "hasher", + srcs = ["hasher.cc"], + hdrs = ["hasher.h"], + deps = [ + "//bindings:input_hasher", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "hasher_test", + srcs = ["hasher_test.cc"], + deps = [ + ":hasher", + "//bindings:input_hasher", + "//crypto:algorithms", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +) + cc_library( name = "reader", srcs = ["reader.cc"], @@ -137,3 +177,59 @@ "@abseil-cpp//absl/status:statusor", ], ) + +cc_library( + name = "writer", + srcs = ["writer.cc"], + hdrs = ["writer.h"], + deps = [ + ":constants", + ":hasher", + ":reader", + "//bindings:input_hasher", + "//crypto:algorithms", + "//formats:byte_range", + "//generator", + "//proto:data_hash_assertion_cc_proto", + "//proto:generator_cc_proto", + "//utils:byte_instruction", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/types:span", + "@protobuf//:protobuf_lite", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "writer_test", + srcs = ["writer_test.cc"], + deps = [ + ":constants", + ":extractor", + ":reader", + ":writer", + "//formats:byte_range", + "//generator", + "//proto:generator_cc_proto", + "//testing:nonprod_generator", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/endian:endian_writing", + ], +)
diff --git a/formats/tiff/constants_test.cc b/formats/tiff/constants_test.cc new file mode 100644 index 0000000..d2d1e29 --- /dev/null +++ b/formats/tiff/constants_test.cc
@@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/tiff/constants.h" + +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +TEST(ConstantsTest, SizeOfTiffType) { + EXPECT_EQ(SizeOfTiffType(kTiffTypeByte), 1); + EXPECT_EQ(SizeOfTiffType(kTiffTypeAscii), 1); + EXPECT_EQ(SizeOfTiffType(kTiffTypeShort), 2); + EXPECT_EQ(SizeOfTiffType(kTiffTypeLong), 4); + EXPECT_EQ(SizeOfTiffType(kTiffTypeRational), 8); + EXPECT_EQ(SizeOfTiffType(kTiffTypeSByte), 1); + EXPECT_EQ(SizeOfTiffType(kTiffTypeUndefined), 1); + EXPECT_EQ(SizeOfTiffType(kTiffTypeSShort), 2); + EXPECT_EQ(SizeOfTiffType(kTiffTypeSLong), 4); + EXPECT_EQ(SizeOfTiffType(kTiffTypeSRational), 8); + EXPECT_EQ(SizeOfTiffType(kTiffTypeFloat), 4); + EXPECT_EQ(SizeOfTiffType(kTiffTypeDouble), 8); + EXPECT_EQ(SizeOfTiffType(0xFFFF), 1); // Default case +} + +TEST(ConstantsTest, ByteSizeOfImageFileDirectory) { + // count (2) + next offset (4) = 6 + EXPECT_EQ(ByteSizeOfImageFileDirectory(0), 6); + // 6 + 1 * 12 = 18 + EXPECT_EQ(ByteSizeOfImageFileDirectory(1), 18); + // 6 + 10 * 12 = 126 + EXPECT_EQ(ByteSizeOfImageFileDirectory(10), 126); +} + +} // namespace +} // namespace credentio
diff --git a/formats/tiff/extractor_test.cc b/formats/tiff/extractor_test.cc index 6c580e8..1bc8012 100644 --- a/formats/tiff/extractor_test.cc +++ b/formats/tiff/extractor_test.cc
@@ -38,24 +38,9 @@ namespace credentio { namespace { -using ::absl_testing::IsOk; using ::absl_testing::StatusIs; using ::testing::Eq; -#ifndef ASSERT_OK_AND_ASSIGN -#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y -#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) - -#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ - ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) - -#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ - auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ - ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ - ::absl_testing::IsOk()); \ - lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) -#endif - using ::testing::HasSubstr; struct TestCase { @@ -109,7 +94,7 @@ class ExtractorTest : public testing::TestWithParam<TestCase> { public: - void SetUp() override { ASSERT_THAT(GetParam().contents.status(), IsOk()); } + void SetUp() override { ABSL_ASSERT_OK(GetParam().contents.status()); } }; INSTANTIATE_TEST_SUITE_P( @@ -664,14 +649,15 @@ TEST_P(ExtractorTest, ExtractManifestStore) { const TestCase& test_case = GetParam(); - ASSERT_OK_AND_ASSIGN(std::string contents, test_case.contents); - riegeli::StringReader<> input(contents); + const absl::StatusOr<std::string>& contents = test_case.contents; + ABSL_ASSERT_OK(contents); + riegeli::StringReader<> input(*contents); absl::StatusOr<std::string> result = TiffExtractor().ExtractManifestStore(input); const auto& expected_result = test_case.result; if (expected_result.ok()) { - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_THAT(*result, Eq(expected_result->manifest_store)); } else { EXPECT_THAT(result, @@ -682,14 +668,15 @@ TEST_P(ExtractorTest, ExtractManifestStoreLocation) { const TestCase& test_case = GetParam(); - ASSERT_OK_AND_ASSIGN(std::string contents, test_case.contents); - riegeli::StringReader<> input(contents); + const absl::StatusOr<std::string>& contents = test_case.contents; + ABSL_ASSERT_OK(contents); + riegeli::StringReader<> input(*contents); absl::StatusOr<std::optional<ByteRange>> result = TiffExtractor().ExtractManifestStoreLocation(input, {}); const auto& expected_result = test_case.result; if (expected_result.ok()) { - ASSERT_THAT(result, IsOk()); + ABSL_ASSERT_OK(result); EXPECT_THAT(*result, Eq(expected_result->asset_byte_info.manifest_store_location)); } else { @@ -700,8 +687,9 @@ } TEST_P(ExtractorTest, ExtractBoxes) { - ASSERT_OK_AND_ASSIGN(std::string contents, GetParam().contents); - riegeli::StringReader<> input(contents); + const absl::StatusOr<std::string>& contents = GetParam().contents; + ABSL_ASSERT_OK(contents); + riegeli::StringReader<> input(*contents); absl::StatusOr<std::vector<AssetBox>> result = TiffExtractor().ExtractBoxes(input, {});
diff --git a/formats/tiff/hasher.cc b/formats/tiff/hasher.cc new file mode 100644 index 0000000..999fa07 --- /dev/null +++ b/formats/tiff/hasher.cc
@@ -0,0 +1,121 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/tiff/hasher.h" + +#include <algorithm> +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "bindings/input_hasher.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { + +namespace { +// It's fine to use a fixed kMaxChunkBytes here, because the embedder will work +// with any size greater than 8 bytes. (Larger sizes are more efficient.) +constexpr uint64_t kMaxChunkBytes = 1048576; // 1 MiB + +template <typename T> +absl::StatusOr<std::string> UintToString(T value, bool is_big_endian) { + std::string buffer(sizeof(T), '\0'); + if constexpr (sizeof(T) == 2 || sizeof(T) == 4) { + if (is_big_endian) { + riegeli::WriteBigEndian<T>(value, buffer.data()); + } else { + riegeli::WriteLittleEndian<T>(value, buffer.data()); + } + } else { + return absl::InvalidArgumentError("Unsupported type size"); + } + return buffer; +} + +template <typename T> +absl::Status HashWrite(T value, bool is_big_endian, InputHasher& hasher, + riegeli::Writer& writer) { + ABSL_ASSIGN_OR_RETURN(std::string buffer, UintToString(value, is_big_endian)); + hasher.Update(buffer); + if (!writer.Write(buffer)) { + return absl::DataLossError("failed to write to writer"); + } + return absl::OkStatus(); +} + +template <typename T> +absl::Status Write(T value, bool is_big_endian, riegeli::Writer& writer) { + ABSL_ASSIGN_OR_RETURN(std::string buffer, UintToString(value, is_big_endian)); + if (!writer.Write(buffer)) { + return absl::DataLossError("failed to write to writer"); + } + return absl::OkStatus(); +} + +} // namespace + +absl::Status ReadHash(riegeli::Reader& reader, int64_t length, + InputHasher& hasher) { + uint64_t remaining_length_to_copy = length; + while (remaining_length_to_copy > 0) { + std::string chunk; + auto chunk_size = std::min(remaining_length_to_copy, kMaxChunkBytes); + if (!reader.Read(chunk_size, chunk)) { + return absl::DataLossError("Failed to read from source TIFF"); + } + hasher.Update(chunk); + remaining_length_to_copy -= chunk_size; + } + return absl::OkStatus(); +} + +absl::Status ReadHashWrite(riegeli::Reader& reader, int64_t length, + InputHasher& hasher, riegeli::Writer& writer) { + uint64_t remaining_length_to_copy = length; + while (remaining_length_to_copy > 0) { + std::string chunk; + auto chunk_size = std::min(remaining_length_to_copy, kMaxChunkBytes); + if (!reader.Read(chunk_size, chunk)) { + return absl::DataLossError("Failed to read from source TIFF"); + } + hasher.Update(chunk); + if (!writer.Write(chunk)) { + return absl::DataLossError("Failed to write to destination TIFF"); + } + remaining_length_to_copy -= chunk_size; + } + return absl::OkStatus(); +} + +absl::Status HashWrite16(uint16_t value, bool is_big_endian, + InputHasher& hasher, riegeli::Writer& writer) { + return HashWrite<uint16_t>(value, is_big_endian, hasher, writer); +} +absl::Status HashWrite32(uint32_t value, bool is_big_endian, + InputHasher& hasher, riegeli::Writer& writer) { + return HashWrite<uint32_t>(value, is_big_endian, hasher, writer); +} + +absl::Status Write32(uint32_t value, bool is_big_endian, + riegeli::Writer& writer) { + return Write<uint32_t>(value, is_big_endian, writer); +} + +} // namespace credentio
diff --git a/formats/tiff/hasher.h b/formats/tiff/hasher.h new file mode 100644 index 0000000..f2e8566 --- /dev/null +++ b/formats/tiff/hasher.h
@@ -0,0 +1,54 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_TIFF_HASHER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_HASHER_H_ + +#include <cstdint> + +#include "absl/status/status.h" +#include "bindings/input_hasher.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// Reads the given length from the reader and hashes the bytes. Returns an error +// if the read fails. +absl::Status ReadHash(riegeli::Reader& reader, int64_t length, + InputHasher& hasher); + +// Copies the given length from the reader to the writer, hashing the bytes as +// they are copied. This is a helper function for reading a chunk of data and +// hashing it, then writing it to the destination. Returns an error if the read +// or write fails. +absl::Status ReadHashWrite(riegeli::Reader& reader, int64_t length, + InputHasher& hasher, riegeli::Writer& writer); + +// Writes the given value to the writer, hashing the bytes as they are written. +// This is a helper function for writing a chunk of data and hashing it, then +// writing it to the destination. Returns an error if the write fails. +absl::Status HashWrite16(uint16_t value, bool is_big_endian, + InputHasher& hasher, riegeli::Writer& writer); +absl::Status HashWrite32(uint32_t value, bool is_big_endian, + InputHasher& hasher, riegeli::Writer& writer); + +// Writes the given value to the writer. Returns an error if the write fails. +absl::Status Write32(uint32_t value, bool is_big_endian, + riegeli::Writer& writer); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_HASHER_H_
diff --git a/formats/tiff/hasher_test.cc b/formats/tiff/hasher_test.cc new file mode 100644 index 0000000..2643c25 --- /dev/null +++ b/formats/tiff/hasher_test.cc
@@ -0,0 +1,226 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/tiff/hasher.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/escaping.h" +#include "bindings/input_hasher.h" +#include "crypto/algorithms.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(WriteTest, LittleEndianWrite) { + std::string output; + riegeli::StringWriter writer(&output); + + EXPECT_THAT(Write32(1, /*is_big_endian=*/false, writer), IsOk()); + + writer.Close(); + + EXPECT_EQ(output, std::string("\x01\0\0\0", 4)); +} + +TEST(WriteTest, BigEndianWrite) { + std::string output; + riegeli::StringWriter writer(&output); + + EXPECT_THAT(Write32(1, /*is_big_endian=*/true, writer), IsOk()); + + writer.Close(); + + EXPECT_EQ(output, std::string("\0\0\0\x01", 4)); +} + +TEST(WriteTest, Fails) { + std::string output; + riegeli::StringWriter writer(&output); + writer.Close(); + + auto hasher = InputHasher::Create(HashAlgorithm::kSha256); + ABSL_ASSERT_OK(hasher); + + EXPECT_THAT( + Write32(1, /*is_big_endian=*/true, writer), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("failed to write"))); +} + +TEST(HashWriteTest, LittleEndianWrite16) { + std::string output; + riegeli::StringWriter writer(&output); + + auto hasher = InputHasher::Create(HashAlgorithm::kSha256); + ABSL_ASSERT_OK(hasher); + + EXPECT_THAT(HashWrite16(1, /*is_big_endian=*/false, **hasher, writer), + IsOk()); + + writer.Close(); + + EXPECT_EQ(output, std::string("\x01\0", 2)); + EXPECT_EQ(absl::Base64Escape((*hasher)->Digest()), + "R9xUDJTOtwSiOHXBEnPha7C4qHrthN6RHyEzVoEV8lQ="); +} + +TEST(HashWriteTest, BigEndianWrite16) { + std::string output; + riegeli::StringWriter writer(&output); + + auto hasher = InputHasher::Create(HashAlgorithm::kSha256); + ABSL_ASSERT_OK(hasher); + + EXPECT_THAT(HashWrite16(1, /*is_big_endian=*/true, **hasher, writer), IsOk()); + + writer.Close(); + + EXPECT_EQ(output, std::string("\0\x01", 2)); + EXPECT_EQ(absl::Base64Escape((*hasher)->Digest()), + "tBP0fRPuL+bIRbLuFBr4HehY307FSaWLeXC7lmRbyNI="); +} + +TEST(HashWriteTest, LittleEndianWrite32) { + std::string output; + riegeli::StringWriter writer(&output); + + auto hasher = InputHasher::Create(HashAlgorithm::kSha256); + ABSL_ASSERT_OK(hasher); + + EXPECT_THAT(HashWrite32(1, /*is_big_endian=*/false, **hasher, writer), + IsOk()); + + writer.Close(); + + EXPECT_EQ(output, std::string("\x01\0\0\0", 4)); + EXPECT_EQ(absl::Base64Escape((*hasher)->Digest()), + "Z6vdchAk8P9OCz9ML8E7xbrULQt4UdRW2I0gPRWqpFA="); +} + +TEST(HashWriteTest, BigEndianWrite32) { + std::string output; + riegeli::StringWriter writer(&output); + + auto hasher = InputHasher::Create(HashAlgorithm::kSha256); + ABSL_ASSERT_OK(hasher); + + EXPECT_THAT(HashWrite32(1, /*is_big_endian=*/true, **hasher, writer), IsOk()); + + writer.Close(); + + EXPECT_EQ(output, std::string("\0\0\0\x01", 4)); + EXPECT_EQ(absl::Base64Escape((*hasher)->Digest()), + "tAcRqIxwOXVvuKc4J+q+LA/loDRsp+ChBK3A/HZPUo0="); +} + +TEST(HashWriteTest, Fails) { + std::string output; + riegeli::StringWriter writer(&output); + writer.Close(); + + auto hasher = InputHasher::Create(HashAlgorithm::kSha256); + ABSL_ASSERT_OK(hasher); + + EXPECT_THAT( + HashWrite32(1, /*is_big_endian=*/true, **hasher, writer), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("failed to write"))); +} + +TEST(ReadHashWriteTest, Success) { + std::string input = "test_string"; + riegeli::StringReader reader(input); + std::string output; + riegeli::StringWriter writer(&output); + + auto hasher = InputHasher::Create(HashAlgorithm::kSha256); + ABSL_ASSERT_OK(hasher); + + EXPECT_THAT(ReadHashWrite(reader, 4, **hasher, writer), IsOk()); + + writer.Close(); + + EXPECT_EQ(output, "test"); + EXPECT_EQ(absl::Base64Escape((*hasher)->Digest()), + "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="); +} + +TEST(ReadHashTest, Success) { + std::string input = "test_string"; + riegeli::StringReader reader(input); + + auto hasher = InputHasher::Create(HashAlgorithm::kSha256); + ABSL_ASSERT_OK(hasher); + + EXPECT_THAT(ReadHash(reader, 4, **hasher), IsOk()); + + EXPECT_EQ(absl::Base64Escape((*hasher)->Digest()), + "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="); +} + +TEST(ReadHashTest, FailsWithClosedReader) { + std::string input = "test_string"; + riegeli::StringReader reader(input); + + auto hasher = InputHasher::Create(HashAlgorithm::kSha256); + ABSL_ASSERT_OK(hasher); + + reader.Close(); + EXPECT_THAT( + ReadHash(reader, 4, **hasher), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("Failed to read"))); +} + +TEST(ReadHashWriteTest, FailsWithClosedReader) { + std::string input = "test_string"; + riegeli::StringReader reader(input); + std::string output; + riegeli::StringWriter writer(&output); + + auto hasher = InputHasher::Create(HashAlgorithm::kSha256); + ABSL_ASSERT_OK(hasher); + + reader.Close(); + EXPECT_THAT( + ReadHashWrite(reader, 4, **hasher, writer), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("Failed to read"))); +} + +TEST(ReadHashWriteTest, FailsWithClosedWriter) { + std::string input = "test_string"; + riegeli::StringReader reader(input); + std::string output; + riegeli::StringWriter writer(&output); + + auto hasher = InputHasher::Create(HashAlgorithm::kSha256); + ABSL_ASSERT_OK(hasher); + + writer.Close(); + EXPECT_THAT( + ReadHashWrite(reader, 4, **hasher, writer), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("Failed to write"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/tiff/writer.cc b/formats/tiff/writer.cc new file mode 100644 index 0000000..b59d26d --- /dev/null +++ b/formats/tiff/writer.cc
@@ -0,0 +1,514 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/tiff/writer.h" + +#include <algorithm> +#include <cstdint> +#include <limits> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/container/flat_hash_set.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "bindings/input_hasher.h" +#include "crypto/algorithms.h" +#include "formats/byte_range.h" +#include "formats/tiff/constants.h" +#include "formats/tiff/hasher.h" +#include "formats/tiff/reader.h" +#include "google/protobuf/repeated_ptr_field.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_writing.h" +#include "utils/byte_instruction.h" + +namespace credentio { +namespace { + +// The maximum number of entries per IFD in a TIFF file. +constexpr uint16_t kTiffMaxEntriesPerIfd = 60000; + +template <typename T> +absl::Status WriteEndian(T value, TiffEndianness endianness, + riegeli::Writer& writer) { + const bool success = endianness == TiffEndianness::kBigEndian + ? riegeli::WriteBigEndian<T>(value, writer) + : riegeli::WriteLittleEndian<T>(value, writer); + + return success ? absl::OkStatus() + : writer.StatusOrAnnotate( + absl::InternalError("Failed to write value")); +} + +struct ArtifactInformation { + TiffImageFileDirectory ifd_to_add_to; + TiffEndianness endianness; +}; + +// Scans the TIFF file for the first IFD that can be used to store the C2PA +// data. If no suitable IFD is found, a new IFD will be created at the end of +// the file. Returns an error if a C2PA entry already exists. +absl::StatusOr<ArtifactInformation> ScanForArtifactInformation( + riegeli::Reader& source, uint32_t file_size, + const GenerateManifestStoreRequest& request, + const TiffWriter::ExclusionProcessor& exclusion_processor, + DataHashAssertion* absl_nonnull assertion) { + ArtifactInformation result; + + absl::flat_hash_set<uint32_t> visited_offsets; + ABSL_RETURN_IF_ERROR(IterateOverImageFileDirectories( + source, /*known_endianness=*/std::nullopt, visited_offsets, + [&result, &request, &exclusion_processor, assertion, &source, + &visited_offsets](const TiffImageFileDirectory& ifd, + TiffEndianness endianness) -> absl::StatusOr<bool> { + for (const TiffImageFileDirectoryEntry& entry : ifd.entries) { + if (entry.tag == kTiffTagC2pa) { + return absl::AlreadyExistsError("C2PA entry already exists"); + } + if (exclusion_processor != nullptr) { + ABSL_ASSIGN_OR_RETURN( + std::vector<ByteRange> exclusions, + exclusion_processor(source, entry, endianness, visited_offsets, + request)); + for (const ByteRange& exclusion_range : exclusions) { + auto* exclusion = assertion->add_exclusions(); + exclusion->set_start(exclusion_range.offset); + exclusion->set_length(exclusion_range.length); + } + } + } + + result.endianness = endianness; + result.ifd_to_add_to = ifd; + return true; + })); + + if (result.ifd_to_add_to.offset_of_pointer != 4 || + result.ifd_to_add_to.entries.size() >= kTiffMaxEntriesPerIfd) { + // Need to insert a new IFD at the end of the file + result.ifd_to_add_to = TiffImageFileDirectory{ + .offset_of_pointer = static_cast<uint32_t>(source.pos()), + .offset = file_size, + }; + } + + return result; +} + +template <typename T> +absl::Status AppendNumberToString(T value, TiffEndianness endianness, + std::string& output) { + riegeli::StringWriter writer( + &output, riegeli::StringWriterBase::Options().set_append(true)); + + const bool success = endianness == TiffEndianness::kBigEndian + ? riegeli::WriteBigEndian<T>(value, writer) + : riegeli::WriteLittleEndian<T>(value, writer); + if (!success) { + return writer.StatusOrAnnotate( + absl::InternalError("Failed to write value")); + } + if (!writer.Close()) { + return writer.StatusOrAnnotate( + absl::InternalError("Failed to close writer")); + } + return absl::OkStatus(); +} + +// Adjusts the pointer to the IFD to point to the new location. If the IFD is +// being moved, zero out the original location. +absl::StatusOr<std::vector<ByteInstruction>> +CreateByteInstructionsForIfdInsertion( + const TiffImageFileDirectory& ifd_to_add_to, TiffEndianness endianness, + uint32_t file_size) { + std::vector<ByteInstruction> byte_instructions; + + // Add a byte instruction to update pointer to the IFD. + byte_instructions.push_back(ByteInstruction{ + .operation = ByteInstruction::Operation::kReplace, + .offset = ifd_to_add_to.offset_of_pointer, + }); + ABSL_RETURN_IF_ERROR(AppendNumberToString<uint32_t>( + file_size, endianness, byte_instructions.back().bytes)); + + if (ifd_to_add_to.offset_of_pointer == 4) { + // C2PA data will be written to the first (and only) IFD, it will be moved + // to the end of the file, zero out the original location. + byte_instructions.push_back(ByteInstruction{ + .operation = ByteInstruction::Operation::kReplace, + .offset = ifd_to_add_to.offset, + .bytes = std::string( + ByteSizeOfImageFileDirectory(ifd_to_add_to.entries.size()), '\0'), + }); + } + return std::move(byte_instructions); +} + +void SanitizeExclusions( + google::protobuf::RepeatedPtrField<DataRange>* exclusions) { + if (exclusions->empty()) return; + std::sort(exclusions->pointer_begin(), exclusions->pointer_end(), + [](const DataRange* a, const DataRange* b) { + if (a->start() != b->start()) return a->start() < b->start(); + return a->length() < b->length(); + }); + + google::protobuf::RepeatedPtrField<DataRange> merged; + DataRange current = (*exclusions)[0]; + for (int i = 1; i < exclusions->size(); ++i) { + const DataRange& next = (*exclusions)[i]; + uint64_t current_end = current.start() + current.length(); + uint64_t next_end = next.start() + next.length(); + if (next.start() <= current_end) { + if (next_end > current_end) { + current.set_length(next_end - current.start()); + } + } else { + *merged.Add() = current; + current = next; + } + } + *merged.Add() = current; + exclusions->Swap(&merged); +} + +absl::Status HashAroundExclusion(const DataRange& exclusion, + riegeli::Reader& source, InputHasher& hasher) { + if (exclusion.start() < source.pos()) { + return absl::InvalidArgumentError( + "Exclusion start is before current source position"); + } + // Hash up to the start of the exclusion. + uint64_t hash_length = exclusion.start() - source.pos(); + ABSL_RETURN_IF_ERROR(ReadHash(source, hash_length, hasher)); + + // Skip the excluded data + if (!source.Skip(exclusion.length())) { + return absl::InternalError("failed to skip data"); + } + + return absl::OkStatus(); +} + +absl::Status HashInstructionAroundExclusion(const DataRange& exclusion, + uint64_t instruction_offset, + absl::string_view instruction_bytes, + InputHasher& hasher) { + if (exclusion.start() < instruction_offset || + exclusion.start() - instruction_offset > instruction_bytes.size()) { + return absl::InvalidArgumentError( + "Exclusion range out of bounds of byte instruction"); + } + + // hash until the exclusion offset + absl::string_view data_to_hash = + instruction_bytes.substr(0, exclusion.start() - instruction_offset); + hasher.Update(data_to_hash); + + if (exclusion.length() > + instruction_bytes.size() - (exclusion.start() - instruction_offset)) { + return absl::InvalidArgumentError( + "Exclusion data out of bounds of byte instruction"); + } + + return absl::OkStatus(); +} + +absl::Status Hash(riegeli::Reader& source, uint64_t length_with_ifd, + absl::Span<const ByteInstruction> byte_instructions, + const DataHashAssertion& assertion, InputHasher& hasher) { + auto exclusion_iter = assertion.exclusions().begin(); + + for (const ByteInstruction& ins : byte_instructions) { + while (exclusion_iter->start() < ins.offset) { + ABSL_RETURN_IF_ERROR( + HashAroundExclusion(*exclusion_iter, source, hasher)); + ++exclusion_iter; + if (exclusion_iter == assertion.exclusions().end()) { + return absl::InternalError("ran out of exclusions"); + } + } + // Next exclusion is after the start of the instruction, hash up to the + // instruction offset. + if (ins.offset < source.pos()) { + return absl::InvalidArgumentError( + "Byte instruction offset is before current source position"); + } + ABSL_RETURN_IF_ERROR(ReadHash(source, ins.offset - source.pos(), hasher)); + + uint64_t ins_processed = 0; + while (exclusion_iter->start() < ins.offset + ins.bytes.size()) { + ABSL_RETURN_IF_ERROR(HashInstructionAroundExclusion( + *exclusion_iter, ins.offset + ins_processed, + absl::string_view(ins.bytes).substr(ins_processed), hasher)); + + ins_processed = + (exclusion_iter->start() + exclusion_iter->length()) - ins.offset; + + ++exclusion_iter; + if (exclusion_iter == assertion.exclusions().end()) { + return absl::InternalError("ran out of exclusions"); + } + } + + // Process remaining instruction bytes + if (ins_processed < ins.bytes.size()) { + absl::string_view data_to_hash = + absl::string_view(ins.bytes).substr(ins_processed); + hasher.Update(data_to_hash); + } + + if (ins.operation == ByteInstruction::Operation::kReplace) { + if (!source.Skip(ins.bytes.size())) { + return absl::InternalError("failed to skip data"); + } + } + } + + return absl::OkStatus(); +} + +absl::Status WriteImageFileDirectory( + absl::Span<const TiffImageFileDirectoryEntry> entries, + TiffEndianness endianness, riegeli::Writer& writer) { + // Entry Count + ABSL_RETURN_IF_ERROR( + WriteEndian<uint16_t>(entries.size(), endianness, writer)); + // Entries + for (const auto& entry : entries) { + ABSL_RETURN_IF_ERROR(WriteEndian<uint16_t>(entry.tag, endianness, writer)); + ABSL_RETURN_IF_ERROR(WriteEndian<uint16_t>(entry.type, endianness, writer)); + ABSL_RETURN_IF_ERROR( + WriteEndian<uint32_t>(entry.count, endianness, writer)); + ABSL_RETURN_IF_ERROR( + WriteEndian<uint32_t>(entry.value_or_offset, endianness, writer)); + } + // Next IFD Offset + ABSL_RETURN_IF_ERROR(WriteEndian<uint32_t>(0, endianness, writer)); + return absl::OkStatus(); +} + +absl::Status WriteImageFileDirectory( + absl::Span<const TiffImageFileDirectoryEntry> entries, + TiffEndianness endianness, std::string& destination) { + riegeli::StringWriter<> writer(&destination); + ABSL_RETURN_IF_ERROR(WriteImageFileDirectory(entries, endianness, writer)); + if (!writer.Close()) { + return writer.status(); + } + return absl::OkStatus(); +} + +} // namespace + +absl::StatusOr<std::vector<ByteInstruction>> TiffWriter::WriteInternal( + GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source, + OutputMetadata* absl_nullable output_metadata) const { + if (request.has_hard_binding()) { + return absl::InvalidArgumentError( + "request must not include `hard_binding` (binding is computed by " + "TiffWriter)"); + } + if (!source->SupportsSize()) { + return absl::InvalidArgumentError("source must support Size()"); + } + if (!source->Size().has_value()) { + return absl::InternalError("source->Size() returned no value"); + } + + uint64_t source_size = *source->Size(); + + auto hash_algorithm = HashAlgorithm::kSha256; + + auto* assertion = request.mutable_hard_binding()->mutable_data_hash(); + assertion->set_alg(absl::StrCat(hash_algorithm)); + assertion->set_hash(std::string(32, '\0')); + + ABSL_ASSIGN_OR_RETURN( + ArtifactInformation artifact_information, + ScanForArtifactInformation(*source, source_size, request, + options_.exclusion_processor, assertion)); + + ABSL_ASSIGN_OR_RETURN(std::vector<ByteInstruction> byte_instructions, + CreateByteInstructionsForIfdInsertion( + artifact_information.ifd_to_add_to, + artifact_information.endianness, source_size)); + + uint32_t file_size_after_ifd = + source_size + ByteSizeOfImageFileDirectory( + artifact_information.ifd_to_add_to.entries.size() + 1); + + artifact_information.ifd_to_add_to.entries.push_back( + TiffImageFileDirectoryEntry{ + .tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 1, + .value_or_offset = file_size_after_ifd, + }); + + std::sort(artifact_information.ifd_to_add_to.entries.begin(), + artifact_information.ifd_to_add_to.entries.end(), + [](const TiffImageFileDirectoryEntry& a, + const TiffImageFileDirectoryEntry& b) { return a.tag < b.tag; }); + + { + // Holder for the C2PA IFD entry count. + auto* count_exclusion = assertion->add_exclusions(); + // IFD count, IFD Entry tag, and IFD Entry type are 3 uint16_t's + + uint16_t c2pa_entry_index = 0; + for (const auto& entry : artifact_information.ifd_to_add_to.entries) { + if (entry.tag == kTiffTagC2pa) { + break; + } + ++c2pa_entry_index; + } + + count_exclusion->set_start(source_size + // Existing file size + sizeof(uint16_t) + // final IFD count + // Number of entries before C2PA entry + (c2pa_entry_index * kTiffIfdEntrySize) + + sizeof(uint16_t) + // C2PA entry tag + sizeof(uint16_t)); // C2PA entry type + count_exclusion->set_length(sizeof(uint32_t)); // C2PA entry count + + // Holder for the Manifest Store. + auto* length_exclusion = assertion->add_exclusions(); + // Will be located after the IFD containing the C2PA data. + length_exclusion->set_start(file_size_after_ifd); + length_exclusion->set_length(1); + } + + SanitizeExclusions(assertion->mutable_exclusions()); + + ABSL_ASSIGN_OR_RETURN(auto template_response, + generator_.GenerateManifestStoreTemplate(request)); + + if ((std::numeric_limits<uint32_t>::max() - file_size_after_ifd - + template_response.manifest_store_size()) < 0) { + return absl::InvalidArgumentError( + "Manifest store size is too large (not enough space for TIFF " + "structure)"); + } + + // Update the length exclusion to the actual length of the manifest store. + assertion->mutable_exclusions()->rbegin()->set_length( + template_response.manifest_store_size()); + for (auto& entry : artifact_information.ifd_to_add_to.entries) { + if (entry.tag == kTiffTagC2pa) { + entry.count = template_response.manifest_store_size(); + break; + } + } + + { + ByteInstruction last_ifd{ + .operation = ByteInstruction::Operation::kInsert, + .offset = source_size, + }; + ABSL_RETURN_IF_ERROR(WriteImageFileDirectory( + artifact_information.ifd_to_add_to.entries, + artifact_information.endianness, last_ifd.bytes)); + byte_instructions.push_back(last_ifd); + } + + std::sort(byte_instructions.begin(), byte_instructions.end(), + [](const ByteInstruction& a, const ByteInstruction& b) { + return a.offset < b.offset; + }); + + source->Seek(0); + + std::unique_ptr<credentio::InputHasher> hasher; + ABSL_ASSIGN_OR_RETURN(hasher, InputHasher::Create(hash_algorithm)); + + ABSL_RETURN_IF_ERROR(Hash(*source, file_size_after_ifd, byte_instructions, + *assertion, *hasher)); + + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + std::move(template_response); + *finalize_request.mutable_hard_binding()->mutable_data_hash() = + request.hard_binding().data_hash(); + finalize_request.mutable_hard_binding()->mutable_data_hash()->set_hash( + hasher->Digest()); + + ABSL_ASSIGN_OR_RETURN(auto finalize_response, + generator_.FinalizeManifestStore(finalize_request)); + + byte_instructions.push_back( + ByteInstruction{.operation = ByteInstruction::Operation::kInsert, + .offset = static_cast<uint64_t>(file_size_after_ifd), + .bytes = finalize_response.manifest_store()}); + + std::sort(byte_instructions.begin(), byte_instructions.end(), + [](const ByteInstruction& a, const ByteInstruction& b) { + return a.offset < b.offset; + }); + + if (output_metadata != nullptr) { + output_metadata->timestamp_included = finalize_response.is_timestamped(); + } + + return byte_instructions; +} + +absl::Status TiffWriter::Write( + GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata) const { + ABSL_ASSIGN_OR_RETURN( + auto byte_instructions, + WriteInternal(std::move(request), source, output_metadata)); + + return ApplyByteInstructions(source, byte_instructions, destination); +} + +absl::Status TiffWriter::Write( + GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset, + OutputMetadata* absl_nullable output_metadata) const { + if (!asset->SupportsRandomAccess()) { + return absl::InvalidArgumentError("asset must support RandomAccess()"); + } + if (!asset->SupportsReadMode()) { + return absl::InvalidArgumentError("asset must support ReadMode()"); + } + + riegeli::Reader* source = asset->ReadMode(0); + if (source == nullptr) { + return absl::InternalError("Failed to switch asset to ReadMode"); + } + + ABSL_ASSIGN_OR_RETURN( + auto byte_instructions, + WriteInternal(std::move(request), source, output_metadata)); + + return ApplyByteInstructions(byte_instructions, asset); +} + +} // namespace credentio
diff --git a/formats/tiff/writer.h b/formats/tiff/writer.h new file mode 100644 index 0000000..6bb76ff --- /dev/null +++ b/formats/tiff/writer.h
@@ -0,0 +1,96 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_TIFF_WRITER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_WRITER_H_ + +#include <cstdint> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/container/flat_hash_set.h" +#include "absl/functional/any_invocable.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "formats/byte_range.h" +#include "formats/tiff/reader.h" +#include "generator/generator.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "utils/byte_instruction.h" + +namespace credentio { + +// Writer of C2PA manifest stores into TIFF images. +class TiffWriter { + public: + struct OutputMetadata { + bool timestamp_included = false; + }; + + // A function that determines byte ranges associated with a given IFD entry to + // be excluded from hashing. + using ExclusionProcessor = + absl::AnyInvocable<absl::StatusOr<std::vector<ByteRange>>( + riegeli::Reader&, const TiffImageFileDirectoryEntry&, TiffEndianness, + absl::flat_hash_set<uint32_t>&, const GenerateManifestStoreRequest&) + const>; + + struct Options { + ExclusionProcessor exclusion_processor = nullptr; + }; + + // Creates a TIFF writer. + // + // The generator must outlive the writer. + explicit TiffWriter(const ManifestStoreGenerator* absl_nonnull generator) + : TiffWriter(generator, Options{}) {} + + TiffWriter(const ManifestStoreGenerator* absl_nonnull generator, + Options options) + : generator_(*ABSL_DIE_IF_NULL(generator)), + options_(std::move(options)) {} + + // Generates a C2PA manifest store and embeds it into a copy of the given + // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the + // same underlying data. The request must not include `hard_binding`. + // Fails if C2PA metadata already exists. + absl::Status Write( + GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination, + OutputMetadata* absl_nullable output_metadata = nullptr) const; + + // Generates and embeds C2PA manifest in place into the given asset. + absl::Status Write( + GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset, + OutputMetadata* absl_nullable output_metadata = nullptr) const; + + private: + absl::StatusOr<std::vector<ByteInstruction>> WriteInternal( + GenerateManifestStoreRequest request, + riegeli::Reader* absl_nonnull source, + OutputMetadata* absl_nullable output_metadata) const; + + const ManifestStoreGenerator& generator_; + Options options_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_WRITER_H_
diff --git a/formats/tiff/writer_test.cc b/formats/tiff/writer_test.cc new file mode 100644 index 0000000..8fc46e5 --- /dev/null +++ b/formats/tiff/writer_test.cc
@@ -0,0 +1,225 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/tiff/writer.h" + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> + +#include "absl/container/flat_hash_set.h" +#include "absl/log/absl_check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "formats/byte_range.h" +#include "formats/tiff/constants.h" +#include "formats/tiff/extractor.h" +#include "formats/tiff/reader.h" +#include "generator/generator.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/endian/endian_writing.h" +#include "testing/nonprod_generator.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::AllOf; +using ::testing::Field; +using ::testing::Gt; +using ::testing::HasSubstr; +using ::testing::Optional; + +std::string CreateMinimalTiff( + TiffEndianness endianness = TiffEndianness::kBigEndian) { + std::string buffer; + riegeli::StringWriter writer(&buffer); + if (endianness == TiffEndianness::kBigEndian) { + riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer); + riegeli::WriteBigEndian<uint32_t>(8, writer); // Offset to first IFD + riegeli::WriteBigEndian<uint16_t>(2, writer); // count + riegeli::WriteBigEndian<uint16_t>(0x0100, writer); + riegeli::WriteBigEndian<uint16_t>(kTiffTypeShort, writer); + riegeli::WriteBigEndian<uint32_t>(1, writer); + riegeli::WriteBigEndian<uint32_t>(10, writer); + riegeli::WriteBigEndian<uint16_t>(0xffff, writer); + riegeli::WriteBigEndian<uint16_t>(kTiffTypeShort, writer); + riegeli::WriteBigEndian<uint32_t>(1, writer); + riegeli::WriteBigEndian<uint32_t>(20, writer); + riegeli::WriteBigEndian<uint32_t>(0, writer); // next IFD offset + } else { + riegeli::WriteBigEndian<uint32_t>(kTiffLittleEndianHeader, writer); + riegeli::WriteLittleEndian<uint32_t>(8, writer); // Offset to first IFD + riegeli::WriteLittleEndian<uint16_t>(2, writer); // count + riegeli::WriteLittleEndian<uint16_t>(0x0100, writer); + riegeli::WriteLittleEndian<uint16_t>(kTiffTypeShort, writer); + riegeli::WriteLittleEndian<uint32_t>(1, writer); + riegeli::WriteLittleEndian<uint32_t>(10, writer); + riegeli::WriteLittleEndian<uint16_t>(0xffff, writer); + riegeli::WriteLittleEndian<uint16_t>(kTiffTypeShort, writer); + riegeli::WriteLittleEndian<uint32_t>(1, writer); + riegeli::WriteLittleEndian<uint32_t>(20, writer); + riegeli::WriteLittleEndian<uint32_t>(0, writer); // next IFD offset + } + writer.Close(); + return buffer; +} + +class TiffWriterTest : public ::testing::Test { + protected: + void SetUp() override { + auto generator = + CreateNonProdManifestStoreGenerator({.disable_timestamping = true}); + ABSL_CHECK_OK(generator); + generator_ = *std::move(generator); + writer_ = std::make_unique<TiffWriter>(generator_.get()); + } + + std::unique_ptr<ManifestStoreGenerator> generator_; + std::unique_ptr<TiffWriter> writer_; +}; + +TEST_F(TiffWriterTest, WriteMaintainsSortedTags) { + std::string buffer = CreateMinimalTiff(); + + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + + riegeli::StringReader source(buffer); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(writer_->Write(request, &source, &destination)); + ASSERT_TRUE(source.Close()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader result_reader(result); + EXPECT_THAT(TiffExtractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)), + Field(&ByteRange::length, Gt(0)))))); + + // Validate that output IFD has the C2PA tag, and all tags are properly + // sorted. + ASSERT_TRUE(result_reader.Seek(0)); + absl::flat_hash_set<uint32_t> visited_offsets; + bool validated = false; + ABSL_ASSERT_OK(IterateOverImageFileDirectories( + result_reader, /*known_endianness=*/std::nullopt, visited_offsets, + [&](const TiffImageFileDirectory& ifd, + TiffEndianness endianness) -> absl::StatusOr<bool> { + validated = true; + EXPECT_EQ(ifd.entries.size(), 3); + EXPECT_EQ(ifd.entries[0].tag, 0x0100); + EXPECT_EQ(ifd.entries[1].tag, kTiffTagC2pa); + EXPECT_EQ(ifd.entries[2].tag, 0xffff); + return true; + })); + EXPECT_TRUE(validated); +} + +TEST_F(TiffWriterTest, WriteLittleEndianSucceeds) { + std::string buffer = CreateMinimalTiff(TiffEndianness::kLittleEndian); + + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + + riegeli::StringReader source(buffer); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(writer_->Write(request, &source, &destination)); + ASSERT_TRUE(source.Close()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader result_reader(result); + EXPECT_THAT(TiffExtractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)), + Field(&ByteRange::length, Gt(0)))))); +} + +TEST_F(TiffWriterTest, WriteTwiceFails) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + + std::string image = CreateMinimalTiff(); + riegeli::StringReader source(image); + std::string result1; + riegeli::StringWriter destination1(&result1); + ABSL_ASSERT_OK(writer_->Write(request, &source, &destination1)); + ASSERT_TRUE(source.Close()); + ASSERT_TRUE(destination1.Close()); + + riegeli::StringReader source2(result1); + std::string result2; + riegeli::StringWriter destination2(&result2); + EXPECT_THAT( + writer_->Write(request, &source2, &destination2), + StatusIs(absl::StatusCode::kAlreadyExists, HasSubstr("already exists"))); +} + +TEST_F(TiffWriterTest, WriteWithHardBindingFails) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + request.mutable_hard_binding(); + std::string image = CreateMinimalTiff(); + riegeli::StringReader source(image); + std::string result; + riegeli::StringWriter destination(&result); + EXPECT_THAT(writer_->Write(request, &source, &destination), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("must not include `hard_binding`"))); +} + +TEST_F(TiffWriterTest, WriteInPlaceSucceeds) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + std::string image = CreateMinimalTiff(); + + riegeli::StringWriter<std::string*> asset( + &image, riegeli::StringWriterBase::Options().set_append(true)); + ABSL_ASSERT_OK(asset.status()); + + ABSL_ASSERT_OK(writer_->Write(request, &asset)); + ASSERT_TRUE(asset.Close()); + + riegeli::StringReader result_reader(image); + EXPECT_THAT(TiffExtractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)), + Field(&ByteRange::length, Gt(0)))))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/zip/BUILD b/formats/zip/BUILD index 10b9ff8..1a0dbf8 100644 --- a/formats/zip/BUILD +++ b/formats/zip/BUILD
@@ -107,6 +107,7 @@ "@abseil-cpp//absl/strings", "@abseil-cpp//absl/types:span", "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:reader", "@riegeli//riegeli/bytes:string_reader", "@riegeli//riegeli/bytes:string_writer", "@riegeli//riegeli/endian:endian_writing", @@ -149,3 +150,236 @@ "@abseil-cpp//absl/status:statusor", ], ) + +cc_library( + name = "crc", + srcs = ["crc.cc"], + hdrs = ["crc.h"], + deps = [ + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "crc_test", + srcs = ["crc_test.cc"], + deps = [ + ":crc", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "hasher_data", + srcs = ["hasher_data.cc"], + hdrs = ["hasher_data.h"], + deps = [ + ":constants", + ":reader", + "//bindings:input_hasher", + "//crypto:algorithms", + "//proto:collection_data_hash_assertion_cc_proto", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/types:span", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "hasher_data_test", + srcs = ["hasher_data_test.cc"], + data = [ + "//formats/zip/testing:assets", + ], + deps = [ + ":hasher_data", + ":reader", + "//bindings:collection_data_hash_hard_binding_validator", + "//constants:status_codes", + "//crypto:algorithms", + "//proto:collection_data_hash_assertion_cc_proto", + "//testing:proto_test_utils", + "//testing:test_file_utils", + "//testing:test_validation_tracker", + "//utils:status_tracker", + "//validator:tracker", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "zlib_util", + srcs = ["zlib_util.cc"], + hdrs = ["zlib_util.h"], + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/base:initializer", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/zlib:zlib_reader", + "@riegeli//riegeli/zlib:zlib_writer", + ], +) + +cc_test( + name = "zlib_util_test", + srcs = ["zlib_util_test.cc"], + deps = [ + ":zlib_util", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "content_type_stream_util", + srcs = ["content_type_stream_util.cc"], + hdrs = ["content_type_stream_util.h"], + deps = [ + ":constants", + ":crc", + ":reader", + ":zlib_util", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/base:types", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_reading", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "content_type_stream_util_test", + srcs = ["content_type_stream_util_test.cc"], + deps = [ + ":constants", + ":content_type_stream_util", + ":reader", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +) + +cc_library( + name = "embedder", + srcs = ["embedder.cc"], + hdrs = ["embedder.h"], + deps = [ + ":constants", + ":content_type_stream_util", + ":crc", + ":reader", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@riegeli//riegeli/base:byte_fill", + "@riegeli//riegeli/base:types", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "embedder_test", + srcs = ["embedder_test.cc"], + data = [ + "//formats/zip/testing:assets", + ], + deps = [ + ":constants", + ":embedder", + ":extractor", + ":reader", + ":zlib_util", + "//formats:byte_range", + "//testing:test_file_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +) + +cc_library( + name = "writer", + srcs = ["writer.cc"], + hdrs = ["writer.h"], + deps = [ + ":embedder", + ":hasher_data", + ":reader", + "//crypto:algorithms", + "//generator", + "//proto:generator_cc_proto", + "@abseil-cpp//absl/base:core_headers", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_test( + name = "writer_test", + srcs = ["writer_test.cc"], + data = [ + "//formats/zip/testing:assets", + ], + deps = [ + ":extractor", + ":writer", + "//formats:byte_range", + "//generator", + "//proto:generator_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:nonprod_generator", + "//testing:nonprod_validator", + "//testing:test_file_utils", + "//validator:asset_validator", + "//validator:result", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +)
diff --git a/formats/zip/content_type_stream_util.cc b/formats/zip/content_type_stream_util.cc new file mode 100644 index 0000000..c9fba27 --- /dev/null +++ b/formats/zip/content_type_stream_util.cc
@@ -0,0 +1,150 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/zip/content_type_stream_util.h" + +#include <cstddef> +#include <cstdint> +#include <optional> +#include <string> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "formats/zip/constants.h" +#include "formats/zip/crc.h" +#include "formats/zip/reader.h" +#include "formats/zip/zlib_util.h" +#include "riegeli/base/types.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_reading.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { +namespace { + +constexpr uint16_t kDeflateCompressionMethod = 8; + +// Checks if the file is compressed using DEFLATE algorithm. +absl::StatusOr<bool> IsFileDeflated(riegeli::Reader* absl_nonnull source, + const ZipReader::FileEntry& entry) { + // Seek to the compression method field in the local file header. + source->Seek(entry.local_file_header_offset + 8); + ABSL_RETURN_IF_ERROR(source->status()); + uint16_t compression_method; + riegeli::ReadLittleEndian<uint16_t>(*source, compression_method); + ABSL_RETURN_IF_ERROR(source->status()); + return compression_method == kDeflateCompressionMethod; +} + +} // namespace + +absl::StatusOr<ContentTypesStream> ReadContentTypesStream( + riegeli::Reader* absl_nonnull source, + const ZipReader::FileEntry& content_types_file_entry) { + const uint64_t length = content_types_file_entry.file_range.length; + // Content Types file is typically very small. Cap it at 10 MB. + constexpr uint64_t kMaxContentTypesSize = 10 * 1024 * 1024; + if (length > kMaxContentTypesSize) { + return absl::InvalidArgumentError( + "Content types stream size exceeds maximum limit."); + } + std::optional<riegeli::Position> source_size = source->Size(); + if (source_size.has_value() && length > *source_size) { + return absl::InvalidArgumentError( + "Content types stream length exceeds source size."); + } + source->Seek(content_types_file_entry.file_range.offset); + ABSL_RETURN_IF_ERROR(source->status()); + std::string content; + source->Read(length, content); + ABSL_RETURN_IF_ERROR(source->status()); + ABSL_ASSIGN_OR_RETURN(bool is_deflated, + IsFileDeflated(source, content_types_file_entry)); + if (is_deflated) { + ABSL_ASSIGN_OR_RETURN(content, Uncompress(content)); + } + return ContentTypesStream{.content = content, .is_deflated = is_deflated}; +} + +absl::Status UpdateContentTypesStream(ContentTypesStream& stream, + absl::string_view content_type, + absl::string_view part_name) { + std::string override_element = + absl::Substitute(R"(<Override ContentType="$0" PartName="/$1"/>)", + content_type, part_name); + + // If the override already exists, we do nothing. + if (stream.content.find(override_element) != std::string::npos) { + return absl::OkStatus(); + } + + size_t pos = stream.content.rfind("</Types>"); + if (pos == std::string::npos) { + return absl::InvalidArgumentError( + "Invalid content types stream: missing </Types>"); + } + stream.content.insert(pos, override_element); + return absl::OkStatus(); +} + +absl::StatusOr<ContentTypesStreamMetadata> WriteContentTypesStream( + const ContentTypesStream& stream, bool write_data_descriptor, + riegeli::Writer* absl_nonnull destination) { + // Write the updated content types stream to the destination. + std::string content_types_stream_payload; + if (stream.is_deflated) { + ABSL_ASSIGN_OR_RETURN(content_types_stream_payload, + Compress(stream.content)); + } else { + content_types_stream_payload = stream.content; + } + destination->Write(content_types_stream_payload); + ABSL_RETURN_IF_ERROR(destination->status()); + + ContentTypesStreamMetadata metadata = { + .crc32 = ZipFileCrc(stream.content), + .compressed_size = + static_cast<uint32_t>(content_types_stream_payload.size()), + .uncompressed_size = static_cast<uint32_t>(stream.content.size())}; + + if (write_data_descriptor) { + // Data descriptor signature (4 bytes) + destination->Write(kZipDataDescriptorSignature); + ABSL_RETURN_IF_ERROR(destination->status()); + + // CRC-32 (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>(metadata.crc32, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Compressed size (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>(metadata.compressed_size, + *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Uncompressed size (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>(metadata.uncompressed_size, + *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + } + + return metadata; +} + +} // namespace credentio
diff --git a/formats/zip/content_type_stream_util.h b/formats/zip/content_type_stream_util.h new file mode 100644 index 0000000..6162f6b --- /dev/null +++ b/formats/zip/content_type_stream_util.h
@@ -0,0 +1,67 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CONTENT_TYPE_STREAM_UTIL_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CONTENT_TYPE_STREAM_UTIL_H_ + +#include <cstdint> +#include <string> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/zip/reader.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// The content types stream, including its original content and whether it was +// deflated. +struct ContentTypesStream { + std::string content; + bool is_deflated; +}; + +// Metadata for a ContentTypesStream written to a ZIP archive. +struct ContentTypesStreamMetadata { + uint32_t crc32; + uint32_t compressed_size; + uint32_t uncompressed_size; +}; + +// Reads the content types stream from the source, and inflates it if necessary. +absl::StatusOr<ContentTypesStream> ReadContentTypesStream( + riegeli::Reader* absl_nonnull source, + const ZipReader::FileEntry& content_types_file_entry); + +// Updates the given ContentTypesStream in place by inserting an <Override> +// element for the given content_type and part_name before the closing </Types> +// tag. +absl::Status UpdateContentTypesStream(ContentTypesStream& stream, + absl::string_view content_type, + absl::string_view part_name); + +// Writes the given content types stream payload to the destination, along with +// the data descriptor signature, CRC32, compressed size, and uncompressed size +// if write_data_descriptor is true. Returns the metadata of the stream. +absl::StatusOr<ContentTypesStreamMetadata> WriteContentTypesStream( + const ContentTypesStream& stream, bool write_data_descriptor, + riegeli::Writer* absl_nonnull destination); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CONTENT_TYPE_STREAM_UTIL_H_
diff --git a/formats/zip/content_type_stream_util_test.cc b/formats/zip/content_type_stream_util_test.cc new file mode 100644 index 0000000..f24835d --- /dev/null +++ b/formats/zip/content_type_stream_util_test.cc
@@ -0,0 +1,150 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/zip/content_type_stream_util.h" + +#include <cstddef> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "formats/zip/constants.h" +#include "formats/zip/reader.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(ContentTypeStreamUtilTest, UpdateContentTypesStreamAddsNewOverride) { + ContentTypesStream stream = { + .content = R"(<?xml version="1.0"?><Types></Types>)", + .is_deflated = false}; + ABSL_ASSERT_OK(UpdateContentTypesStream(stream, kZipC2paManifestMimeType, + kZipManifestFileName)); + + EXPECT_THAT( + stream.content, + HasSubstr( + R"(<Override ContentType="application/c2pa" PartName="/META-INF/content_credential.c2pa"/>)")); +} + +TEST(ContentTypeStreamUtilTest, UpdateContentTypesStreamSkipsExistingOverride) { + ContentTypesStream stream = { + .content = + R"(<?xml version="1.0"?><Types><Override ContentType="application/c2pa" PartName="/META-INF/content_credential.c2pa"/></Types>)", + .is_deflated = false}; + ABSL_ASSERT_OK(UpdateContentTypesStream(stream, kZipC2paManifestMimeType, + kZipManifestFileName)); + + EXPECT_EQ( + stream.content, + R"(<?xml version="1.0"?><Types><Override ContentType="application/c2pa" PartName="/META-INF/content_credential.c2pa"/></Types>)"); +} + +TEST(ContentTypeStreamUtilTest, + UpdateContentTypesStreamFailsWithoutClosingTag) { + ContentTypesStream stream = { + .content = R"(<?xml version="1.0"?><Types>Some random data)", + .is_deflated = false}; + + EXPECT_THAT(UpdateContentTypesStream(stream, kZipC2paManifestMimeType, + kZipManifestFileName), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("missing </Types>"))); +} + +TEST(ContentTypeStreamUtilTest, WriteContentTypesStreamSucceeds) { + ContentTypesStream stream = { + .content = R"(<?xml version="1.0"?><Types></Types>)", + .is_deflated = false}; + + std::string output; + riegeli::StringWriter destination(&output); + + auto metadata = WriteContentTypesStream( + stream, /*write_data_descriptor=*/true, &destination); + ABSL_ASSERT_OK(metadata); + ASSERT_TRUE(destination.Close()); + + // Expected written content size: stream size + 4 (signature) + 4 (crc32) + 4 + // (compressed_size) + 4 (uncompressed_size) + size_t expected_size = stream.content.size() + 16; + EXPECT_EQ(output.size(), expected_size); + EXPECT_EQ(metadata->uncompressed_size, stream.content.size()); + EXPECT_EQ(metadata->compressed_size, stream.content.size()); +} + +TEST(ContentTypeStreamUtilTest, WriteContentTypesStreamWithDeflateSucceeds) { + ContentTypesStream stream = { + .content = R"(<?xml version="1.0"?><Types></Types>)", + .is_deflated = true}; + + std::string output; + riegeli::StringWriter destination(&output); + + auto metadata = WriteContentTypesStream( + stream, /*write_data_descriptor=*/true, &destination); + ABSL_ASSERT_OK(metadata); + ASSERT_TRUE(destination.Close()); + + // Cannot determine exact length because deflate output size varies, but + // it should be > 16. + EXPECT_GT(output.size(), 16); + EXPECT_EQ(metadata->uncompressed_size, stream.content.size()); +} + +TEST(ContentTypeStreamUtilTest, + WriteContentTypesStreamWithoutDataDescriptorSucceeds) { + ContentTypesStream stream = { + .content = R"(<?xml version="1.0"?><Types></Types>)", + .is_deflated = false}; + + std::string output; + riegeli::StringWriter destination(&output); + + auto metadata = WriteContentTypesStream( + stream, /*write_data_descriptor=*/false, &destination); + ABSL_ASSERT_OK(metadata); + ASSERT_TRUE(destination.Close()); + + // Expected written content size: stream size only + EXPECT_EQ(output.size(), stream.content.size()); + EXPECT_EQ(metadata->uncompressed_size, stream.content.size()); + EXPECT_EQ(metadata->compressed_size, stream.content.size()); +} + +TEST(ContentTypeStreamUtilTest, ReadContentTypesStreamLengthErrorWithMaxLimit) { + std::string input_data = "some data"; + riegeli::StringReader<> reader(input_data); + ZipReader::FileEntry entry = { + .file_name = "[Content_Types].xml", + .file_range = {.offset = 0, .length = 0x7FFFFFFF}, + }; + auto result = ReadContentTypesStream(&reader, entry); + EXPECT_THAT( + result.status(), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Content types stream size exceeds maximum limit"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/zip/crc.cc b/formats/zip/crc.cc new file mode 100644 index 0000000..127893b --- /dev/null +++ b/formats/zip/crc.cc
@@ -0,0 +1,56 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/zip/crc.h" + +#include <array> +#include <cstdint> + +#include "absl/strings/string_view.h" + +namespace credentio { +namespace { + +// Precomputed CRC-32 table (IEEE 802.3 polynomial: 0xedb88320) +constexpr std::array<uint32_t, 256> MakeCrcTable() { + std::array<uint32_t, 256> table = {}; + for (uint32_t i = 0; i < 256; ++i) { + uint32_t c = i; + for (int j = 0; j < 8; ++j) { + if (c & 1) { + c = 0xedb88320L ^ (c >> 1); + } else { + c = c >> 1; + } + } + table[i] = c; + } + return table; +} + +constexpr std::array<uint32_t, 256> kCrcTable = MakeCrcTable(); +constexpr uint32_t kCrcMask = 0xffffffff; + +} // namespace + +uint32_t ZipFileCrc(absl::string_view data) { + uint32_t crc = kCrcMask; + for (char byte : data) { + crc = kCrcTable[(crc ^ static_cast<uint8_t>(byte)) & 0xff] ^ (crc >> 8); + } + return crc ^ kCrcMask; +} + +} // namespace credentio
diff --git a/formats/zip/crc.h b/formats/zip/crc.h new file mode 100644 index 0000000..645861b --- /dev/null +++ b/formats/zip/crc.h
@@ -0,0 +1,30 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CRC_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CRC_H_ + +#include <cstdint> + +#include "absl/strings/string_view.h" + +namespace credentio { + +// Computes the CRC32 of the given data within a ZIP archive. +uint32_t ZipFileCrc(absl::string_view data); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CRC_H_
diff --git a/formats/zip/crc_test.cc b/formats/zip/crc_test.cc new file mode 100644 index 0000000..5ca402a --- /dev/null +++ b/formats/zip/crc_test.cc
@@ -0,0 +1,31 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/zip/crc.h" + +#include "absl/strings/string_view.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +TEST(ZipFileCrcTest, ComputesCrc32Correctly) { + EXPECT_EQ(ZipFileCrc("hello world"), 0x0d4a1185); + EXPECT_EQ(ZipFileCrc("c2pa"), 0x3ab29e37); + EXPECT_EQ(ZipFileCrc("zip"), 0x421d9546); +} + +} // namespace +} // namespace credentio
diff --git a/formats/zip/embedder.cc b/formats/zip/embedder.cc new file mode 100644 index 0000000..8f7d9d6 --- /dev/null +++ b/formats/zip/embedder.cc
@@ -0,0 +1,482 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/zip/embedder.h" + +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "formats/zip/constants.h" +#include "formats/zip/content_type_stream_util.h" +#include "formats/zip/crc.h" +#include "formats/zip/reader.h" +#include "riegeli/base/byte_fill.h" +#include "riegeli/base/types.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { +namespace { + +// A file entry that has been copied from the source to the destination. +struct FileCopied { + ZipReader::FileEntry entry; + riegeli::Position offset_in_destination; +}; + +// Copies a file entry from the source to the destination. +absl::Status CopyFileEntry(riegeli::Reader* absl_nonnull source, + const ZipReader::FileEntry& entry, + riegeli::Writer* absl_nonnull destination) { + int64_t file_end = entry.file_range.offset + entry.file_range.length; + int64_t data_descriptor_length = 0; + if (entry.has_data_descriptor) { + source->Seek(file_end); + ABSL_RETURN_IF_ERROR(source->status()); + std::string buffer; + source->Read(kZipDataDescriptorSignature.size(), buffer); + ABSL_RETURN_IF_ERROR(source->status()); + if (buffer == kZipDataDescriptorSignature) { + data_descriptor_length = kDataDescriptorWithSignatureSize; + } else { + data_descriptor_length = kDataDescriptorSize; + } + } + int64_t length = + file_end + data_descriptor_length - entry.local_file_header_offset; + source->Seek(entry.local_file_header_offset); + ABSL_RETURN_IF_ERROR(source->status()); + source->Copy(length, *destination); + ABSL_RETURN_IF_ERROR(source->status()); + ABSL_RETURN_IF_ERROR(destination->status()); + + return absl::OkStatus(); +} + +// Reads the content types stream from the source, writes the updated stream to +// the destination, including the local file header and the data descriptor (if +// used). Returns a `ContentTypesStreamMetadata` containing the updated metadata +// of the content types stream. +absl::StatusOr<ContentTypesStreamMetadata> WriteUpdatedContentTypesStream( + riegeli::Reader* absl_nonnull source, + const ZipReader::FileEntry& content_types_file_entry, + riegeli::Writer* absl_nonnull destination) { + if (content_types_file_entry.has_data_descriptor) { + // Copy the original local file header since they don't need to be updated. + source->Seek(content_types_file_entry.local_file_header_offset); + ABSL_RETURN_IF_ERROR(source->status()); + source->Copy(kLocalFileHeaderFixedSize + kOoxmlContentTypesFileName.size() + + content_types_file_entry.extra_field_length, + *destination); + ABSL_RETURN_IF_ERROR(source->status()); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Read and update the original content types stream from the source. + ABSL_ASSIGN_OR_RETURN( + ContentTypesStream content_types_stream, + ReadContentTypesStream(source, content_types_file_entry)); + ABSL_RETURN_IF_ERROR(UpdateContentTypesStream( + content_types_stream, kZipC2paManifestMimeType, kZipManifestFileName)); + return WriteContentTypesStream(content_types_stream, + content_types_file_entry.has_data_descriptor, + destination); + } else { + return absl::UnimplementedError( + "Content types stream without a data descriptor is not supported."); + } +} + +// Writes the central directory header for the updated content types stream to +// the destination. +absl::Status WriteUpdatedContentTypesStreamCentralDirectoryHeader( + riegeli::Reader* absl_nonnull source, + const ZipReader::FileEntry& original_file_entry, + const ContentTypesStreamMetadata& content_types_stream_metadata, + int64_t updated_local_file_header_offset, + riegeli::Writer* absl_nonnull destination) { + // Copy the following fields from the original central directory header: + // - Central directory file header signature (4 bytes) + // - Version made by (2 bytes) + // - Version needed to extract (2 bytes) + // - General purpose bit flag (2 bytes) + // - Compression method (2 bytes) + // - File modification time (2 bytes) + // - File modification date (2 bytes) + source->Seek(original_file_entry.central_directory_header_offset); + ABSL_RETURN_IF_ERROR(source->status()); + source->Copy(16, *destination); + ABSL_RETURN_IF_ERROR(source->status()); + ABSL_RETURN_IF_ERROR(destination->status()); + + // CRC-32 (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>(content_types_stream_metadata.crc32, + *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Compressed size (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>( + content_types_stream_metadata.compressed_size, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Uncompressed size (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>( + content_types_stream_metadata.uncompressed_size, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Copy the remaining fields from the original central directory header: + // - File name length (2 bytes) + // - Extra field length (2 bytes) + // - File comment length (2 bytes) + // - Disk number start (2 bytes) + // - Internal file attributes (2 bytes) + // - External file attributes (4 bytes) + source->Seek(original_file_entry.central_directory_header_offset + 28); + ABSL_RETURN_IF_ERROR(source->status()); + source->Copy(14, *destination); + ABSL_RETURN_IF_ERROR(source->status()); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Local file header offset (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>(updated_local_file_header_offset, + *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // File name + destination->Write(kOoxmlContentTypesFileName); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Extra field and File comment data + int64_t extra_and_comment_length = original_file_entry.extra_field_length + + original_file_entry.file_comment_length; + if (extra_and_comment_length > 0) { + source->Seek(original_file_entry.central_directory_header_offset + + kCentralDirectoryHeaderFixedSize + + original_file_entry.file_name.size()); + ABSL_RETURN_IF_ERROR(source->status()); + source->Copy(extra_and_comment_length, *destination); + ABSL_RETURN_IF_ERROR(source->status()); + ABSL_RETURN_IF_ERROR(destination->status()); + } + + return absl::OkStatus(); +} + +// Writes the local file header for the manifest store to the destination. +absl::Status WriteManifestLocalFileHeader( + int64_t manifest_store_size, riegeli::Writer* absl_nonnull destination) { + // Local file header signature (4 bytes) + destination->Write(kZipLocalFileHeaderSignature); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Version needed to extract (2 bytes) + riegeli::WriteLittleEndian<uint16_t>(10, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Zero-filled fields: + // - General purpose bit flag (2 bytes) + // - Compression method (2 bytes, 0 means no compression) + // - File modification time (2 bytes) + // - File modification date (2 bytes) + // - CRC-32 (4 bytes, placeholder) + destination->Write(riegeli::ByteFill(12)); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Compressed size (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>(manifest_store_size, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Uncompressed size (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>(manifest_store_size, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // File name length (2 bytes, little-endian) + riegeli::WriteLittleEndian<uint16_t>(kZipManifestFileName.size(), + *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Zero-filled extra field length (2 bytes) + destination->Write(riegeli::ByteFill(2)); + ABSL_RETURN_IF_ERROR(destination->status()); + + // File name + destination->Write(kZipManifestFileName); + ABSL_RETURN_IF_ERROR(destination->status()); + + return absl::OkStatus(); +} + +// Writes the central directory header for the manifest store to the +// destination. +absl::Status WriteManifestCentralDirectoryHeader( + uint32_t manifest_store_size, + uint32_t manifest_store_local_file_header_offset, + riegeli::Writer* absl_nonnull destination) { + // Central directory file header signature (4 bytes) + destination->Write(kZipCentralDirectorySignature); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Version made by (2 bytes) + riegeli::WriteLittleEndian<uint16_t>(20, *destination); // Version 2.0 + ABSL_RETURN_IF_ERROR(destination->status()); + + // Version needed to extract (2 bytes) + riegeli::WriteLittleEndian<uint16_t>(10, *destination); // Version 1.0 + ABSL_RETURN_IF_ERROR(destination->status()); + + // Zero-filled fields: + // - General purpose bit flag (2 bytes) + // - Compression method (2 bytes, 0 means no compression) + // - File modification time (2 bytes) + // - File modification date (2 bytes) + // - CRC-32 (4 bytes, placeholder) + destination->Write(riegeli::ByteFill(12)); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Compressed size (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>(manifest_store_size, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Uncompressed size (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>(manifest_store_size, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // File name length (2 bytes, little-endian) + riegeli::WriteLittleEndian<uint16_t>(kZipManifestFileName.size(), + *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Zero-filled fields: + // - Extra field length (2 bytes) + // - File comment length (2 bytes) + // - Disk number start (2 bytes) + // - Internal file attributes (2 bytes) + // - External file attributes (4 bytes) + destination->Write(riegeli::ByteFill(12)); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Local file header offset (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>(manifest_store_local_file_header_offset, + *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // File name + destination->Write(kZipManifestFileName); + ABSL_RETURN_IF_ERROR(destination->status()); + + return absl::OkStatus(); +} + +// Writes the central directory header for a copied file entry to the +// destination. +absl::Status WriteCentralDirectoryHeader( + riegeli::Reader* absl_nonnull source, const FileCopied& file_copied, + riegeli::Writer* absl_nonnull destination) { + source->Seek(file_copied.entry.central_directory_header_offset); + ABSL_RETURN_IF_ERROR(source->status()); + + int64_t total_cd_length = kCentralDirectoryHeaderFixedSize + + file_copied.entry.file_name.size() + + file_copied.entry.extra_field_length + + file_copied.entry.file_comment_length; + + if (file_copied.offset_in_destination == + file_copied.entry.local_file_header_offset) { + source->Copy(total_cd_length, *destination); + ABSL_RETURN_IF_ERROR(source->status()); + ABSL_RETURN_IF_ERROR(destination->status()); + } else { + source->Copy(kCentralDirectoryHeaderFixedSize - 4, *destination); + ABSL_RETURN_IF_ERROR(source->status()); + ABSL_RETURN_IF_ERROR(destination->status()); + + riegeli::WriteLittleEndian<uint32_t>(file_copied.offset_in_destination, + *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + source->Skip(4); + ABSL_RETURN_IF_ERROR(source->status()); + + int64_t remaining_length = file_copied.entry.file_name.size() + + file_copied.entry.extra_field_length + + file_copied.entry.file_comment_length; + source->Copy(remaining_length, *destination); + ABSL_RETURN_IF_ERROR(source->status()); + ABSL_RETURN_IF_ERROR(destination->status()); + } + return absl::OkStatus(); +} + +// Updates the End of Central Directory (EOCD) record with the new central +// directory offset, length and entry count. +absl::Status UpdateEndOfCentralDirectory( + riegeli::Reader* absl_nonnull source, uint32_t central_directory_offset, + uint32_t central_directory_length, uint16_t entry_count, + riegeli::Writer* absl_nonnull destination) { + // The first 8 bytes of the EOCD don't need to be updated. + auto source_size = source->Size(); + if (!source_size.has_value()) return source->status(); + source->Seek(*source_size - kEocdRecordSize); + ABSL_RETURN_IF_ERROR(source->status()); + source->Copy(8, *destination); + ABSL_RETURN_IF_ERROR(source->status()); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Total number of entries in the central directory on this disk (2 bytes, + // little-endian) + riegeli::WriteLittleEndian<uint16_t>(entry_count, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Total number of entries in the central directory (2 bytes, little-endian) + riegeli::WriteLittleEndian<uint16_t>(entry_count, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Size of the central directory (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>(central_directory_length, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Offset of the start of the central directory (4 bytes, little-endian) + riegeli::WriteLittleEndian<uint32_t>(central_directory_offset, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Zero-filled comment length (2 bytes) + destination->Write(riegeli::ByteFill(2)); + ABSL_RETURN_IF_ERROR(destination->status()); + + return absl::OkStatus(); +} + +} // namespace + +absl::StatusOr<ZipEmbedder::CopyAndAllocateResult> ZipEmbedder::CopyAndAllocate( + riegeli::Reader* absl_nonnull source, + absl::Span<const ZipReader::FileEntry> file_entries, + int64_t manifest_store_size, + riegeli::Writer* absl_nonnull destination) const { + ABSL_ASSIGN_OR_RETURN(std::vector<ZipReader::FileEntry> sorted_entries, + ZipReader::SortFileEntries(file_entries)); + + // Step 1: Copy all the files in the ZIP archive from the source to the + // destination, except the content types stream. + std::vector<FileCopied> files_copied; + std::optional<ZipReader::FileEntry> content_types_file_entry; + for (const auto& entry : sorted_entries) { + if (entry.file_name == kOoxmlContentTypesFileName) { + content_types_file_entry = entry; + continue; + } + if (entry.file_name == kZipManifestFileName) { + return absl::AlreadyExistsError("Manifest store already exists."); + } + files_copied.push_back( + {.entry = entry, .offset_in_destination = destination->pos()}); + ABSL_RETURN_IF_ERROR(CopyFileEntry(source, entry, destination)); + } + + // Step 2: Write the updated content types stream to the destination. + if (!content_types_file_entry.has_value()) { + return absl::NotFoundError("Content types stream not found."); + } + auto content_types_stream_output_offset = destination->pos(); + ABSL_ASSIGN_OR_RETURN(ContentTypesStreamMetadata content_types_metadata, + WriteUpdatedContentTypesStream( + source, *content_types_file_entry, destination)); + + // Step 3: Write the zero-filled manifest store to the destination. + auto manifest_store_local_file_header_offset = destination->pos(); + ABSL_RETURN_IF_ERROR( + WriteManifestLocalFileHeader(manifest_store_size, destination)); + auto manifest_store_offset = destination->pos(); + destination->Write(riegeli::ByteFill(manifest_store_size)); + ABSL_RETURN_IF_ERROR(destination->status()); + + // Step 4: Write the central directory headers. + auto central_directory_offset = destination->pos(); + for (const auto& file : files_copied) { + ABSL_RETURN_IF_ERROR( + WriteCentralDirectoryHeader(source, file, destination)); + } + ABSL_RETURN_IF_ERROR(WriteUpdatedContentTypesStreamCentralDirectoryHeader( + source, *content_types_file_entry, content_types_metadata, + content_types_stream_output_offset, destination)); + auto manifest_store_central_directory_header_offset = destination->pos(); + ABSL_RETURN_IF_ERROR(WriteManifestCentralDirectoryHeader( + manifest_store_size, manifest_store_local_file_header_offset, + destination)); + + // Step 5: Update the End of Central Directory (EOCD). + ABSL_RETURN_IF_ERROR(UpdateEndOfCentralDirectory( + source, central_directory_offset, + /*central_directory_length=*/destination->pos() - + central_directory_offset, + files_copied.size() + 2, destination)); + + destination->Flush(); + ABSL_RETURN_IF_ERROR(destination->status()); + + return CopyAndAllocateResult{ + .manifest_store_offset = manifest_store_offset, + .manifest_store_central_directory_header_offset = + manifest_store_central_directory_header_offset, + .central_directory_offset = central_directory_offset, + }; +} + +absl::Status ZipEmbedder::Embed( + absl::string_view manifest_store, + const CopyAndAllocateResult& copy_and_allocate_result, + riegeli::Writer* absl_nonnull destination) const { + if (!destination->SupportsRandomAccess()) { + return absl::InvalidArgumentError( + "ZipEmbedder::Embed requires a riegeli::Writer that supports random " + "access."); + } + uint32_t crc32 = ZipFileCrc(manifest_store); + + int64_t local_file_header_crc_offset = + copy_and_allocate_result.manifest_store_offset - + kZipManifestFileName.size() - 16; + int64_t central_directory_header_crc_offset = + copy_and_allocate_result.manifest_store_central_directory_header_offset + + 16; + + // Replace the zero-filled manifest store placeholder with the actual manifest + // store. Also update the CRC-32 fields in both the local file header and the + // central directory header with the actual CRC-32 of the manifest store. + destination->Seek(local_file_header_crc_offset); + ABSL_RETURN_IF_ERROR(destination->status()); + riegeli::WriteLittleEndian<uint32_t>(crc32, *destination); + ABSL_RETURN_IF_ERROR(destination->status()); + + destination->Seek(copy_and_allocate_result.manifest_store_offset); + ABSL_RETURN_IF_ERROR(destination->status()); + destination->Write(manifest_store); + ABSL_RETURN_IF_ERROR(destination->status()); + + destination->Seek(central_directory_header_crc_offset); + ABSL_RETURN_IF_ERROR(destination->status()); + riegeli::WriteLittleEndian<uint32_t>(crc32, *destination); + return destination->status(); +} + +} // namespace credentio
diff --git a/formats/zip/embedder.h b/formats/zip/embedder.h new file mode 100644 index 0000000..5d32bdd --- /dev/null +++ b/formats/zip/embedder.h
@@ -0,0 +1,66 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ZIP_EMBEDDER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_EMBEDDER_H_ + +#include <cstdint> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "formats/zip/reader.h" +#include "riegeli/base/types.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// A utility class to embed a C2PA manifest store into a ZIP archive. +class ZipEmbedder { + public: + struct CopyAndAllocateResult { + // The offset of the manifest store in the output. + riegeli::Position manifest_store_offset; + // The offset of the manifest store's central directory header in the + // output. + riegeli::Position manifest_store_central_directory_header_offset; + // The offset of the central directory in the output. + riegeli::Position central_directory_offset; + }; + + explicit ZipEmbedder() = default; + + // Copies the file entries from the source to the destination, and allocates + // space for the manifest store. Returns a `CopyAndAllocateResult` containing + // the offsets of the manifest store and its central directory header in the + // output. + absl::StatusOr<CopyAndAllocateResult> CopyAndAllocate( + riegeli::Reader* absl_nonnull source, + absl::Span<const ZipReader::FileEntry> file_entries, + int64_t manifest_store_size, + riegeli::Writer* absl_nonnull destination) const; + + // Embeds the manifest store into the ZIP archive at the offsets specified in + // `copy_and_allocate_result`. + absl::Status Embed(absl::string_view manifest_store, + const CopyAndAllocateResult& copy_and_allocate_result, + riegeli::Writer* absl_nonnull destination) const; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_EMBEDDER_H_
diff --git a/formats/zip/embedder_test.cc b/formats/zip/embedder_test.cc new file mode 100644 index 0000000..3634cd9 --- /dev/null +++ b/formats/zip/embedder_test.cc
@@ -0,0 +1,360 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/zip/embedder.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/byte_range.h" +#include "formats/zip/constants.h" +#include "formats/zip/extractor.h" +#include "formats/zip/reader.h" +#include "formats/zip/zlib_util.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "testing/test_file_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +absl::StatusOr<std::vector<ZipReader::FileEntry>> GetFileEntries( + riegeli::Reader& input) { + ABSL_ASSIGN_OR_RETURN(auto reader, ZipReader::Create(&input)); + std::vector<ZipReader::FileEntry> file_entries; + while (reader->HasNext()) { + ABSL_ASSIGN_OR_RETURN(ZipReader::FileEntry entry, reader->Next()); + file_entries.push_back(entry); + } + return file_entries; +} + +absl::StatusOr<std::string> GetContentTypesStream(riegeli::Reader& input) { + ABSL_ASSIGN_OR_RETURN(auto reader, ZipReader::Create(&input)); + while (reader->HasNext()) { + ABSL_ASSIGN_OR_RETURN(ZipReader::FileEntry entry, reader->Next()); + if (entry.file_name == kOoxmlContentTypesFileName) { + if (!input.Seek(entry.file_range.offset)) { + return input.StatusOrAnnotate(absl::DataLossError("failed to seek")); + } + std::string content; + if (!input.Read(entry.file_range.length, content)) { + return input.StatusOrAnnotate(absl::DataLossError("failed to read")); + } + ABSL_ASSIGN_OR_RETURN(std::string uncompressed, Uncompress(content)); + return uncompressed; + } + } + return absl::NotFoundError("Content types stream not found."); +} + +class ZipEmbedderTest : public testing::Test { + protected: + void InitWithZipFile(absl::string_view file_name, + int64_t manifest_store_size) { + auto source = credentio_testing::GetFileReader(file_name); + ABSL_ASSERT_OK(source); + source_ = *std::move(source); + auto file_entries = GetFileEntries(*source_); + ABSL_ASSERT_OK(file_entries); + file_entries_ = *std::move(file_entries); + + destination_ = std::make_unique<riegeli::StringWriter<>>(&output_s_); + manifest_store_size_ = manifest_store_size; + } + + std::string zip_file_contents_; + std::unique_ptr<riegeli::Reader> source_; + std::vector<ZipReader::FileEntry> file_entries_; + std::string output_s_; + std::unique_ptr<riegeli::StringWriter<>> destination_; + int64_t manifest_store_size_; +}; + +TEST_F(ZipEmbedderTest, CopyAndAllocateFailsIfManifestStoreAlreadyExists) { + InitWithZipFile( + "credentio/formats/zip/testing/" + "asset_fake_c2pa.zip", + /*manifest_store_size=*/100); + + EXPECT_THAT( + ZipEmbedder().CopyAndAllocate(source_.get(), file_entries_, + manifest_store_size_, destination_.get()), + StatusIs(absl::StatusCode::kAlreadyExists, + HasSubstr("Manifest store already exists."))); +} + +TEST_F(ZipEmbedderTest, CopyAndAllocateFailsIfNoContentTypesStream) { + InitWithZipFile("credentio/formats/zip/testing/asset_no_c2pa.zip", + /*manifest_store_size=*/100); + + EXPECT_THAT( + ZipEmbedder().CopyAndAllocate(source_.get(), file_entries_, + manifest_store_size_, destination_.get()), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("Content types stream not found."))); +} + +TEST_F(ZipEmbedderTest, CopyAndAllocate) { + InitWithZipFile("credentio/formats/zip/testing/no_c2pa_032_asset.pptx", + /*manifest_store_size=*/100); + + auto copy_and_allocate_result = ZipEmbedder().CopyAndAllocate( + source_.get(), file_entries_, manifest_store_size_, destination_.get()); + ABSL_ASSERT_OK(copy_and_allocate_result); + ASSERT_TRUE(destination_->Close()); + + riegeli::StringReader<absl::string_view> output_reader(output_s_); + + EXPECT_THAT(ZipExtractor().ExtractManifestStore(output_reader), + IsOkAndHolds(std::string(100, '\0'))); + ASSERT_TRUE(output_reader.Seek(0)); + EXPECT_THAT(ZipExtractor().ExtractManifestStoreLocation(output_reader, {}), + IsOkAndHolds(ByteRange{ + .offset = copy_and_allocate_result->manifest_store_offset, + .length = 100, + })); + ASSERT_TRUE(output_reader.Seek(0)); + auto content_types_stream = GetContentTypesStream(output_reader); + ABSL_ASSERT_OK(content_types_stream); + EXPECT_THAT( + *content_types_stream, + HasSubstr( + R"(<Override ContentType="application/c2pa" PartName="/META-INF/content_credential.c2pa"/>)")); +} + +TEST_F(ZipEmbedderTest, Embed) { + std::string manifest_store = "c2pa manifest store"; + InitWithZipFile("credentio/formats/zip/testing/no_c2pa_032_asset.pptx", + manifest_store.size()); + + auto copy_and_allocate_result = ZipEmbedder().CopyAndAllocate( + source_.get(), file_entries_, manifest_store_size_, destination_.get()); + ABSL_ASSERT_OK(copy_and_allocate_result); + + EXPECT_THAT(ZipEmbedder().Embed(manifest_store, *copy_and_allocate_result, + destination_.get()), + IsOk()); + ASSERT_TRUE(destination_->Close()); + + riegeli::StringReader output_reader(&output_s_); + + EXPECT_THAT(ZipExtractor().ExtractManifestStore(output_reader), + IsOkAndHolds(manifest_store)); +} + +TEST_F(ZipEmbedderTest, CopyAndAllocateFailsWithDuplicateEntries) { + InitWithZipFile("credentio/formats/zip/testing/no_c2pa_032_asset.pptx", 100); + ASSERT_GE(file_entries_.size(), 1); + + std::vector<ZipReader::FileEntry> duplicate_entries = file_entries_; + duplicate_entries.push_back(file_entries_[0]); + + EXPECT_THAT( + ZipEmbedder().CopyAndAllocate(source_.get(), duplicate_entries, + manifest_store_size_, destination_.get()), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Overlapping or duplicate ZIP entries detected"))); +} + +TEST_F(ZipEmbedderTest, CopyAndAllocatePreservesExtraFieldAndCommentData) { + auto write_le16 = [](std::string& s, uint16_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + }; + auto write_le32 = [](std::string& s, uint32_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + s.push_back(static_cast<char>((val >> 16) & 0xff)); + s.push_back(static_cast<char>((val >> 24) & 0xff)); + }; + + std::string zip; + std::string file1_name = "[Content_Types].xml"; + std::string file1_data = "<Types></Types>"; + std::string file1_extra = "EX1"; + std::string file1_cmt = "CM1"; + + std::string file2_name = "doc.xml"; + std::string file2_data = "<Document/>"; + std::string file2_extra = "EXTRA2"; + std::string file2_cmt = "CMT2"; + + // LFH 1 + uint32_t lfh1_offset = zip.size(); + zip.append("PK\x03\x04", 4); + write_le16(zip, 10); + write_le16(zip, 0x0008); // Has data descriptor + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); + write_le16(zip, file1_name.size()); + write_le16(zip, file1_extra.size()); + zip.append(file1_name); + zip.append(file1_extra); + zip.append(file1_data); + // Data descriptor with signature + zip.append("PK\x07\x08", 4); + write_le32(zip, 0x12345678); + write_le32(zip, file1_data.size()); + write_le32(zip, file1_data.size()); + + // LFH 2 + uint32_t lfh2_offset = zip.size(); + zip.append("PK\x03\x04", 4); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0x87654321); + write_le32(zip, file2_data.size()); + write_le32(zip, file2_data.size()); + write_le16(zip, file2_name.size()); + write_le16(zip, file2_extra.size()); + zip.append(file2_name); + zip.append(file2_extra); + zip.append(file2_data); + + // CD Start + uint32_t cd_offset = zip.size(); + // CD 1 + zip.append("PK\x01\x02", 4); + write_le16(zip, 20); + write_le16(zip, 10); + write_le16(zip, 0x0008); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0x12345678); + write_le32(zip, file1_data.size()); + write_le32(zip, file1_data.size()); + write_le16(zip, file1_name.size()); + write_le16(zip, file1_extra.size()); + write_le16(zip, file1_cmt.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, lfh1_offset); + zip.append(file1_name); + zip.append(file1_extra); + zip.append(file1_cmt); + + // CD 2 + zip.append("PK\x01\x02", 4); + write_le16(zip, 20); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0x87654321); + write_le32(zip, file2_data.size()); + write_le32(zip, file2_data.size()); + write_le16(zip, file2_name.size()); + write_le16(zip, file2_extra.size()); + write_le16(zip, file2_cmt.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, lfh2_offset); + zip.append(file2_name); + zip.append(file2_extra); + zip.append(file2_cmt); + + uint32_t cd_size = zip.size() - cd_offset; + // EOCD + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 2); + write_le16(zip, 2); + write_le32(zip, cd_size); + write_le32(zip, cd_offset); + write_le16(zip, 0); + + riegeli::StringReader source_reader(zip); + auto source_zip_reader = ZipReader::Create(&source_reader); + ABSL_ASSERT_OK(source_zip_reader); + std::vector<ZipReader::FileEntry> file_entries; + while ((*source_zip_reader)->HasNext()) { + auto entry = (*source_zip_reader)->Next(); + ABSL_ASSERT_OK(entry); + file_entries.push_back(*entry); + } + ASSERT_EQ(file_entries.size(), 2); + + std::string output_s; + riegeli::StringWriter<> destination(&output_s); + + auto copy_and_allocate_result = ZipEmbedder().CopyAndAllocate( + &source_reader, file_entries, /*manifest_store_size=*/100, &destination); + ABSL_ASSERT_OK(copy_and_allocate_result); + (void)*copy_and_allocate_result; + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader output_reader(output_s); + auto out_zip_reader = ZipReader::Create(&output_reader); + ABSL_ASSERT_OK(out_zip_reader); + std::vector<ZipReader::FileEntry> out_file_entries; + while ((*out_zip_reader)->HasNext()) { + auto entry = (*out_zip_reader)->Next(); + ABSL_ASSERT_OK(entry); + out_file_entries.push_back(*entry); + } + + // Expect 3 entries in output: doc.xml, [Content_Types].xml, and manifest + // store + EXPECT_EQ(out_file_entries.size(), 3); + + // Check doc.xml retains extra_field_length and file_comment_length + const ZipReader::FileEntry* doc_entry = nullptr; + for (const auto& entry : out_file_entries) { + if (entry.file_name == file2_name) { + doc_entry = &entry; + } + } + ASSERT_NE(doc_entry, nullptr); + EXPECT_EQ(doc_entry->extra_field_length, file2_extra.size()); + EXPECT_EQ(doc_entry->file_comment_length, file2_cmt.size()); + + // Check extra field data and comment data are present in output string + EXPECT_THAT(output_s, HasSubstr(file1_extra)); + EXPECT_THAT(output_s, HasSubstr(file1_cmt)); + EXPECT_THAT(output_s, HasSubstr(file2_extra)); + EXPECT_THAT(output_s, HasSubstr(file2_cmt)); +} + +} // namespace +} // namespace credentio
diff --git a/formats/zip/extractor_test.cc b/formats/zip/extractor_test.cc index a2f6021..ee41064 100644 --- a/formats/zip/extractor_test.cc +++ b/formats/zip/extractor_test.cc
@@ -18,6 +18,7 @@ #include <cstddef> #include <cstdint> #include <cstdlib> +#include <memory> #include <optional> #include <string> #include <utility> @@ -33,31 +34,13 @@ #include "formats/zip/constants.h" #include "gmock/gmock.h" #include "gtest/gtest.h" +#include "riegeli/bytes/reader.h" #include "riegeli/bytes/string_reader.h" #include "riegeli/bytes/string_writer.h" #include "riegeli/endian/endian_writing.h" #include "testing/jumbf_utils.h" #include "testing/test_file_utils.h" -// Compatibility macros for OSS -#ifndef ASSERT_OK -#define ASSERT_OK(expr) ASSERT_THAT(expr, ::absl_testing::IsOk()) -#endif - -#ifndef ASSERT_OK_AND_ASSIGN -#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y -#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) - -#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ - ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) - -#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ - auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ - ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ - ::absl_testing::IsOk()); \ - lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) -#endif - namespace credentio { namespace { @@ -67,6 +50,7 @@ using ::testing::Eq; using ::testing::Field; using ::testing::HasSubstr; +using ::testing::Optional; // A Zip file entry. struct ZipEntry { @@ -184,25 +168,26 @@ } TEST(ZipExtractorTest, ExtractManifestStore) { - ASSERT_OK_AND_ASSIGN(auto input, - credentio_testing::GetFileReader( - "c2pa/formats/zip/testing/asset_fake_c2pa.zip")); + absl::StatusOr<std::unique_ptr<riegeli::Reader>> input = + credentio_testing::GetFileReader( + "credentio/formats/zip/testing/asset_fake_c2pa.zip"); + ABSL_ASSERT_OK(input); - EXPECT_THAT(ZipExtractor().ExtractManifestStore(*input), + EXPECT_THAT(ZipExtractor().ExtractManifestStore(**input), IsOkAndHolds(HasSubstr("c2pa manifest"))); } TEST(ZipExtractorTest, ExtractManifestStoreThrowsErrorIfMultipleC2paManifestStoresFound) { // Create a fake Zip file with duplicate manifest stores. - ASSERT_OK_AND_ASSIGN(auto zip_file_contents, - CreateFakeZip({ - {.name = "META-INF/content_credential.c2pa", - .content = "c2pa manifest 1"}, - {.name = "META-INF/content_credential.c2pa", - .content = "c2pa manifest 2"}, - })); - riegeli::StringReader<> input(zip_file_contents); + absl::StatusOr<std::string> zip_file_contents = CreateFakeZip({ + {.name = "META-INF/content_credential.c2pa", + .content = "c2pa manifest 1"}, + {.name = "META-INF/content_credential.c2pa", + .content = "c2pa manifest 2"}, + }); + ABSL_ASSERT_OK(zip_file_contents); + riegeli::StringReader<> input(*zip_file_contents); EXPECT_THAT(ZipExtractor().ExtractManifestStore(input), StatusIs(absl::StatusCode::kNotFound, @@ -210,54 +195,56 @@ } TEST(ZipExtractorTest, ExtractManifestStoreThrowsNotFoundErrorIfNoC2pa) { - ASSERT_OK_AND_ASSIGN(auto input, - credentio_testing::GetFileReader( - "c2pa/formats/zip/testing/asset_no_c2pa.zip")); + absl::StatusOr<std::unique_ptr<riegeli::Reader>> input = + credentio_testing::GetFileReader( + "credentio/formats/zip/testing/asset_no_c2pa.zip"); + ABSL_ASSERT_OK(input); - EXPECT_THAT(ZipExtractor().ExtractManifestStore(*input), + EXPECT_THAT(ZipExtractor().ExtractManifestStore(**input), StatusIs(absl::StatusCode::kNotFound, HasSubstr("No manifest store found"))); } TEST(ZipExtractorTest, ExtractManifestStoreLocation) { - ASSERT_OK_AND_ASSIGN(auto input, - credentio_testing::GetFileReader( - "c2pa/formats/zip/testing/asset_fake_c2pa.zip")); - ASSERT_OK_AND_ASSIGN(auto manifest_store_location, - ZipExtractor().ExtractManifestStoreLocation( - *input, {.requires_c2pa = true})); + absl::StatusOr<std::unique_ptr<riegeli::Reader>> input = + credentio_testing::GetFileReader( + "credentio/formats/zip/testing/asset_fake_c2pa.zip"); + ABSL_ASSERT_OK(input); - EXPECT_THAT(manifest_store_location, - Optional(AllOf(Field(&ByteRange::offset, Eq(201)), - Field(&ByteRange::length, Eq(14))))); + EXPECT_THAT(ZipExtractor().ExtractManifestStoreLocation( + **input, {.requires_c2pa = true}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Eq(201)), + Field(&ByteRange::length, Eq(14)))))); } TEST(ZipExtractorTest, ExtractManifestStoreLocationReturnsNulloptIfNoC2paAndNotRequired) { - ASSERT_OK_AND_ASSIGN(auto input, - credentio_testing::GetFileReader( - "c2pa/formats/zip/testing/asset_no_c2pa.zip")); - ASSERT_OK_AND_ASSIGN(auto manifest_store_location, - ZipExtractor().ExtractManifestStoreLocation( - *input, {.requires_c2pa = false})); + absl::StatusOr<std::unique_ptr<riegeli::Reader>> input = + credentio_testing::GetFileReader( + "credentio/formats/zip/testing/asset_no_c2pa.zip"); + ABSL_ASSERT_OK(input); - EXPECT_EQ(manifest_store_location, std::nullopt); + EXPECT_THAT(ZipExtractor().ExtractManifestStoreLocation( + **input, {.requires_c2pa = false}), + IsOkAndHolds(Eq(std::nullopt))); } TEST(ZipExtractorTest, ExtractManifestStoreLocationReturnsErrorIfNoC2paButRequired) { - ASSERT_OK_AND_ASSIGN(auto input, - credentio_testing::GetFileReader( - "c2pa/formats/zip/testing/asset_no_c2pa.zip")); + absl::StatusOr<std::unique_ptr<riegeli::Reader>> input = + credentio_testing::GetFileReader( + "credentio/formats/zip/testing/asset_no_c2pa.zip"); + ABSL_ASSERT_OK(input); EXPECT_THAT(ZipExtractor().ExtractManifestStoreLocation( - *input, {.requires_c2pa = true}), + **input, {.requires_c2pa = true}), StatusIs(absl::StatusCode::kNotFound, HasSubstr("No manifest store found"))); } TEST(ZipExtractorTest, ExtractBoxesReturnsUnimplemented) { - ASSERT_OK_AND_ASSIGN(auto zip, CreateFakeZip({})); - riegeli::StringReader<> input(zip); + absl::StatusOr<std::string> zip = CreateFakeZip({}); + ABSL_ASSERT_OK(zip); + riegeli::StringReader<> input(*zip); EXPECT_THAT(ZipExtractor().ExtractBoxes(input, {}), StatusIs(absl::StatusCode::kUnimplemented, @@ -273,12 +260,12 @@ TEST(ZipExtractorTest, ExtractManifestStoreThrowsErrorIfManifestStoreExceedsLimit) { std::string large_content(17 * 1024 * 1024, 'a'); - ASSERT_OK_AND_ASSIGN(auto zip, - CreateFakeZip({ - {.name = "META-INF/content_credential.c2pa", - .content = std::move(large_content)}, - })); - riegeli::StringReader<> input(zip); + absl::StatusOr<std::string> zip = CreateFakeZip({ + {.name = "META-INF/content_credential.c2pa", + .content = std::move(large_content)}, + }); + ABSL_ASSERT_OK(zip); + riegeli::StringReader<> input(*zip); EXPECT_THAT(ZipExtractor().ExtractManifestStore(input), StatusIs(absl::StatusCode::kInvalidArgument,
diff --git a/formats/zip/hasher_data.cc b/formats/zip/hasher_data.cc new file mode 100644 index 0000000..9e1eb53 --- /dev/null +++ b/formats/zip/hasher_data.cc
@@ -0,0 +1,121 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/zip/hasher_data.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/types/span.h" +#include "bindings/input_hasher.h" +#include "crypto/algorithms.h" +#include "formats/zip/constants.h" +#include "formats/zip/reader.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +absl::StatusOr<CollectionDataHashAssertion> CollectionDataHashTemplate( + absl::Span<const ZipReader::FileEntry> file_entries, HashAlgorithm alg) { + CollectionDataHashAssertion assertion; + assertion.set_alg(absl::StrCat(alg)); + for (const ZipReader::FileEntry& file_entry : file_entries) { + if (file_entry.file_name == kZipManifestFileName) { + continue; + } + UriHashedData* uri_hashed_data = assertion.add_uris(); + uri_hashed_data->set_uri(file_entry.file_name); + uri_hashed_data->set_hash(std::string(HashSizeBytes(alg), 0)); + } + assertion.set_zip_central_directory_hash(std::string(HashSizeBytes(alg), 0)); + + return assertion; +} + +absl::StatusOr<CollectionDataHashAssertion> DigestCollectionDataHash( + riegeli::Reader* absl_nonnull source, HashAlgorithm alg, + absl::Span<const ZipReader::FileEntry> file_entries, + uint64_t central_directory_offset) { + ABSL_ASSIGN_OR_RETURN(std::vector<ZipReader::FileEntry> sorted_entries, + ZipReader::SortFileEntries(file_entries)); + if (!source->Size().has_value()) { + return absl::InternalError("source->Size() returned no value"); + } + CollectionDataHashAssertion assertion; + assertion.set_alg(absl::StrCat(alg)); + const ZipReader::FileEntry* manifest_file_entry = nullptr; + for (const ZipReader::FileEntry& file_entry : sorted_entries) { + if (file_entry.file_name == kZipManifestFileName) { + manifest_file_entry = &file_entry; + continue; + } + UriHashedData* uri_hashed_data = assertion.add_uris(); + uri_hashed_data->set_uri(file_entry.file_name); + + int64_t entry_end = + file_entry.file_range.offset + file_entry.file_range.length; + if (file_entry.has_data_descriptor) { + if (!source->Seek(entry_end) || source->pos() != entry_end) { + return absl::InternalError("Failed to seek to entry end."); + } + std::string buffer; + if (!source->Read(kZipDataDescriptorSignature.size(), buffer)) { + return absl::InternalError("Failed to read data descriptor signature."); + } + if (buffer == kZipDataDescriptorSignature) { + entry_end += kDataDescriptorWithSignatureSize; + } else { + entry_end += kDataDescriptorSize; + } + } + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<InputHasher> hasher, + InputHasher::Create(alg)); + ABSL_RETURN_IF_ERROR( + hasher->Update(*source, file_entry.local_file_header_offset, + entry_end - file_entry.local_file_header_offset)); + uri_hashed_data->set_hash(hasher->Digest()); + } + + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<InputHasher> hasher, + InputHasher::Create(alg)); + if (!source->Seek(central_directory_offset) || + source->pos() != central_directory_offset) { + return absl::InternalError("Failed to seek to central directory offset."); + } + + if (manifest_file_entry == nullptr) { + return absl::InternalError("No manifest file entry found."); + } + uint64_t crc32_offset = + manifest_file_entry->central_directory_header_offset + 16; + ABSL_RETURN_IF_ERROR(hasher->Update(*source, central_directory_offset, + crc32_offset - central_directory_offset)); + uint64_t after_crc32_offset = crc32_offset + 4; + ABSL_RETURN_IF_ERROR( + hasher->Update(*source, after_crc32_offset, + source->Size().value() - after_crc32_offset)); + assertion.set_zip_central_directory_hash(hasher->Digest()); + + return assertion; +} +} // namespace credentio
diff --git a/formats/zip/hasher_data.h b/formats/zip/hasher_data.h new file mode 100644 index 0000000..87f8b86 --- /dev/null +++ b/formats/zip/hasher_data.h
@@ -0,0 +1,45 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ZIP_HASHER_DATA_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_HASHER_DATA_H_ + +#include <cstdint> + +#include "absl/base/nullability.h" +#include "absl/status/statusor.h" +#include "absl/types/span.h" +#include "crypto/algorithms.h" +#include "formats/zip/reader.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Produces a "template" CollectionDataHashAssertion. This assertion will have +// the same contents as an assertion produced by DigestCollectionDataHash, +// except for the specific hash value for each file in the ZIP archive. +absl::StatusOr<CollectionDataHashAssertion> CollectionDataHashTemplate( + absl::Span<const ZipReader::FileEntry> file_entries, HashAlgorithm alg); + +// Creates a CollectionDataHashAssertion for the given asset. +absl::StatusOr<CollectionDataHashAssertion> DigestCollectionDataHash( + riegeli::Reader* absl_nonnull source, HashAlgorithm alg, + absl::Span<const ZipReader::FileEntry> file_entries, + uint64_t central_directory_offset); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_HASHER_DATA_H_
diff --git a/formats/zip/hasher_data_test.cc b/formats/zip/hasher_data_test.cc new file mode 100644 index 0000000..0e95bb0 --- /dev/null +++ b/formats/zip/hasher_data_test.cc
@@ -0,0 +1,164 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/zip/hasher_data.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "bindings/collection_data_hash_hard_binding_validator.h" +#include "constants/status_codes.h" +#include "crypto/algorithms.h" +#include "formats/zip/reader.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" +#include "testing/proto_test_utils.h" +#include "testing/test_file_utils.h" +#include "testing/test_validation_tracker.h" +#include "utils/status_tracker.h" +#include "validator/tracker.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::credentio_testing::EqualsProto; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::SizeIs; + +// A StatusTracker that records to a ValidationTracker. +class RecordingStatusTracker : public StatusTracker { + public: + explicit RecordingStatusTracker(ValidationTracker& tracker) + : tracker_(tracker) {} + + void RecordSuccess(SuccessStatusCode code, + ValidationTracker::RecordOptions options) override { + tracker_.RecordSuccess(code, options); + } + void RecordFailure(FailureStatusCode code, + ValidationTracker::RecordOptions options) override { + tracker_.RecordFailure(code, options); + } + void RecordInformational(InformationalStatusCode code, + ValidationTracker::RecordOptions options) override { + tracker_.RecordInformational(code, options); + } + + ValidationTracker& tracker_; +}; + +class DigestCollectionDataHashTest : public testing::Test { + protected: + static void SetUpTestSuite() { + // This ZIP archive contains three files: "file_1.txt", "file_2.txt" and a + // fake manifest. + auto file_reader = credentio_testing::GetFileReader( + "credentio/formats/zip/testing/" + "asset_fake_c2pa.zip"); + ABSL_ASSERT_OK(file_reader); + source_ = file_reader->release(); + auto reader = ZipReader::Create(source_); + ABSL_ASSERT_OK(reader); + central_directory_offset_ = (*reader)->central_directory_offset(); + file_entries_ = new std::vector<ZipReader::FileEntry>(); + while ((*reader)->HasNext()) { + absl::StatusOr<ZipReader::FileEntry> entry = (*reader)->Next(); + ABSL_ASSERT_OK(entry); + file_entries_->push_back(*entry); + } + } + + static void TearDownTestSuite() { + delete file_entries_; + file_entries_ = nullptr; + delete source_; + source_ = nullptr; + } + + inline static riegeli::Reader* source_ = nullptr; + inline static std::vector<ZipReader::FileEntry>* file_entries_ = nullptr; + inline static uint64_t central_directory_offset_ = 0; +}; + +TEST_F(DigestCollectionDataHashTest, TemplateMatchesFinal) { + absl::StatusOr<CollectionDataHashAssertion> template_assertion = + CollectionDataHashTemplate(*file_entries_, HashAlgorithm::kSha256); + ABSL_ASSERT_OK(template_assertion); + + absl::StatusOr<CollectionDataHashAssertion> final_assertion = + DigestCollectionDataHash(source_, HashAlgorithm::kSha256, *file_entries_, + central_directory_offset_); + ABSL_ASSERT_OK(final_assertion); + EXPECT_THAT(final_assertion->zip_central_directory_hash(), + SizeIs(template_assertion->zip_central_directory_hash().size())); + EXPECT_THAT(final_assertion->uris(), SizeIs(template_assertion->uris_size())); + + // Copy values from final assertion to template assertion and compare. + for (int i = 0; i < final_assertion->uris_size(); ++i) { + template_assertion->mutable_uris(i)->set_hash( + final_assertion->uris(i).hash()); + } + template_assertion->set_zip_central_directory_hash( + final_assertion->zip_central_directory_hash()); + + EXPECT_THAT(*final_assertion, EqualsProto(*template_assertion)); +} + +TEST_F(DigestCollectionDataHashTest, BindingValidates) { + absl::StatusOr<CollectionDataHashAssertion> final_assertion = + DigestCollectionDataHash(source_, HashAlgorithm::kSha256, *file_entries_, + central_directory_offset_); + ABSL_ASSERT_OK(final_assertion); + + ASSERT_TRUE(source_->Seek(0)); + + CollectionDataHashHardBindingValidator validator; + TestValidationTracker t; + RecordingStatusTracker tracker(t.tracker()); + validator.Validate(*source_, *final_assertion, "test_uri", tracker); + + EXPECT_THAT( + t.GetSuccesses(), + ContainsSuccess(SuccessStatusCode::kAssertionCollectionHashMatch)); + EXPECT_THAT(t.GetFailures(), IsEmpty()); + EXPECT_THAT(t.GetInformationals(), IsEmpty()); +} + +TEST_F(DigestCollectionDataHashTest, + DigestCollectionDataHashFailsWithDuplicateEntries) { + ASSERT_GE(file_entries_->size(), 1); + std::vector<ZipReader::FileEntry> duplicate_entries = *file_entries_; + duplicate_entries.push_back((*file_entries_)[0]); + + EXPECT_THAT( + DigestCollectionDataHash(source_, HashAlgorithm::kSha256, + duplicate_entries, central_directory_offset_), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Overlapping or duplicate ZIP entries detected"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/zip/reader_test.cc b/formats/zip/reader_test.cc index 2bbbfa5..63d726c 100644 --- a/formats/zip/reader_test.cc +++ b/formats/zip/reader_test.cc
@@ -39,7 +39,6 @@ namespace credentio { namespace { -using ::absl_testing::IsOk; using ::absl_testing::StatusIs; using ::testing::AllOf; using ::testing::Contains; @@ -169,7 +168,7 @@ std::string empty_zip_file_contents(kEmptyZipFile, sizeof(kEmptyZipFile)); riegeli::StringReader<> input(empty_zip_file_contents); auto reader_or = ZipReader::Create(&input); - ASSERT_THAT(reader_or, IsOk()); + ABSL_ASSERT_OK(reader_or); auto reader = std::move(*reader_or); EXPECT_FALSE(reader->HasNext()); @@ -180,7 +179,7 @@ "formats/zip/testing/" "asset_unsupported_zip64.zip"); riegeli::CFileReader<> input(zip); - ASSERT_THAT(input.status(), IsOk()); + ABSL_ASSERT_OK(input.status()); EXPECT_THAT(ZipReader::Create(&input), StatusIs(absl::StatusCode::kUnimplemented, @@ -237,12 +236,12 @@ sizeof(kZipWithDataDescriptor)); riegeli::StringReader<> input(zip_contents); auto reader_or = ZipReader::Create(&input); - ASSERT_THAT(reader_or, IsOk()); + ABSL_ASSERT_OK(reader_or); auto reader = std::move(*reader_or); ASSERT_TRUE(reader->HasNext()); auto entry_or = reader->Next(); - ASSERT_THAT(entry_or, IsOk()); + ABSL_ASSERT_OK(entry_or); auto entry = std::move(*entry_or); EXPECT_TRUE(entry.has_data_descriptor); @@ -251,15 +250,15 @@ TEST(ZipReaderTest, ReadsZipFile) { std::string zip = GetTestDataPath("formats/zip/testing/asset_no_c2pa.zip"); riegeli::CFileReader<> input(zip); - ASSERT_THAT(input.status(), IsOk()); + ABSL_ASSERT_OK(input.status()); auto reader_or = ZipReader::Create(&input); - ASSERT_THAT(reader_or, IsOk()); + ABSL_ASSERT_OK(reader_or); auto reader = std::move(*reader_or); std::vector<ZipReader::FileEntry> entries; while (reader->HasNext()) { auto entry_or = reader->Next(); - ASSERT_THAT(entry_or, IsOk()); + ABSL_ASSERT_OK(entry_or); entries.push_back(std::move(*entry_or)); } @@ -294,15 +293,15 @@ std::string pptx = GetTestDataPath("formats/zip/testing/no_c2pa_032_asset.pptx"); riegeli::CFileReader<> input(pptx); - ASSERT_THAT(input.status(), IsOk()); + ABSL_ASSERT_OK(input.status()); auto reader_or = ZipReader::Create(&input); - ASSERT_THAT(reader_or, IsOk()); + ABSL_ASSERT_OK(reader_or); auto reader = std::move(*reader_or); std::vector<ZipReader::FileEntry> entries; while (reader->HasNext()) { auto entry_or = reader->Next(); - ASSERT_THAT(entry_or, IsOk()); + ABSL_ASSERT_OK(entry_or); entries.push_back(std::move(*entry_or)); } @@ -320,7 +319,7 @@ std::string empty_zip_file_contents(kEmptyZipFile, sizeof(kEmptyZipFile)); riegeli::StringReader<> input(empty_zip_file_contents); auto reader_or = ZipReader::Create(&input); - ASSERT_THAT(reader_or, IsOk()); + ABSL_ASSERT_OK(reader_or); auto reader = std::move(*reader_or); EXPECT_EQ(reader->central_directory_offset(), 0); @@ -329,9 +328,9 @@ TEST(ZipReaderTest, CentralDirectoryOffset) { std::string zip = GetTestDataPath("formats/zip/testing/asset_no_c2pa.zip"); riegeli::CFileReader<> input(zip); - ASSERT_THAT(input.status(), IsOk()); + ABSL_ASSERT_OK(input.status()); auto reader_or = ZipReader::Create(&input); - ASSERT_THAT(reader_or, IsOk()); + ABSL_ASSERT_OK(reader_or); auto reader = std::move(*reader_or); EXPECT_EQ(reader->central_directory_offset(), 139); @@ -775,13 +774,13 @@ eocd_offset); auto reader_or = ZipReader::Create(&input); - ASSERT_THAT(reader_or, IsOk()); + ABSL_ASSERT_OK(reader_or); auto reader = std::move(*reader_or); std::vector<ZipReader::FileEntry> entries; while (reader->HasNext()) { auto entry_or = reader->Next(); - ASSERT_THAT(entry_or, IsOk()); + ABSL_ASSERT_OK(entry_or); entries.push_back(std::move(*entry_or)); }
diff --git a/formats/zip/writer.cc b/formats/zip/writer.cc new file mode 100644 index 0000000..c349aad --- /dev/null +++ b/formats/zip/writer.cc
@@ -0,0 +1,121 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/zip/writer.h" + +#include <cstdint> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "formats/zip/embedder.h" +#include "formats/zip/hasher_data.h" +#include "formats/zip/reader.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +absl::Status ZipWriter::Write(GenerateManifestStoreRequest& request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination) { + if (request.has_hard_binding()) { + return absl::InvalidArgumentError( + "request must not include `hard_binding` (binding is computed by " + "ZipWriter)"); + } + + ABSL_ASSIGN_OR_RETURN(auto reader, ZipReader::Create(source)); + + std::vector<ZipReader::FileEntry> file_entries; + while (reader->HasNext()) { + ABSL_ASSIGN_OR_RETURN(auto entry, reader->Next()); + file_entries.push_back(entry); + } + + // The two-phase approach is required for ZIP-based assets: The sections to be + // hashed can't be finalized until the manifest store size is known and + // content types stream is updated. + + // Phase 1: Copy the asset to the destination, update the content types + // stream, compute preliminary hard binding, produce a manifest store + // template, and embed a zero-filled placeholder in the asset. + HashAlgorithm alg = HashAlgorithm::kSha256; + ABSL_ASSIGN_OR_RETURN( + *request.mutable_hard_binding()->mutable_collection_data_hash(), + CollectionDataHashTemplate(file_entries, alg)); + ABSL_ASSIGN_OR_RETURN(ManifestStoreTemplate manifest_store_template, + generator_.GenerateManifestStoreTemplate(request)); + constexpr int64_t kMaxManifestStoreSizeBytes = 100 * 1024 * 1024; // 100 MiB + if (manifest_store_template.manifest_store_size() <= 0 || + manifest_store_template.manifest_store_size() > + kMaxManifestStoreSizeBytes) { + return absl::InvalidArgumentError( + absl::StrCat("invalid manifest store template size: ", + manifest_store_template.manifest_store_size())); + } + + ZipEmbedder embedder; + ABSL_ASSIGN_OR_RETURN( + auto copy_and_allocate_result, + embedder.CopyAndAllocate(source, file_entries, + manifest_store_template.manifest_store_size(), + destination)); + + // Phase 2: Compute final hard binding based on the updated content, finalize + // the manifest store, and embed the final manifest store in the asset. + if (!destination->SupportsReadMode()) { + return absl::InvalidArgumentError( + "ZipWriter::Write requires a riegeli::Writer that supports read mode."); + } + riegeli::Reader* phase2_reader = destination->ReadMode(0); + if (phase2_reader == nullptr) { + return absl::InternalError("failed to switch to read mode"); + } + + ABSL_ASSIGN_OR_RETURN(auto phase2_zip_reader, + ZipReader::Create(phase2_reader)); + std::vector<ZipReader::FileEntry> phase2_file_entries; + while (phase2_zip_reader->HasNext()) { + ABSL_ASSIGN_OR_RETURN(auto entry, phase2_zip_reader->Next()); + phase2_file_entries.push_back(entry); + } + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + std::move(manifest_store_template); + ABSL_ASSIGN_OR_RETURN( + *finalize_request.mutable_hard_binding()->mutable_collection_data_hash(), + DigestCollectionDataHash( + phase2_reader, alg, phase2_file_entries, + copy_and_allocate_result.central_directory_offset)); + ABSL_ASSIGN_OR_RETURN(auto response, + generator_.FinalizeManifestStore(finalize_request)); + if (response.manifest_store().size() != + finalize_request.manifest_store_template().manifest_store_size()) { + return absl::InternalError("manifest store size does not match template"); + } + ABSL_RETURN_IF_ERROR(embedder.Embed(response.manifest_store(), + copy_and_allocate_result, destination)); + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/formats/zip/writer.h b/formats/zip/writer.h new file mode 100644 index 0000000..78b7a76 --- /dev/null +++ b/formats/zip/writer.h
@@ -0,0 +1,54 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ZIP_WRITER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_WRITER_H_ + +#include "absl/base/attributes.h" +#include "absl/base/nullability.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "generator/generator.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// Writer of C2PA manifest stores into ZIP-based assets. +class ZipWriter { + public: + // Creates a Zip writer. + // + // The generator must outlive the writer. + explicit ZipWriter(const ManifestStoreGenerator* absl_nonnull generator + ABSL_ATTRIBUTE_LIFETIME_BOUND) + : generator_(*ABSL_DIE_IF_NULL(generator)) {} + + // Generates a C2PA manifest store and embeds it into a copy of the given + // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the + // same underlying data. The request must not include `hard_binding`. + // Fails if C2PA metadata already exists. + absl::Status Write(GenerateManifestStoreRequest& request, + riegeli::Reader* absl_nonnull source, + riegeli::Writer* absl_nonnull destination); + + private: + const ManifestStoreGenerator& generator_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_WRITER_H_
diff --git a/formats/zip/writer_test.cc b/formats/zip/writer_test.cc new file mode 100644 index 0000000..9761ef3 --- /dev/null +++ b/formats/zip/writer_test.cc
@@ -0,0 +1,131 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/zip/writer.h" + +#include <memory> +#include <optional> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "formats/byte_range.h" +#include "formats/zip/extractor.h" +#include "generator/generator.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/generator.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_status.pb.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "testing/nonprod_generator.h" +#include "testing/nonprod_validator.h" +#include "testing/test_file_utils.h" +#include "validator/asset_validator.h" +#include "validator/result.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::AllOf; +using ::testing::Field; +using ::testing::Gt; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::Optional; + +class ZipWriterTest : public testing::Test { + protected: + void SetUp() override { + auto generator = CreateNonProdManifestStoreGenerator({}); + ABSL_ASSERT_OK(generator); + generator_ = *std::move(generator); + writer_ = std::make_unique<ZipWriter>(generator_.get()); + auto validator = CreateNonProdValidator({}); + ABSL_ASSERT_OK(validator); + validator_ = *std::move(validator); + } + std::unique_ptr<ManifestStoreGenerator> generator_; + std::unique_ptr<ZipWriter> writer_; + std::unique_ptr<AssetValidator> validator_; +}; + +TEST_F(ZipWriterTest, Write) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + + auto source = credentio_testing::GetFileReader( + "credentio/formats/zip/testing/" + "no_c2pa_032_asset.pptx"); + ABSL_ASSERT_OK(source); + + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + + ABSL_ASSERT_OK(writer_->Write(request, source->get(), &destination)); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader result_reader(result); + ABSL_ASSERT_OK(result_reader.status()); + + EXPECT_THAT(ZipExtractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)), + Field(&ByteRange::length, Gt(0)))))); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validation_result = validator_->Validate( + result_reader, + "application/" + "vnd.openxmlformats-officedocument.presentationml.presentation"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST_F(ZipWriterTest, WriteWithHardBindingFails) { + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + request.mutable_hard_binding(); + + absl::StatusOr<std::string> pptx_file_contents = + credentio_testing::GetContents( + "credentio/formats/zip/testing/" + "no_c2pa_032_asset.pptx"); + ABSL_ASSERT_OK(pptx_file_contents); + riegeli::StringReader source(*pptx_file_contents); + ABSL_ASSERT_OK(source.status()); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + EXPECT_THAT(writer_->Write(request, &source, &destination), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("must not include `hard_binding`"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/zip/zlib_util.cc b/formats/zip/zlib_util.cc new file mode 100644 index 0000000..f451ba0 --- /dev/null +++ b/formats/zip/zlib_util.cc
@@ -0,0 +1,58 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/zip/zlib_util.h" + +#include <cstddef> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "riegeli/base/maker.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/zlib/zlib_reader.h" +#include "riegeli/zlib/zlib_writer.h" + +namespace credentio { + +absl::StatusOr<std::string> Compress(absl::string_view data) { + std::string compressed_data; + riegeli::ZlibWriter<riegeli::StringWriter<>> writer( + riegeli::Maker(&compressed_data), + riegeli::ZlibWriterBase::Options() + .set_header(riegeli::ZlibWriterBase::Header::kRaw) + .set_compression_level(1)); + if (!writer.Write(data) || !writer.Close()) { + return absl::InternalError("Failed to compress data."); + } + return compressed_data; +} + +absl::StatusOr<std::string> Uncompress(absl::string_view data, + size_t max_uncompressed_size) { + riegeli::ZlibReader<riegeli::StringReader<>> reader( + riegeli::Maker(data), riegeli::ZlibReaderBase::Options().set_header( + riegeli::ZlibReaderBase::Header::kRaw)); + std::string uncompressed_data; + reader.Read(max_uncompressed_size, uncompressed_data); + if (reader.Pull() || !reader.Close()) { + return absl::InternalError("Failed to uncompress data."); + } + return uncompressed_data; +} + +} // namespace credentio
diff --git a/formats/zip/zlib_util.h b/formats/zip/zlib_util.h new file mode 100644 index 0000000..220e568 --- /dev/null +++ b/formats/zip/zlib_util.h
@@ -0,0 +1,44 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ZIP_ZLIB_UTIL_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_ZLIB_UTIL_H_ + +#include <cstddef> +#include <string> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" + +namespace credentio { + +// Compresses the given data using ZLib. +// +// NOTE: This function should be used for compressing small files, like +// `[Content_Types].xml`. It shouldn't be used for large data files. +absl::StatusOr<std::string> Compress(absl::string_view data); + +// Uncompresses the given data using ZLib. Returns an error if the uncompressed +// size exceeds `max_uncompressed_size` (default is 1 MiB). +// +// NOTE: This function should be used for uncompressing small files, like +// `[Content_Types].xml`. It shouldn't be used for large data files. +absl::StatusOr<std::string> Uncompress(absl::string_view data, + size_t max_uncompressed_size = 1024 * + 1024); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_ZLIB_UTIL_H_
diff --git a/formats/zip/zlib_util_test.cc b/formats/zip/zlib_util_test.cc new file mode 100644 index 0000000..e2ae4aa --- /dev/null +++ b/formats/zip/zlib_util_test.cc
@@ -0,0 +1,67 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "formats/zip/zlib_util.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +TEST(ZlibUtilTest, CompressUncompressE2E) { + std::string data = + "hello world! this is a test string to be compressed. hello world!"; + + auto compressed = Compress(data); + ABSL_ASSERT_OK(compressed); + auto uncompressed = Uncompress(*compressed); + ABSL_ASSERT_OK(uncompressed); + + EXPECT_EQ(*uncompressed, data); +} + +TEST(ZlibUtilTest, UncompressInvalidDataFailed) { + std::string data = "this is not a compressed string"; + + EXPECT_THAT(Uncompress(data), + absl_testing::StatusIs(absl::StatusCode::kInternal, + "Failed to uncompress data.")); +} + +TEST(ZlibUtilTest, UncompressLargePayloadFailed) { + std::string large_string(2 * 1024 * 1024, 'a'); + auto compressed = Compress(large_string); + ABSL_ASSERT_OK(compressed); + + // To prevent ZIP bomb attacks, uncompressing a payload exceeding the maximum + // allowed size will fail. + EXPECT_THAT(Uncompress(*compressed), + absl_testing::StatusIs(absl::StatusCode::kInternal, + "Failed to uncompress data.")); + + // Adjusting the maximum uncompressed size to a larger value will succeed. + auto uncompressed = + Uncompress(*compressed, /*max_uncompressed_size=*/3 * 1024 * 1024); + ABSL_ASSERT_OK(uncompressed); + EXPECT_EQ(*uncompressed, large_string); +} + +} // namespace +} // namespace credentio
diff --git a/generator/BUILD b/generator/BUILD new file mode 100644 index 0000000..79b6e83 --- /dev/null +++ b/generator/BUILD
@@ -0,0 +1,191 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "generator", + srcs = ["generator.cc"], + hdrs = ["generator.h"], + deps = [ + "//async:async_context", + "//proto:generator_cc_proto", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/synchronization", + ], +) + +cc_library( + name = "manifest_store_transformer", + hdrs = ["manifest_store_transformer.h"], + deps = [ + "//jumbf:box_builder", + "@abseil-cpp//absl/status", + ], +) + +cc_library( + name = "generator_impl", + srcs = ["generator_impl.cc"], + hdrs = ["generator_impl.h"], + deps = [ + ":generator", + ":manifest_store_transformer", + "//async:async_context", + "//cbor", + "//claim:generator", + "//claim:manifest_box", + "//claim:manifest_box_assembler", + "//claim:manifest_box_encoder", + "//claim:manifest_store_view", + "//cose:sig_structure", + "//proto:bmff_based_hash_assertion_cbor_proto", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:boxes_hash_assertion_cbor_proto", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:collection_data_hash_assertion_cbor_proto", + "//proto:collection_data_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cbor_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:generator_cc_proto", + "//utils:cbor_padding", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "generator_impl_test", + srcs = ["generator_impl_test.cc"], + deps = [ + ":generator", + ":generator_impl", + ":manifest_store_transformer", + "//assertion:assertion_encoder", + "//async:async_context", + "//claim:claim_cbor_encoder", + "//claim:generator", + "//claim:local_signer", + "//claim:manifest_box_assembler", + "//claim:signer", + "//crypto:algorithms", + "//crypto:crypto_read_handler", + "//crypto:crypto_write_handler", + "//crypto/default:default_crypto_read_handler", + "//crypto/default:hasher", + "//formats/bmff:hasher", + "//jumbf:box_builder", + "//jumbf:constants", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:collection_data_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:generator_cc_proto", + "//proto:generator_info_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:ingredient_validation_result_cc_proto", + "//proto:manifest_cc_proto", + "//proto:signature_info_cc_proto", + "//proto:validation_result_cc_proto", + "//testing:create_test_crypto_write_handler", + "//testing:manifest_store_test_utils", + "//testing:proto_test_utils", + "//uuid", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@googletest//:gtest_main", + "@protobuf//:differencer", + ], +) + +cc_library( + name = "generator_options", + hdrs = ["generator_options.h"], + deps = [ + ":manifest_store_transformer", + "//assertion:assertion_encoder", + "//claim:claim_cbor_encoder", + "//claim:generator", + "//claim:signer", + "//constants:spec_version", + "//crypto:hash", + "//crypto/default:hasher", + "//proto:generator_info_cc_proto", + "//uuid", + "@abseil-cpp//absl/base:nullability", + ], +) + +cc_library( + name = "generator_factory", + srcs = ["generator_factory.cc"], + hdrs = ["generator_factory.h"], + deps = [ + ":generator", + ":generator_impl", + ":generator_options", + "//assertion:assertion_encoder", + "//async:async_context", + "//claim:claim_cbor_encoder", + "//claim:generator", + "//claim:manifest_box_assembler", + "//claim:signer", + "//crypto:hash", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + ], +) + +cc_test( + name = "generator_factory_test", + srcs = ["generator_factory_test.cc"], + deps = [ + ":generator", + ":generator_factory", + ":generator_options", + "//async:async_context", + "//claim:generator", + "//claim:manifest_box", + "//claim:manifest_box_assembler", + "//claim:signer", + "//crypto:hash", + "//proto:generator_cc_proto", + "//proto:generator_info_cc_proto", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@googletest//:gtest_main", + ], +)
diff --git a/generator/generator.cc b/generator/generator.cc new file mode 100644 index 0000000..bc87eea --- /dev/null +++ b/generator/generator.cc
@@ -0,0 +1,69 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "generator/generator.h" + +#include <utility> + +#include "absl/status/statusor.h" +#include "absl/synchronization/notification.h" +#include "async/async_context.h" +#include "proto/generator.pb.h" + +namespace credentio { + +absl::StatusOr<GenerateManifestStoreResponse> +ManifestStoreGenerator::GenerateManifestStore( + GenerateManifestStoreRequest request) const { + absl::Notification done; + absl::StatusOr<GenerateManifestStoreResponse> result; + GenerateManifestStore(std::move(request), AsyncContext(), + [&](absl::StatusOr<GenerateManifestStoreResponse> r) { + result = std::move(r); + done.Notify(); + }); + done.WaitForNotification(); + return result; +} + +absl::StatusOr<ManifestStoreTemplate> +ManifestStoreGenerator::GenerateManifestStoreTemplate( + GenerateManifestStoreRequest request) const { + absl::Notification done; + absl::StatusOr<ManifestStoreTemplate> result; + GenerateManifestStoreTemplate(std::move(request), AsyncContext(), + [&](absl::StatusOr<ManifestStoreTemplate> r) { + result = std::move(r); + done.Notify(); + }); + done.WaitForNotification(); + return result; +} + +absl::StatusOr<FinalizeManifestStoreResponse> +ManifestStoreGenerator::FinalizeManifestStore( + FinalizeManifestStoreRequest request) const { + absl::Notification done; + absl::StatusOr<FinalizeManifestStoreResponse> result; + FinalizeManifestStore(std::move(request), AsyncContext(), + [&](absl::StatusOr<FinalizeManifestStoreResponse> r) { + result = std::move(r); + done.Notify(); + }); + done.WaitForNotification(); + return result; +} + +} // namespace credentio
diff --git a/generator/generator.h b/generator/generator.h new file mode 100644 index 0000000..a68861f --- /dev/null +++ b/generator/generator.h
@@ -0,0 +1,71 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_H_ +#define THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_H_ + +#include "absl/functional/any_invocable.h" +#include "absl/status/statusor.h" +#include "async/async_context.h" +#include "proto/generator.pb.h" + +namespace credentio { +// A generator for creating a C2PA Manifest Store for an asset. +// +// This is sometimes called the "bare metal" generation API, to distinguish it +// from "container-level" APIs that operate on a complete media file. +class ManifestStoreGenerator { + public: + virtual ~ManifestStoreGenerator() = default; + // Generates a Manifest Store -- a collection of C2PA Manifests -- from the + // given request according to the C2PA specification, and returns the encoded + // bytes that can be embedded into an asset (e.g., an image, a video, etc.). + // The request must include a hard binding assertion. + virtual void GenerateManifestStore( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&> + cb) const = 0; + // Blocking version. + absl::StatusOr<GenerateManifestStoreResponse> GenerateManifestStore( + GenerateManifestStoreRequest request) const; + + // Generates a Manifest Store template for allocating space for the C2PA + // data. The request must include a hard binding assertion. Within the hard + // binding, the following may be placeholders (zero): + // - hash values + // - data hash exclusion byte offsets and lengths + // - multi-asset part location byte offsets and lengths + // Caller must provide the fully populated binding to FinalizeManifestStore + // once the asset is constructed (except for the manifest store). + virtual void GenerateManifestStoreTemplate( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb) + const = 0; + // Blocking version. + absl::StatusOr<ManifestStoreTemplate> GenerateManifestStoreTemplate( + GenerateManifestStoreRequest request) const; + + // Finalizes and returns the serialized manifest store. + virtual void FinalizeManifestStore( + FinalizeManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&> + cb) const = 0; + // Blocking version. + absl::StatusOr<FinalizeManifestStoreResponse> FinalizeManifestStore( + FinalizeManifestStoreRequest request) const; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_H_
diff --git a/generator/generator_factory.cc b/generator/generator_factory.cc new file mode 100644 index 0000000..b30e6d6 --- /dev/null +++ b/generator/generator_factory.cc
@@ -0,0 +1,163 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "generator/generator_factory.h" + +#include <memory> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "assertion/assertion_encoder.h" +#include "async/async_context.h" +#include "claim/claim_cbor_encoder.h" +#include "claim/generator.h" +#include "claim/manifest_box_assembler.h" +#include "claim/signer.h" +#include "crypto/hash.h" +#include "generator/generator.h" +#include "generator/generator_impl.h" +#include "generator/generator_options.h" + +namespace credentio { +namespace { +// A ManifestStoreGenerator implementation that routes generation requests +// between two underlying generators: +// - `signing_generator`: used for full manifest store generation and +// finalization with real signatures. +// - `template_generator`: used for manifest store template generation for size +// estimation. +// Also retains ownership of `template_hash_factory` to guarantee its lifetime. +class DualModeManifestStoreGenerator : public ManifestStoreGenerator { + public: + // Constructs a DualModeManifestStoreGenerator with the given signing and + // template generators, optionally retaining ownership of + // `template_hash_factory`. + DualModeManifestStoreGenerator( + std::unique_ptr<ManifestStoreGenerator> absl_nonnull signing_generator, + std::unique_ptr<ManifestStoreGenerator> absl_nonnull template_generator, + std::unique_ptr<HasherFactory> absl_nullable template_hash_factory = + nullptr) + : signing_generator_(std::move(signing_generator)), + template_hash_factory_(std::move(template_hash_factory)), + template_generator_(std::move(template_generator)) {} + + // Generates a manifest store using the signing generator. + void GenerateManifestStore( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&> + cb) const override { + signing_generator_->GenerateManifestStore( + std::move(request), std::move(context), std::move(cb)); + } + + // Generates a manifest store template for size estimation using the template + // generator. + void GenerateManifestStoreTemplate( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb) + const override { + template_generator_->GenerateManifestStoreTemplate( + std::move(request), std::move(context), std::move(cb)); + } + + // Finalizes the manifest store using the signing generator. + void FinalizeManifestStore( + FinalizeManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&> + cb) const override { + signing_generator_->FinalizeManifestStore( + std::move(request), std::move(context), std::move(cb)); + } + + private: + std::unique_ptr<ManifestStoreGenerator> signing_generator_; + // Must be declared before `template_generator_` so it outlives + // `template_generator_`. + std::unique_ptr<HasherFactory> template_hash_factory_; + std::unique_ptr<ManifestStoreGenerator> template_generator_; +}; + +// Helper function to create an underlying ManifestStoreGeneratorImpl with the +// given options, hash factory, and claim signer. +absl::StatusOr<std::unique_ptr<ManifestStoreGenerator>> CreateGenerator( + const GeneratorOptions& options, + const HasherFactory* absl_nonnull hash_factory, + ClaimSigner* absl_nonnull claim_signer) { + ABSL_ASSIGN_OR_RETURN(auto manifest_assembler, + options.manifest_assembler_factory({ + .uuid_generator = options.uuid_generator, + .hash_factory = hash_factory, + .spec_version = options.spec_version, + })); + ABSL_ASSIGN_OR_RETURN( + auto manifest_encoder, + CreateManifestEncoder({ + .generator_info = options.generator_info, + .claim_signer = claim_signer, + .assertion_cbor_encoder = options.assertion_cbor_encoder + ? options.assertion_cbor_encoder + : &GetAssertionEncoder(), + .claim_cbor_encoder = options.claim_cbor_encoder + ? options.claim_cbor_encoder + : &GetClaimCborEncoder(), + .hash_factory = hash_factory, + })); + return std::make_unique<ManifestStoreGeneratorImpl>( + std::move(manifest_assembler), std::move(manifest_encoder), + options.manifest_store_transformer); +} + +// Validates that required options are provided. +absl::Status ValidateOptions(const GeneratorOptions& options) { + if (options.signing_claim_signer == nullptr) { + return absl::InvalidArgumentError("signing_claim_signer is required"); + } + if (options.template_claim_signer == nullptr) { + return absl::InvalidArgumentError("template_claim_signer is required"); + } + if (options.uuid_generator == nullptr) { + return absl::InvalidArgumentError("uuid_generator is required"); + } + if (options.hash_factory == nullptr) { + return absl::InvalidArgumentError("hash_factory is required"); + } + if (options.manifest_assembler_factory == nullptr) { + return absl::InvalidArgumentError("manifest_assembler_factory is required"); + } + return absl::OkStatus(); +} +} // namespace + +absl::StatusOr<std::unique_ptr<ManifestStoreGenerator>> CreateGenerator( + GeneratorOptions options) { + ABSL_RETURN_IF_ERROR(ValidateOptions(options)); + auto template_hash_factory = std::make_unique<TemplatedHasherFactory>( + options.hash_factory->algorithm()); + ABSL_ASSIGN_OR_RETURN(auto signing_generator, + CreateGenerator(options, options.hash_factory, + options.signing_claim_signer)); + ABSL_ASSIGN_OR_RETURN(auto template_generator, + CreateGenerator(options, template_hash_factory.get(), + options.template_claim_signer)); + return std::make_unique<DualModeManifestStoreGenerator>( + std::move(signing_generator), std::move(template_generator), + std::move(template_hash_factory)); +} + +} // namespace credentio
diff --git a/generator/generator_factory.h b/generator/generator_factory.h new file mode 100644 index 0000000..22e3698 --- /dev/null +++ b/generator/generator_factory.h
@@ -0,0 +1,38 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_FACTORY_H_ +#define THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_FACTORY_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "generator/generator.h" +#include "generator/generator_options.h" + +namespace credentio { + +// Creates a ManifestStoreGenerator based on the given options. +// +// The returned generator delegates full manifest store generation and +// finalization to an internal generator using `options.signing_claim_signer`, +// and delegates manifest store template generation to an internal generator +// using `options.template_claim_signer`. +absl::StatusOr<std::unique_ptr<ManifestStoreGenerator>> CreateGenerator( + GeneratorOptions options); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_FACTORY_H_
diff --git a/generator/generator_factory_test.cc b/generator/generator_factory_test.cc new file mode 100644 index 0000000..7c8f85e --- /dev/null +++ b/generator/generator_factory_test.cc
@@ -0,0 +1,221 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "generator/generator_factory.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "async/async_context.h" +#include "claim/generator.h" +#include "claim/manifest_box.h" +#include "claim/manifest_box_assembler.h" +#include "claim/signer.h" +#include "crypto/hash.h" +#include "generator/generator.h" +#include "generator/generator_options.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/generator.pb.h" +#include "proto/generator_info.pb.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; +using ::testing::NotNull; + +class FakeClaimSigner : public ClaimSigner { + public: + void Sign(absl::string_view claim, Options options, + absl::AnyInvocable<void(absl::StatusOr<SignResult>) &&> cb) + const override { + std::move(cb)(SignResult{.signature = "fake_sig"}); + } +}; + +class TrackingManifestBoxAssembler : public ManifestBoxAssembler { + public: + explicit TrackingManifestBoxAssembler(std::string name, int* call_count) + : name_(std::move(name)), call_count_(call_count) {} + + absl::StatusOr<ManifestBox> Assemble( + const GenerateManifestStoreRequest& request) const override { + (*call_count_)++; + return absl::InternalError(name_); + } + + private: + std::string name_; + int* call_count_; +}; + +TEST(GeneratorFactoryTest, ValidateOptionsRejectsNullSigningClaimSigner) { + FakeClaimSigner template_signer; + GeneratorOptions options; + options.signing_claim_signer = nullptr; + options.template_claim_signer = &template_signer; + + EXPECT_THAT(CreateGenerator(std::move(options)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("signing_claim_signer is required"))); +} + +TEST(GeneratorFactoryTest, ValidateOptionsRejectsNullTemplateClaimSigner) { + FakeClaimSigner signing_signer; + GeneratorOptions options; + options.signing_claim_signer = &signing_signer; + options.template_claim_signer = nullptr; + + EXPECT_THAT(CreateGenerator(std::move(options)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("template_claim_signer is required"))); +} + +TEST(GeneratorFactoryTest, ValidateOptionsRejectsNullUuidGenerator) { + FakeClaimSigner signing_signer; + FakeClaimSigner template_signer; + GeneratorOptions options; + options.signing_claim_signer = &signing_signer; + options.template_claim_signer = &template_signer; + options.uuid_generator = nullptr; + + EXPECT_THAT(CreateGenerator(std::move(options)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("uuid_generator is required"))); +} + +TEST(GeneratorFactoryTest, ValidateOptionsRejectsNullHashFactory) { + FakeClaimSigner signing_signer; + FakeClaimSigner template_signer; + GeneratorOptions options; + options.signing_claim_signer = &signing_signer; + options.template_claim_signer = &template_signer; + options.hash_factory = nullptr; + + EXPECT_THAT(CreateGenerator(std::move(options)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("hash_factory is required"))); +} + +TEST(GeneratorFactoryTest, ValidateOptionsRejectsNullManifestAssemblerFactory) { + FakeClaimSigner signing_signer; + FakeClaimSigner template_signer; + GeneratorOptions options; + options.signing_claim_signer = &signing_signer; + options.template_claim_signer = &template_signer; + options.manifest_assembler_factory = nullptr; + + EXPECT_THAT(CreateGenerator(std::move(options)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("manifest_assembler_factory is required"))); +} + +TEST(GeneratorFactoryTest, CreateGeneratorSuccess) { + FakeClaimSigner signing_signer; + FakeClaimSigner template_signer; + GeneratorOptions options; + options.generator_info.set_name("TestGenerator"); + options.generator_info.set_spec_version("2.2.0"); + options.signing_claim_signer = &signing_signer; + options.template_claim_signer = &template_signer; + + auto generator_or = CreateGenerator(std::move(options)); + ABSL_ASSERT_OK(generator_or); + EXPECT_THAT(*generator_or, NotNull()); +} + +TEST(GeneratorFactoryTest, DualModeGeneratorDelegation) { + FakeClaimSigner signing_signer; + FakeClaimSigner template_signer; + int signing_assemble_count = 0; + int template_assemble_count = 0; + + GeneratorOptions options; + options.generator_info.set_name("TestGenerator"); + options.generator_info.set_spec_version("2.2.0"); + options.signing_claim_signer = &signing_signer; + options.template_claim_signer = &template_signer; + options.manifest_assembler_factory = + [&](const CreateManifestAssemblerOptions& opts) + -> absl::StatusOr<std::unique_ptr<ManifestBoxAssembler>> { + if (dynamic_cast<const TemplatedHasherFactory*>(opts.hash_factory) != + nullptr) { + return std::make_unique<TrackingManifestBoxAssembler>( + "template_assembler", &template_assemble_count); + } + return std::make_unique<TrackingManifestBoxAssembler>( + "signing_assembler", &signing_assemble_count); + }; + + auto generator_or = CreateGenerator(std::move(options)); + ABSL_ASSERT_OK(generator_or); + auto generator = std::move(*generator_or); + + // GenerateManifestStore should delegate to the signing generator. + GenerateManifestStoreRequest gen_request; + absl::Notification gen_done; + absl::Status gen_status; + generator->GenerateManifestStore( + gen_request, AsyncContext(), + [&](absl::StatusOr<GenerateManifestStoreResponse> res) { + gen_status = res.status(); + gen_done.Notify(); + }); + gen_done.WaitForNotification(); + EXPECT_THAT(gen_status, StatusIs(absl::StatusCode::kInternal, + HasSubstr("signing_assembler"))); + EXPECT_EQ(signing_assemble_count, 1); + EXPECT_EQ(template_assemble_count, 0); + + // GenerateManifestStoreTemplate should delegate to the template generator. + absl::Notification template_done; + absl::Status template_status; + generator->GenerateManifestStoreTemplate( + gen_request, AsyncContext(), + [&](absl::StatusOr<ManifestStoreTemplate> res) { + template_status = res.status(); + template_done.Notify(); + }); + template_done.WaitForNotification(); + EXPECT_THAT(template_status, StatusIs(absl::StatusCode::kInternal, + HasSubstr("template_assembler"))); + EXPECT_EQ(signing_assemble_count, 1); + EXPECT_EQ(template_assemble_count, 1); + + // FinalizeManifestStore should delegate to the signing generator. + FinalizeManifestStoreRequest fin_request; + absl::Notification fin_done; + absl::Status fin_status; + generator->FinalizeManifestStore( + fin_request, AsyncContext(), + [&](absl::StatusOr<FinalizeManifestStoreResponse> res) { + fin_status = res.status(); + fin_done.Notify(); + }); + fin_done.WaitForNotification(); + EXPECT_THAT(fin_status, StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("generator_context"))); +} + +} // namespace +} // namespace credentio
diff --git a/generator/generator_impl.cc b/generator/generator_impl.cc new file mode 100644 index 0000000..85f17c6 --- /dev/null +++ b/generator/generator_impl.cc
@@ -0,0 +1,368 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "generator/generator_impl.h" + +#include <cstddef> +#include <cstdint> +#include <memory> +#include <string> +#include <utility> + +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "async/async_context.h" +#include "cbor/options.h" +#include "claim/manifest_box.h" +#include "claim/manifest_box_encoder.h" +#include "claim/manifest_store_view.h" +#include "cose/sig_structure.h" +#include "generator/manifest_store_transformer.h" +#include "proto/bmff_based_hash_assertion.cbor.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/boxes_hash_assertion.cbor.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/collection_data_hash_assertion.cbor.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "proto/data_hash_assertion.cbor.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/generator.pb.h" +#include "utils/cbor_padding.h" + +namespace credentio { + +namespace { + +// Current default limit on manifest store size in our validator is 10 MiB. +// Some PNG parsing code elsewhere fails if the manifest store is > 7.6 MiB. +constexpr int64_t kDefaultMaxManifestStoreSizeBytes = 7 * 1024 * 1024; // 7 MiB + +template <typename T> +absl::StatusOr<size_t> GetCborSize(const T& proto, + const cbor::FromProtoOptions& options) { + ABSL_ASSIGN_OR_RETURN(auto cbor, cbor::FromProto(proto, options)); + return cbor->toString().size(); +} + +absl::Status CheckIfPartBindingByteSizesMatched( + const PartBinding& template_part_binding, + const PartBinding& finalize_part_binding, + const cbor::FromProtoOptions& proto_to_cbor_options) { + size_t template_size; + size_t finalize_size; + switch (template_part_binding.binding_case()) { + case PartBinding::kBmffBasedHash: { + ABSL_ASSIGN_OR_RETURN(template_size, + GetCborSize(template_part_binding.bmff_based_hash(), + proto_to_cbor_options)); + ABSL_ASSIGN_OR_RETURN(finalize_size, + GetCborSize(finalize_part_binding.bmff_based_hash(), + proto_to_cbor_options)); + break; + } + case PartBinding::kBoxHash: { + BoxesHashAssertion template_boxes_hash = template_part_binding.box_hash(); + BoxesHashAssertion finalize_boxes_hash = finalize_part_binding.box_hash(); + PadBoxesHashAssertion(template_boxes_hash); + PadBoxesHashAssertion(finalize_boxes_hash); + ABSL_ASSIGN_OR_RETURN(template_size, GetCborSize(template_boxes_hash, + proto_to_cbor_options)); + ABSL_ASSIGN_OR_RETURN(finalize_size, GetCborSize(finalize_boxes_hash, + proto_to_cbor_options)); + break; + } + case PartBinding::kDataHash: { + DataHashAssertion template_data_hash = template_part_binding.data_hash(); + DataHashAssertion finalize_data_hash = finalize_part_binding.data_hash(); + PadDataHashAssertion(template_data_hash); + PadDataHashAssertion(finalize_data_hash); + ABSL_ASSIGN_OR_RETURN(template_size, GetCborSize(template_data_hash, + proto_to_cbor_options)); + ABSL_ASSIGN_OR_RETURN(finalize_size, GetCborSize(finalize_data_hash, + proto_to_cbor_options)); + break; + } + case PartBinding::BINDING_NOT_SET: + return absl::InvalidArgumentError( + "A part binding in the finalize request must be set"); + } + if (template_size != finalize_size) { + return absl::InvalidArgumentError( + "The part binding differs from template in unsupported ways."); + } + return absl::OkStatus(); +} + +absl::Status CheckIfHardBindingByteSizesMatched( + const HardBinding& template_hard_binding, + const HardBinding& finalize_hard_binding, + const cbor::FromProtoOptions& proto_to_cbor_options) { + size_t template_size; + size_t finalize_size; + switch (template_hard_binding.binding_case()) { + case HardBinding::kBmffBasedHash: { + ABSL_ASSIGN_OR_RETURN(template_size, + GetCborSize(template_hard_binding.bmff_based_hash(), + proto_to_cbor_options)); + ABSL_ASSIGN_OR_RETURN(finalize_size, + GetCborSize(finalize_hard_binding.bmff_based_hash(), + proto_to_cbor_options)); + break; + } + case HardBinding::kBoxHash: { + BoxesHashAssertion template_boxes_hash = template_hard_binding.box_hash(); + BoxesHashAssertion finalize_boxes_hash = finalize_hard_binding.box_hash(); + PadBoxesHashAssertion(template_boxes_hash); + PadBoxesHashAssertion(finalize_boxes_hash); + ABSL_ASSIGN_OR_RETURN(template_size, GetCborSize(template_boxes_hash, + proto_to_cbor_options)); + ABSL_ASSIGN_OR_RETURN(finalize_size, GetCborSize(finalize_boxes_hash, + proto_to_cbor_options)); + break; + } + case HardBinding::kDataHash: { + DataHashAssertion template_data_hash = template_hard_binding.data_hash(); + DataHashAssertion finalize_data_hash = finalize_hard_binding.data_hash(); + PadDataHashAssertion(template_data_hash); + PadDataHashAssertion(finalize_data_hash); + ABSL_ASSIGN_OR_RETURN(template_size, GetCborSize(template_data_hash, + proto_to_cbor_options)); + ABSL_ASSIGN_OR_RETURN(finalize_size, GetCborSize(finalize_data_hash, + proto_to_cbor_options)); + break; + } + case HardBinding::kCollectionDataHash: { + ABSL_ASSIGN_OR_RETURN( + template_size, + GetCborSize(template_hard_binding.collection_data_hash(), + proto_to_cbor_options)); + ABSL_ASSIGN_OR_RETURN( + finalize_size, + GetCborSize(finalize_hard_binding.collection_data_hash(), + proto_to_cbor_options)); + break; + } + case HardBinding::BINDING_NOT_SET: + return absl::InvalidArgumentError( + "The hard binding in the finalize request must be set"); + } + if (template_size != finalize_size) { + return absl::InvalidArgumentError( + "The final hard binding differs from template in unsupported ways."); + } + + if (template_hard_binding.part_bindings_size() != + finalize_hard_binding.part_bindings_size()) { + return absl::InvalidArgumentError( + "The number of part bindings differs between template and finalize."); + } + + for (int i = 0; i < template_hard_binding.part_bindings_size(); ++i) { + ABSL_RETURN_IF_ERROR(CheckIfPartBindingByteSizesMatched( + template_hard_binding.part_bindings(i), + finalize_hard_binding.part_bindings(i), proto_to_cbor_options)); + } + return absl::OkStatus(); +} + +absl::StatusOr<bool> HasActiveClaimTimestamp(absl::string_view manifest_store) { + ABSL_ASSIGN_OR_RETURN(ManifestStoreView view, + ManifestStoreView::Create(manifest_store)); + ABSL_ASSIGN_OR_RETURN(ManifestView manifest, view.GetActiveManifest()); + ABSL_ASSIGN_OR_RETURN(ClaimSignatureView claim_signature, + manifest.GetClaimSignature()); + ABSL_ASSIGN_OR_RETURN(CoseSign1TaggedStructure cose_sign1, + claim_signature.GetStruct()); + return cose_sign1.unprotected_header.sig_tst2.has_value(); +} + +} // namespace + +void ManifestStoreGeneratorImpl::GenerateManifestStore( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&> + cb) const { + int64_t max_manifest_store_size_bytes = + request.options().max_manifest_store_size_bytes(); + if (max_manifest_store_size_bytes < 0) { + AsyncResolver<GenerateManifestStoreResponse>(std::move(context), + std::move(cb)) + .Return(absl::InvalidArgumentError( + "max_manifest_store_size_bytes must be non-negative")); + return; + } + if (request.options().has_max_bytes_to_shift() && + request.options().max_bytes_to_shift() < 0) { + AsyncResolver<GenerateManifestStoreResponse>(std::move(context), + std::move(cb)) + .Return(absl::InvalidArgumentError( + "max_bytes_to_shift must be non-negative")); + return; + } + if (max_manifest_store_size_bytes == 0) { + max_manifest_store_size_bytes = kDefaultMaxManifestStoreSizeBytes; + } + auto request_ptr = + std::make_shared<GenerateManifestStoreRequest>(std::move(request)); + auto manifest_or = manifest_assembler_->Assemble(*request_ptr); + if (!manifest_or.ok()) { + AsyncResolver<GenerateManifestStoreResponse>(std::move(context), + std::move(cb)) + .Return(manifest_or.status()); + return; + } + auto manifest_box = std::make_shared<ManifestBox>(std::move(*manifest_or)); + manifest_encoder_->Encode( + manifest_box.get(), request_ptr->options(), context, + [request_ptr, manifest_box, max_manifest_store_size_bytes, + manifest_store_transformer = manifest_store_transformer_, + cb = std::move(cb)]( + absl::StatusOr<ManifestEncoderResult> res_or) mutable { + if (!res_or.ok()) { + std::move(cb)(res_or.status()); + return; + } + absl::Cord encoded_store = res_or->box.Peek(); + absl::StatusOr<bool> is_timestamped = + HasActiveClaimTimestamp(encoded_store.Flatten()); + if (!is_timestamped.ok()) { + std::move(cb)(is_timestamped.status()); + return; + } + if (manifest_store_transformer != nullptr) { + absl::Status transform_status = + manifest_store_transformer->Transform(&res_or->box); + if (!transform_status.ok()) { + std::move(cb)(transform_status); + return; + } + } + absl::Cord manifest_store_cord = std::move(res_or->box).AsCord(); + if (manifest_store_cord.size() > + static_cast<uint64_t>(max_manifest_store_size_bytes)) { + std::move(cb)(absl::FailedPreconditionError(absl::Substitute( + "Generated manifest store too large ($0 > $1)", + manifest_store_cord.size(), max_manifest_store_size_bytes))); + return; + } + GenerateManifestStoreResponse response; + response.set_manifest_store(std::string(manifest_store_cord)); + response.set_is_timestamped(*is_timestamped); + std::move(cb)(std::move(response)); + }); +} + +void ManifestStoreGeneratorImpl::GenerateManifestStoreTemplate( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb) + const { + std::string generator_context = request.SerializeAsString(); + GenerateManifestStore( + std::move(request), context, + [generator_context = std::move(generator_context), cb = std::move(cb)]( + absl::StatusOr<GenerateManifestStoreResponse> generated_or) mutable { + if (!generated_or.ok()) { + std::move(cb)(generated_or.status()); + return; + } + ManifestStoreTemplate response; + response.set_generator_context(std::move(generator_context)); + response.set_manifest_store_size(generated_or->manifest_store().size()); + std::move(cb)(std::move(response)); + }); +} + +void ManifestStoreGeneratorImpl::FinalizeManifestStore( + FinalizeManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&> + cb) const { + if (request.manifest_store_template().generator_context().empty()) { + AsyncResolver<FinalizeManifestStoreResponse>(std::move(context), + std::move(cb)) + .Return(absl::InvalidArgumentError( + "The generator_context in the manifest store template is empty")); + return; + } + if (request.manifest_store_template().manifest_store_size() < 0) { + AsyncResolver<FinalizeManifestStoreResponse>(std::move(context), + std::move(cb)) + .Return(absl::InvalidArgumentError( + "manifest_store_size in template must be non-negative")); + return; + } + GenerateManifestStoreRequest generate_request; + if (!generate_request.ParseFromString( + request.manifest_store_template().generator_context())) { + AsyncResolver<FinalizeManifestStoreResponse>(std::move(context), + std::move(cb)) + .Return(absl::InvalidArgumentError( + "Failed to parse the generator_context in the manifest store " + "template")); + return; + } + if (generate_request.hard_binding().binding_case() != + request.hard_binding().binding_case()) { + AsyncResolver<FinalizeManifestStoreResponse>(std::move(context), + std::move(cb)) + .Return(absl::InvalidArgumentError( + "The hard binding in the finalize request must match the original " + "request")); + return; + } + absl::Status check_status = CheckIfHardBindingByteSizesMatched( + generate_request.hard_binding(), request.hard_binding(), + proto_to_cbor_options_); + if (!check_status.ok()) { + AsyncResolver<FinalizeManifestStoreResponse>(std::move(context), + std::move(cb)) + .Return(check_status); + return; + } + generate_request.mutable_hard_binding()->Swap(request.mutable_hard_binding()); + + size_t template_manifest_store_size = + request.manifest_store_template().manifest_store_size(); + GenerateManifestStore( + std::move(generate_request), context, + [template_manifest_store_size, cb = std::move(cb)]( + absl::StatusOr<GenerateManifestStoreResponse> generated_or) mutable { + if (!generated_or.ok()) { + std::move(cb)(generated_or.status()); + return; + } + if (generated_or->manifest_store().size() != + template_manifest_store_size) { + std::move(cb)(absl::InternalError(absl::Substitute( + "The generated manifest store size ($0) does not " + "match the estimated size ($1), even though the hard binding " + "sizes match. This is an internal error.", + generated_or->manifest_store().size(), + template_manifest_store_size))); + return; + } + FinalizeManifestStoreResponse response; + response.mutable_manifest_store()->swap( + *generated_or->mutable_manifest_store()); + response.set_is_timestamped(generated_or->is_timestamped()); + std::move(cb)(std::move(response)); + }); +} + +} // namespace credentio
diff --git a/generator/generator_impl.h b/generator/generator_impl.h new file mode 100644 index 0000000..671daec --- /dev/null +++ b/generator/generator_impl.h
@@ -0,0 +1,67 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_IMPL_H_ +#define THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_IMPL_H_ + +#include <memory> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/statusor.h" +#include "async/async_context.h" +#include "cbor/options.h" +#include "claim/generator.h" +#include "claim/manifest_box_assembler.h" +#include "generator/generator.h" +#include "generator/manifest_store_transformer.h" +#include "proto/generator.pb.h" + +namespace credentio { + +class ManifestStoreGeneratorImpl : public ManifestStoreGenerator { + public: + ManifestStoreGeneratorImpl( + std::unique_ptr<ManifestBoxAssembler> absl_nonnull manifest_assembler, + std::unique_ptr<ManifestEncoder> absl_nonnull manifest_encoder, + const ManifestStoreTransformer* absl_nullable manifest_store_transformer) + : manifest_assembler_(std::move(manifest_assembler)), + manifest_encoder_(std::move(manifest_encoder)), + manifest_store_transformer_(manifest_store_transformer) {} + void GenerateManifestStore( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&> + cb) const override; + void GenerateManifestStoreTemplate( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb) + const override; + void FinalizeManifestStore( + FinalizeManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&> + cb) const override; + + private: + std::unique_ptr<ManifestBoxAssembler> manifest_assembler_; + std::unique_ptr<ManifestEncoder> manifest_encoder_; + cbor::FromProtoOptions proto_to_cbor_options_; + const ManifestStoreTransformer* absl_nullable manifest_store_transformer_ = + nullptr; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_IMPL_H_
diff --git a/generator/generator_impl_test.cc b/generator/generator_impl_test.cc new file mode 100644 index 0000000..689d69c --- /dev/null +++ b/generator/generator_impl_test.cc
@@ -0,0 +1,1949 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "generator/generator_impl.h" + +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/absl_check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "assertion/assertion_encoder.h" +#include "async/async_context.h" +#include "claim/claim_cbor_encoder.h" +#include "claim/generator.h" +#include "claim/local_signer.h" +#include "claim/manifest_box_assembler.h" +#include "claim/signer.h" +#include "crypto/algorithms.h" +#include "crypto/crypto_read_handler.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/default/default_crypto_read_handler.h" +#include "crypto/default/hasher.h" +#include "formats/bmff/hasher.h" +#include "generator/generator.h" +#include "generator/manifest_store_transformer.h" +#include "gmock/gmock.h" +#include "google/protobuf/util/message_differencer.h" +#include "gtest/gtest.h" +#include "jumbf/box_builder.h" +#include "jumbf/constants.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/generator.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/manifest.pb.h" +#include "proto/signature_info.pb.h" +#include "proto/validation_result.pb.h" +#include "testing/create_test_crypto_write_handler.h" +#include "testing/manifest_store_test_utils.h" +#include "testing/proto_test_utils.h" +#include "uuid/uuid.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::HasSubstr; +using ::testing::Not; + +MATCHER_P(EqualsProtoIgnoringTimestampAndOrdering, expected, "") { + google::protobuf::util::MessageDifferencer differencer; + differencer.set_repeated_field_comparison( + google::protobuf::util::MessageDifferencer::AS_SET); + differencer.IgnoreField( + SignatureInfo::descriptor()->FindFieldByName("timestamping_time")); + std::string diff; + differencer.ReportDifferencesToString(&diff); + bool match = differencer.Compare(expected, arg); + if (!match) { + *result_listener << "\nDifference:\n" << diff; + } + return match; +} + +MATCHER_P(EqualsProtoIgnoringHashTimestampAndOrdering, expected, "") { + google::protobuf::util::MessageDifferencer differencer; + differencer.set_repeated_field_comparison( + google::protobuf::util::MessageDifferencer::AS_SET); + differencer.IgnoreField(HashedUri::descriptor()->FindFieldByName("hash")); + differencer.IgnoreField( + SignatureInfo::descriptor()->FindFieldByName("timestamping_time")); + std::string diff; + differencer.ReportDifferencesToString(&diff); + bool match = differencer.Compare(expected, arg); + if (!match) { + *result_listener << "\nDifference:\n" << diff; + } + return match; +} + +absl::StatusOr<std::unique_ptr<CryptoReadHandler>> +CreateTrustAnyoneCryptoReadHandlerForTest() { + return CreateDefaultCryptoReadHandler({ + .trust_environment = CryptoReadHandler::TrustEnvironment::kAny, + }); +} + +class MockManifestStoreTransformer : public ManifestStoreTransformer { + public: + MOCK_METHOD(absl::Status, Transform, (jumbf::BuiltSuperBox * box), + (const, override)); +}; + +constexpr Uuid kUuid = + Uuid::FromStringOrDie("03020100-0504-0706-0908-0f0e0d0c0b0a"); + +constexpr Uuid kUuid_2 = + Uuid::FromStringOrDie("12345678-1234-1234-1234-123456789012"); + +class FakeUuidGenerator : public UuidGenerator { + public: + FakeUuidGenerator() : uuids_({kUuid, kUuid_2}) {} + + Uuid Generate() const override { + if (index_ >= uuids_.size()) { + index_ = 0; + } + return uuids_[index_++]; + } + + private: + std::vector<Uuid> uuids_; + mutable int index_ = 0; +}; + +absl::StatusOr<GenerateManifestStoreResponse> GenerateManifestStore( + ManifestStoreGenerator& generator, GenerateManifestStoreRequest request) { + absl::Notification done; + absl::StatusOr<GenerateManifestStoreResponse> result; + generator.GenerateManifestStore( + std::move(request), AsyncContext(), + [&result, &done](absl::StatusOr<GenerateManifestStoreResponse> res) { + result = std::move(res); + done.Notify(); + }); + done.WaitForNotification(); + return result; +} + +absl::StatusOr<ManifestStoreTemplate> GenerateManifestStoreTemplate( + ManifestStoreGenerator& generator, GenerateManifestStoreRequest request) { + absl::Notification done; + absl::StatusOr<ManifestStoreTemplate> result; + generator.GenerateManifestStoreTemplate( + std::move(request), AsyncContext(), + [&result, &done](absl::StatusOr<ManifestStoreTemplate> res) { + result = std::move(res); + done.Notify(); + }); + done.WaitForNotification(); + return result; +} + +absl::StatusOr<FinalizeManifestStoreResponse> FinalizeManifestStore( + ManifestStoreGenerator& generator, FinalizeManifestStoreRequest request) { + absl::Notification done; + absl::StatusOr<FinalizeManifestStoreResponse> result; + generator.FinalizeManifestStore( + std::move(request), AsyncContext(), + [&result, &done](absl::StatusOr<FinalizeManifestStoreResponse> res) { + result = std::move(res); + done.Notify(); + }); + done.WaitForNotification(); + return result; +} + +class ManifestStoreGeneratorTest : public testing::Test { + protected: + ManifestStoreGeneratorTest() { + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> crypto_writer = + CreateTestCryptoWriteHandler({}); + ABSL_CHECK_OK(crypto_writer); + crypto_writer_ = std::move(*crypto_writer); + + generator_info_.set_name("Test generator"); + generator_info_.set_spec_version("2.2.0"); + absl::StatusOr<std::unique_ptr<CryptoReadHandler>> crypto_reader = + CreateTrustAnyoneCryptoReadHandlerForTest(); + ABSL_CHECK_OK(crypto_reader); + crypto_reader_ = std::move(*crypto_reader); + claim_signer_ = std::make_unique<LocalClaimSigner>(crypto_writer_.get(), + &hash_factory_); + + store_generator_ = CreateGenerator(); + } + + std::unique_ptr<ManifestStoreGenerator> CreateGenerator( + const ManifestStoreTransformer* manifest_store_transformer = nullptr) { + absl::StatusOr<std::unique_ptr<ManifestBoxAssembler>> manifest_assembler = + CreateManifestAssembler({.uuid_generator = &uuid_generator_, + .hash_factory = &hash_factory_}); + ABSL_CHECK_OK(manifest_assembler); + + absl::StatusOr<std::unique_ptr<ManifestEncoder>> manifest_encoder = + CreateManifestEncoder({ + .generator_info = generator_info_, + .claim_signer = claim_signer_.get(), + .assertion_cbor_encoder = &GetAssertionEncoder(), + .claim_cbor_encoder = &GetClaimCborEncoder(), + .hash_factory = &hash_factory_, + }); + ABSL_CHECK_OK(manifest_encoder); + + return std::make_unique<ManifestStoreGeneratorImpl>( + std::move(*manifest_assembler), std::move(*manifest_encoder), + manifest_store_transformer); + } + + FakeUuidGenerator uuid_generator_; + std::unique_ptr<ClaimSigner> claim_signer_; + std::unique_ptr<CryptoWriteHandler> crypto_writer_; + std::unique_ptr<CryptoReadHandler> crypto_reader_; + Sha256HasherFactory hash_factory_; + GeneratorInfo generator_info_; + std::unique_ptr<ManifestStoreGenerator> store_generator_; +}; + +TEST_F(ManifestStoreGeneratorTest, Generate) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + instance_id: "12345678-1234-1234-1234-123456789012" + hard_binding { + box_hash { + alg: "some alg" + boxes { names: "some name" names: "other name" hash: "some hash" } + } + } + actions { + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"); + PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie( + R"pb( + active_manifest { + label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + claim { + instance_id: "12345678-1234-1234-1234-123456789012" + claim_generator_info { + name: "Test generator" + spec_version: "2.2.0" + } + signature_info { + cert_serial_number: "02" + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA" + timestamping_time {} + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.boxes" + algorithm: "sha256" + hash: ")\255`[2\265\253\340\256(\265I\216_\222m\302@V\255O\204\017\316bb2\201\317\243\233\204" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\331\271\373\3228\310\335>y\263\334\342\335\367\211Q\360\325\227\r\345$]\214\005\3047[\r#1\312" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + } + assertions { + label: "c2pa.hash.boxes" + boxes_hash { + boxes { names: "some name" names: "other name" hash: "some hash" } + alg: "some alg" + } + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + software_agent { name: "test generator" } + } + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.boxes" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.boxes" + )pb"); + absl::StatusOr<GenerateManifestStoreResponse> generated = + GenerateManifestStore(*store_generator_, request); + ABSL_ASSERT_OK(generated); + EXPECT_TRUE(generated->is_timestamped()); + absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> validated = + ValidateManifestStore(generated->manifest_store()); + ABSL_ASSERT_OK(validated); + EXPECT_THAT(**validated, EqualsProtoIgnoringTimestampAndOrdering( + expected_validated_result)); +} + +TEST_F(ManifestStoreGeneratorTest, GenerateWithGatheredAssertions) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + instance_id: "12345678-1234-1234-1234-123456789012" + hard_binding { + box_hash { + alg: "some alg" + boxes { names: "some name" names: "other name" hash: "some hash" } + } + } + actions { + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + gathered_assertions { + label: "com.example.gathered" + data: "{\"test_key\": \"test_val\"}" + content_type: CONTENT_TYPE_JSON + } + )pb"); + absl::StatusOr<GenerateManifestStoreResponse> generated = + GenerateManifestStore(*store_generator_, request); + ABSL_ASSERT_OK(generated); + EXPECT_TRUE(generated->is_timestamped()); + absl::StatusOr<Claim> claim = ValidateClaim(generated->manifest_store()); + ABSL_ASSERT_OK(claim); + ASSERT_EQ(claim->gathered_assertions_size(), 1); + EXPECT_EQ(claim->gathered_assertions(0).url(), + "self#jumbf=c2pa.assertions/com.example.gathered"); +} + +TEST_F(ManifestStoreGeneratorTest, + GenerateTemplateAndFinalizeWithGatheredAssertions) { + DataHashAssertion data_hash; + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad(""); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_data_hash() = data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + GatheredAssertion* gathered = request.add_gathered_assertions(); + gathered->set_label("com.example.gathered"); + gathered->set_data("{\"test_key\": \"test_val\"}"); + gathered->set_content_type(GatheredAssertion::CONTENT_TYPE_JSON); + + // Step 1: Generate template. + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + + // Step 2: Update hash. + std::string new_hash(HashSizeBytes(HashAlgorithm::kSha256), '\1'); + data_hash.set_hash(new_hash); + + // Step 3: Finalize manifest store. + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_data_hash() = data_hash; + absl::StatusOr<FinalizeManifestStoreResponse> finalized = + FinalizeManifestStore(*store_generator_, finalize_request); + ABSL_ASSERT_OK(finalized); + EXPECT_TRUE(finalized->is_timestamped()); + + EXPECT_EQ(manifest_store_template->manifest_store_size(), + finalized->manifest_store().size()); + absl::StatusOr<Claim> claim = ValidateClaim(finalized->manifest_store()); + ABSL_ASSERT_OK(claim); + ASSERT_EQ(claim->gathered_assertions_size(), 1); + EXPECT_EQ(claim->gathered_assertions(0).url(), + "self#jumbf=c2pa.assertions/com.example.gathered"); +} + +TEST_F(ManifestStoreGeneratorTest, GenerateWithC2paOpenedAction) { + SimpleManifestStoreOptions options; + options.uuid_generator = &uuid_generator_; + absl::StatusOr<std::string> parent_manifest_store_bytes = + CreateSimpleManifestStoreBytes(options); + ABSL_ASSERT_OK(parent_manifest_store_bytes); + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + instance_id: "12345678-1234-1234-1234-123456789012" + hard_binding { + box_hash { + alg: "some alg" + boxes { names: "some name" names: "other name" hash: "some hash" } + } + } + actions { + opened { + action_metadata { + software_agent { name: "test generator" } + description: "opened for testing" + } + ingredient_index: 0 + } + } + ingredients { + metadata { title: "test ingredient" } + ingredient_validation_results { + manifest_store_bytes: "" + ingredient_validation_results { + active_manifest { successes { code: "assertion.dataHash.match" } } + } + } + } + )pb"); + request.mutable_ingredients(0) + ->mutable_ingredient_validation_results() + ->set_manifest_store_bytes(*parent_manifest_store_bytes); + PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie( + R"pb( + active_manifest { + label: "urn:c2pa:12345678-1234-1234-1234-123456789012" + claim { + claim_generator_info { + name: "Test generator" + spec_version: "2.2.0" + } + signature_info { + cert_serial_number: "02" + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA" + timestamping_time {} + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.boxes" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "12345678-1234-1234-1234-123456789012" + } + assertions { + label: "c2pa.hash.boxes" + boxes_hash { + boxes { names: "some name" names: "other name" hash: "some hash" } + alg: "some alg" + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "test ingredient" + relationship: "parentOf" + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + validation_results { + active_manifest { + successes { code: "assertion.dataHash.match" } + } + } + } + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + description: "opened for testing" + software_agent { name: "test generator" } + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.hash.boxes" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.ingredient.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.actions.v2" + } + successes { + code: "ingredient.claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.ingredient.v3" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature" + } + } + } + ingredient_manifests { + label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + claim { + claim_generator_info { name: "Test C2PA Generator" } + signature_info { + cert_serial_number: "02" + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA" + timestamping_time {} + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.boxes" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "12345678-1234-1234-1234-123456789012" + } + assertions { + label: "c2pa.hash.boxes" + boxes_hash { + boxes { names: "some name" names: "other name" hash: "some hash" } + alg: "some alg" + } + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + software_agent { name: "test generator" } + } + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.boxes" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.hash.boxes" + )pb"); + absl::StatusOr<GenerateManifestStoreResponse> generated = + GenerateManifestStore(*store_generator_, request); + ABSL_ASSERT_OK(generated); + absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> validated = + ValidateManifestStore(generated->manifest_store()); + ABSL_ASSERT_OK(validated); + EXPECT_THAT(**validated, EqualsProtoIgnoringHashTimestampAndOrdering( + expected_validated_result)); +} + +TEST_F(ManifestStoreGeneratorTest, + GenerateC2paOpenedActionMissingManifestStoreBytes) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + instance_id: "03020100-0504-0706-0908-0f0e0d0c0b0a" + hard_binding { + box_hash { + alg: "some alg" + boxes { names: "some name" names: "other name" hash: "some hash" } + } + } + actions { + opened { + action_metadata { + software_agent { name: "test generator" } + description: "opened for testing" + } + ingredient_index: 0 + } + } + ingredients { + metadata { title: "test ingredient" } + ingredient_validation_results {} + } + )pb"); + PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie( + R"pb( + active_manifest { + label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a" + claim { + claim_generator_info { + name: "Test generator" + spec_version: "2.2.0" + } + signature_info { + cert_serial_number: "02" + issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer" + certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA" + timestamping_time {} + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.boxes" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + instance_id: "03020100-0504-0706-0908-0f0e0d0c0b0a" + } + assertions { + label: "c2pa.hash.boxes" + boxes_hash { + boxes { names: "some name" names: "other name" hash: "some hash" } + alg: "some alg" + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { title: "test ingredient" relationship: "parentOf" } + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + description: "opened for testing" + software_agent { name: "test generator" } + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + validation { + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.insideValidity" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.boxes" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature" + } + informationals { + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.boxes" + )pb"); + absl::StatusOr<GenerateManifestStoreResponse> generated = + GenerateManifestStore(*store_generator_, request); + ABSL_ASSERT_OK(generated); + absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> validated = + ValidateManifestStore(generated->manifest_store()); + ABSL_ASSERT_OK(validated); + EXPECT_THAT(**validated, EqualsProtoIgnoringHashTimestampAndOrdering( + expected_validated_result)); +} + +TEST_F(ManifestStoreGeneratorTest, + GenerateC2paOpenedActionMissingValidationResultsInvalid) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { + box_hash { + alg: "some alg" + boxes { names: "some name" names: "other name" hash: "some hash" } + } + } + actions { + opened { + action_metadata { + software_agent { name: "test generator" } + description: "opened for testing" + } + ingredient_index: 0 + } + } + ingredients { + metadata { title: "test ingredient" } + ingredient_validation_results { + manifest_store_bytes: "test manifest store bytes" + } + } + )pb"); + EXPECT_THAT(GenerateManifestStore(*store_generator_, request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("validation results cannot be empty"))); +} + +TEST_F(ManifestStoreGeneratorTest, GenerateMissingHardBindingInvalid) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + actions { + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"); + EXPECT_THAT( + GenerateManifestStore(*store_generator_, request), + StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("hard binding"))); +} + +TEST_F(ManifestStoreGeneratorTest, GenerateMissingStartActionInvalid) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + hard_binding { + box_hash { + alg: "some alg" + boxes { names: "some name" names: "other name" hash: "some hash" } + } + } + )pb"); + EXPECT_THAT(GenerateManifestStore(*store_generator_, request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("missing inception action"))); +} + +TEST_F(ManifestStoreGeneratorTest, GenerateTemplateWithBmffBasedHash) { + BmffBasedHashAssertion bmff_based_hash = + GetBmffHashTemplate(HashAlgorithm::kSha256); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_bmff_based_hash() = bmff_based_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + // Step 1: Generate template. + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + + // Step 2: Update hash. + std::string new_hash(HashSizeBytes(HashAlgorithm::kSha256), '\1'); + bmff_based_hash.set_hash(new_hash); + + // Step 3: Finalize manifest store. + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_bmff_based_hash() = + bmff_based_hash; + absl::StatusOr<FinalizeManifestStoreResponse> finalized = + FinalizeManifestStore(*store_generator_, finalize_request); + ABSL_ASSERT_OK(finalized); + EXPECT_TRUE(finalized->is_timestamped()); + + EXPECT_EQ(manifest_store_template->manifest_store_size(), + finalized->manifest_store().size()); + EXPECT_THAT(ValidateManifestStore(finalized->manifest_store()), IsOk()); + EXPECT_THAT(finalized->manifest_store(), HasSubstr(new_hash)); +}; + +TEST_F(ManifestStoreGeneratorTest, GenerateTemplateWithDataHash) { + DataHashAssertion data_hash; + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad(""); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_data_hash() = data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + // Step 1: Generate template. + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + + // Step 2: Update hash. + std::string new_hash(HashSizeBytes(HashAlgorithm::kSha256), '\1'); + data_hash.set_hash(new_hash); + + // Step 3: Finalize manifest store. + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_data_hash() = data_hash; + absl::StatusOr<FinalizeManifestStoreResponse> finalized = + FinalizeManifestStore(*store_generator_, finalize_request); + ABSL_ASSERT_OK(finalized); + EXPECT_TRUE(finalized->is_timestamped()); + + EXPECT_EQ(manifest_store_template->manifest_store_size(), + finalized->manifest_store().size()); + EXPECT_THAT(ValidateManifestStore(finalized->manifest_store()), IsOk()); + EXPECT_THAT(finalized->manifest_store(), HasSubstr(new_hash)); +}; + +TEST_F(ManifestStoreGeneratorTest, GenerateTemplateWithBoxesHash) { + BoxesHashAssertion box_hash; + BoxHash* box = box_hash.add_boxes(); + box->add_names("some name"); + box->set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_box_hash() = box_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + // Step 1: Generate template. + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + + // Step 2: Update boxes hash. + std::string new_hash(HashSizeBytes(HashAlgorithm::kSha256), '\1'); + box_hash.mutable_boxes(0)->set_hash(new_hash); + + // Step 3: Finalize manifest store. + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_box_hash() = box_hash; + absl::StatusOr<FinalizeManifestStoreResponse> finalized = + FinalizeManifestStore(*store_generator_, finalize_request); + ABSL_ASSERT_OK(finalized); + EXPECT_TRUE(finalized->is_timestamped()); + + EXPECT_EQ(manifest_store_template->manifest_store_size(), + finalized->manifest_store().size()); + EXPECT_THAT(ValidateManifestStore(finalized->manifest_store()), IsOk()); + EXPECT_THAT(finalized->manifest_store(), HasSubstr(new_hash)); +}; + +TEST_F(ManifestStoreGeneratorTest, GenerateTemplateWithCollectionDataHash) { + CollectionDataHashAssertion collection_data_hash; + collection_data_hash.set_zip_central_directory_hash( + std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + collection_data_hash.set_alg("sha256"); + UriHashedData* uri_hash = collection_data_hash.add_uris(); + uri_hash->set_uri("some uri"); + uri_hash->set_type("some type"); + uri_hash->set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_collection_data_hash() = + collection_data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + // Step 1: Generate template. + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + + // Step 2: Update hash. + std::string new_central_dir_hash(HashSizeBytes(HashAlgorithm::kSha256), '\1'); + collection_data_hash.set_zip_central_directory_hash(new_central_dir_hash); + std::string new_uri_hash(HashSizeBytes(HashAlgorithm::kSha256), '\2'); + collection_data_hash.mutable_uris(0)->set_hash(new_uri_hash); + + // Step 3: Finalize manifest store. + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_collection_data_hash() = + collection_data_hash; + absl::StatusOr<FinalizeManifestStoreResponse> finalized = + FinalizeManifestStore(*store_generator_, finalize_request); + ABSL_ASSERT_OK(finalized); + EXPECT_TRUE(finalized->is_timestamped()); + + EXPECT_EQ(manifest_store_template->manifest_store_size(), + finalized->manifest_store().size()); + EXPECT_THAT(ValidateManifestStore(finalized->manifest_store()), IsOk()); + EXPECT_THAT(finalized->manifest_store(), HasSubstr(new_central_dir_hash)); + EXPECT_THAT(finalized->manifest_store(), HasSubstr(new_uri_hash)); +}; + +TEST_F(ManifestStoreGeneratorTest, EmptyGeneratorContext) { + BmffBasedHashAssertion bmff_based_hash = + GetBmffHashTemplate(HashAlgorithm::kSha256); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_hard_binding()->mutable_bmff_based_hash() = + bmff_based_hash; + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("empty"))); +} + +TEST_F(ManifestStoreGeneratorTest, FailParsingGeneratorContext) { + BmffBasedHashAssertion bmff_based_hash = + GetBmffHashTemplate(HashAlgorithm::kSha256); + FinalizeManifestStoreRequest finalize_request; + finalize_request.mutable_manifest_store_template()->set_generator_context( + "invalid generator context"); + *finalize_request.mutable_hard_binding()->mutable_bmff_based_hash() = + bmff_based_hash; + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Failed to parse the generator_context"))); +} + +TEST_F(ManifestStoreGeneratorTest, HashBindingMissing) { + DataHashAssertion data_hash; + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad(""); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_data_hash() = data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + EXPECT_THAT( + FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("hard binding"))); +} + +TEST_F(ManifestStoreGeneratorTest, HashBindingMismatchDataHash) { + DataHashAssertion data_hash; + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad(""); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_data_hash() = data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_box_hash() = + ParseTextProtoOrDie(R"pb( + boxes { hash: "some hash" } + )pb"); + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("The hard binding in the finalize request " + "must match the original request"))); +} + +TEST_F(ManifestStoreGeneratorTest, HashBindingMismatchBoxesHash) { + BoxesHashAssertion box_hash; + BoxHash* box = box_hash.add_boxes(); + box->add_names("some name"); + box->set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_box_hash() = box_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_data_hash() = + ParseTextProtoOrDie(R"pb( + hash: "some hash" + )pb"); + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("The hard binding in the finalize request " + "must match the original request"))); +} + +TEST_F(ManifestStoreGeneratorTest, HashBindingMismatchBmffBasedHash) { + BmffBasedHashAssertion bmff_based_hash = + GetBmffHashTemplate(HashAlgorithm::kSha256); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_bmff_based_hash() = bmff_based_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_data_hash() = + ParseTextProtoOrDie(R"pb( + hash: "some hash" + )pb"); + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("The hard binding in the finalize request " + "must match the original request"))); +} + +TEST_F(ManifestStoreGeneratorTest, HashBindingMismatchCollectionDataHash) { + CollectionDataHashAssertion collection_data_hash = ParseTextProtoOrDie(R"pb( + uris { uri: "some uri" hash: "some hash" type: "some type" } + alg: "sha256" + zip_central_directory_hash: "some hash" + )pb"); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_collection_data_hash() = + collection_data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_data_hash() = + ParseTextProtoOrDie(R"pb( + hash: "some hash" + )pb"); + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("The hard binding in the finalize request " + "must match the original request"))); +} + +TEST_F(ManifestStoreGeneratorTest, DataHashByteSizeAlwaysMatch) { + DataHashAssertion data_hash; + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad(""); + DataRange* exclusion = data_hash.add_exclusions(); + exclusion->set_start(0); + exclusion->set_length(0); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_data_hash() = data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + data_hash.set_pad("additional padding"); + data_hash.mutable_exclusions(0)->set_start(10); + data_hash.mutable_exclusions(0)->set_length(4294967296); + *finalize_request.mutable_hard_binding()->mutable_data_hash() = data_hash; + absl::StatusOr<FinalizeManifestStoreResponse> generated = + FinalizeManifestStore(*store_generator_, finalize_request); + ABSL_ASSERT_OK(generated); + EXPECT_THAT(generated->manifest_store(), + Not(HasSubstr("additional padding"))); +} + +TEST_F(ManifestStoreGeneratorTest, DataHashByteSizeMismatchDueToDifferentName) { + DataHashAssertion data_hash; + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad(""); + DataRange* exclusion = data_hash.add_exclusions(); + exclusion->set_start(0); + exclusion->set_length(0); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_data_hash() = data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + data_hash.clear_pad(); // Padding should add it back. + data_hash.mutable_exclusions(0)->set_start(10); + data_hash.mutable_exclusions(0)->set_length(4294967296); + data_hash.set_name("additional name"); + *finalize_request.mutable_hard_binding()->mutable_data_hash() = data_hash; + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("The final hard binding differs from template " + "in unsupported ways"))); +} + +TEST_F(ManifestStoreGeneratorTest, BoxesHashByteSizeMismatch) { + BoxesHashAssertion box_hash; + BoxHash* box = box_hash.add_boxes(); + box->add_names("some name"); + box->set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_box_hash() = box_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + box_hash.mutable_boxes(0)->add_names("additional name"); + *finalize_request.mutable_hard_binding()->mutable_box_hash() = box_hash; + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("The final hard binding differs from template " + "in unsupported ways"))); +} + +TEST_F(ManifestStoreGeneratorTest, BmffBasedHashByteSizeMismatch) { + BmffBasedHashAssertion bmff_based_hash = + GetBmffHashTemplate(HashAlgorithm::kSha256); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_bmff_based_hash() = bmff_based_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + bmff_based_hash.add_exclusions()->set_xpath("additional exclusion"); + *finalize_request.mutable_hard_binding()->mutable_bmff_based_hash() = + bmff_based_hash; + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("The final hard binding differs from template " + "in unsupported ways"))); +} + +TEST_F(ManifestStoreGeneratorTest, + CollectionDataHashByteSizeMismatchDueToDifferentUriLength) { + CollectionDataHashAssertion collection_data_hash = ParseTextProtoOrDie(R"pb( + uris { uri: "some uri" hash: "some hash" type: "some type" } + alg: "sha256" + zip_central_directory_hash: "some hash" + )pb"); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_collection_data_hash() = + collection_data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + // The URI is different in length. + collection_data_hash.mutable_uris(0)->set_uri("updated uri"); + *finalize_request.mutable_hard_binding()->mutable_collection_data_hash() = + collection_data_hash; + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("The final hard binding differs from template " + "in unsupported ways"))); +} + +TEST_F(ManifestStoreGeneratorTest, + CollectionDataHashByteSizeMismatchDueToDifferentUriType) { + CollectionDataHashAssertion collection_data_hash = ParseTextProtoOrDie(R"pb( + uris { uri: "some uri" hash: "some hash" type: "some type" } + alg: "sha256" + zip_central_directory_hash: "some hash" + )pb"); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_collection_data_hash() = + collection_data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + collection_data_hash.mutable_uris(0)->set_type("updated type"); + *finalize_request.mutable_hard_binding()->mutable_collection_data_hash() = + collection_data_hash; + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("The final hard binding differs from template " + "in unsupported ways"))); +} + +TEST_F(ManifestStoreGeneratorTest, + CollectionDataHashByteSizeMismatchDueToDifferentUriCount) { + CollectionDataHashAssertion collection_data_hash = ParseTextProtoOrDie(R"pb( + uris { uri: "some uri" hash: "some hash" type: "some type" } + alg: "sha256" + zip_central_directory_hash: "some hash" + )pb"); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_collection_data_hash() = + collection_data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + UriHashedData* new_uri = collection_data_hash.add_uris(); + new_uri->set_uri("new uri"); + new_uri->set_hash("new hash"); + new_uri->set_type("new type"); + *finalize_request.mutable_hard_binding()->mutable_collection_data_hash() = + collection_data_hash; + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("The final hard binding differs from template " + "in unsupported ways"))); +} + +TEST_F(ManifestStoreGeneratorTest, PartBindingByteSizeMismatch) { + DataHashAssertion data_hash; + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad(""); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_data_hash() = data_hash; + *request.mutable_hard_binding()->add_part_bindings()->mutable_data_hash() = + data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + data_hash.set_name("additional name"); + *finalize_request.mutable_hard_binding()->mutable_data_hash() = + request.hard_binding().data_hash(); + *finalize_request.mutable_hard_binding() + ->add_part_bindings() + ->mutable_data_hash() = data_hash; + EXPECT_THAT( + FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr( + "The part binding differs from template in unsupported ways."))); +} + +TEST_F(ManifestStoreGeneratorTest, PartBindingCountMismatch) { + DataHashAssertion data_hash; + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad(""); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_data_hash() = data_hash; + *request.mutable_hard_binding()->add_part_bindings()->mutable_data_hash() = + data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_data_hash() = + request.hard_binding().data_hash(); + // Different number of part_bindings. + EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("The number of part bindings differs between " + "template and finalize."))); +} + +TEST_F(ManifestStoreGeneratorTest, HardBindingNotSet) { + // Create a template with no hard binding in the generate request. + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + actions { + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"); + // Generate a template (this will fail as hard_binding is required). + EXPECT_THAT( + GenerateManifestStoreTemplate(*store_generator_, request), + StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("hard binding"))); + + // To test the BINDING_NOT_SET case in FinalizeManifestStore, we need to + // manually create a generator_context with no hard binding. + GenerateManifestStoreRequest template_request = request; + template_request.clear_hard_binding(); + FinalizeManifestStoreRequest finalize_request; + finalize_request.mutable_manifest_store_template()->set_generator_context( + template_request.SerializeAsString()); + *finalize_request.mutable_hard_binding() = HardBinding(); + EXPECT_THAT( + FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr("The hard binding in the finalize request must be set"))); +} + +TEST_F(ManifestStoreGeneratorTest, ManifestStoreSizeLimitExceeded) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + instance_id: "12345678-1234-1234-1234-123456789012" + hard_binding { + box_hash { + alg: "some alg" + boxes { names: "some name" names: "other name" hash: "some hash" } + } + } + actions { + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + options { max_manifest_store_size_bytes: 1000 } + )pb"); + EXPECT_THAT(GenerateManifestStore(*store_generator_, request), + StatusIs(absl::StatusCode::kFailedPrecondition, + HasSubstr("manifest store too large"))); +} + +TEST_F(ManifestStoreGeneratorTest, ManifestStoreSizeLimitNotExceeded) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + instance_id: "12345678-1234-1234-1234-123456789012" + hard_binding { + box_hash { + alg: "some alg" + boxes { names: "some name" names: "other name" hash: "some hash" } + } + } + actions { + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + options { max_manifest_store_size_bytes: 16384 } + )pb"); + ABSL_EXPECT_OK(GenerateManifestStore(*store_generator_, request)); +} + +TEST_F(ManifestStoreGeneratorTest, PartBindingBmffBasedHashSizeMismatch) { + DataHashAssertion data_hash; + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad(""); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_data_hash() = data_hash; + BmffBasedHashAssertion bmff_based_hash = + GetBmffHashTemplate(HashAlgorithm::kSha256); + *request.mutable_hard_binding() + ->add_part_bindings() + ->mutable_bmff_based_hash() = bmff_based_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_data_hash() = + request.hard_binding().data_hash(); + bmff_based_hash.add_exclusions()->set_xpath("additional exclusion"); + *finalize_request.mutable_hard_binding() + ->add_part_bindings() + ->mutable_bmff_based_hash() = bmff_based_hash; + EXPECT_THAT( + FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr( + "The part binding differs from template in unsupported ways."))); +} + +TEST_F(ManifestStoreGeneratorTest, PartBindingBoxesHashSizeMismatch) { + DataHashAssertion data_hash; + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad(""); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_data_hash() = data_hash; + BoxesHashAssertion box_hash; + BoxHash* box = box_hash.add_boxes(); + box->add_names("some name"); + box->set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + *request.mutable_hard_binding()->add_part_bindings()->mutable_box_hash() = + box_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_data_hash() = + request.hard_binding().data_hash(); + box_hash.mutable_boxes(0)->add_names("additional name"); + *finalize_request.mutable_hard_binding() + ->add_part_bindings() + ->mutable_box_hash() = box_hash; + EXPECT_THAT( + FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr( + "The part binding differs from template in unsupported ways."))); +} + +TEST_F(ManifestStoreGeneratorTest, PartBindingDataHashSizeMismatch) { + DataHashAssertion data_hash; + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad(""); + GenerateManifestStoreRequest request; + *request.mutable_hard_binding()->mutable_data_hash() = data_hash; + *request.mutable_hard_binding()->add_part_bindings()->mutable_data_hash() = + data_hash; + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + *finalize_request.mutable_hard_binding()->mutable_data_hash() = + request.hard_binding().data_hash(); + data_hash.set_name("additional name"); + *finalize_request.mutable_hard_binding() + ->add_part_bindings() + ->mutable_data_hash() = data_hash; + EXPECT_THAT( + FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr( + "The part binding differs from template in unsupported ways."))); +} + +TEST_F(ManifestStoreGeneratorTest, PartBindingMatchingSizes) { + DataHashAssertion data_hash; + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad(""); + GenerateManifestStoreRequest request; + // Hard binding + *request.mutable_hard_binding()->mutable_data_hash() = data_hash; + // Part binding 1: DataHash + *request.mutable_hard_binding()->add_part_bindings()->mutable_data_hash() = + data_hash; + // Part binding 2: BmffBasedHash + BmffBasedHashAssertion bmff_based_hash = + GetBmffHashTemplate(HashAlgorithm::kSha256); + *request.mutable_hard_binding() + ->add_part_bindings() + ->mutable_bmff_based_hash() = bmff_based_hash; + // Part binding 3: BoxesHash + BoxesHashAssertion box_hash; + BoxHash* box = box_hash.add_boxes(); + box->add_names("some name"); + box->set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + *request.mutable_hard_binding()->add_part_bindings()->mutable_box_hash() = + box_hash; + + *request.mutable_actions() = ParseTextProtoOrDie(R"pb( + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + )pb"); + absl::StatusOr<ManifestStoreTemplate> manifest_store_template = + GenerateManifestStoreTemplate(*store_generator_, request); + ABSL_ASSERT_OK(manifest_store_template); + + FinalizeManifestStoreRequest finalize_request; + *finalize_request.mutable_manifest_store_template() = + *manifest_store_template; + // Set the same hard binding and part bindings in the finalize request. + *finalize_request.mutable_hard_binding() = request.hard_binding(); + + // Modify the hashes to ensure finalization works. + std::string new_hash(HashSizeBytes(HashAlgorithm::kSha256), '\1'); + finalize_request.mutable_hard_binding()->mutable_data_hash()->set_hash( + new_hash); + finalize_request.mutable_hard_binding() + ->mutable_part_bindings(0) + ->mutable_data_hash() + ->set_hash(new_hash); + finalize_request.mutable_hard_binding() + ->mutable_part_bindings(1) + ->mutable_bmff_based_hash() + ->set_hash(new_hash); + finalize_request.mutable_hard_binding() + ->mutable_part_bindings(2) + ->mutable_box_hash() + ->mutable_boxes(0) + ->set_hash(new_hash); + + ABSL_EXPECT_OK(FinalizeManifestStore(*store_generator_, finalize_request)); +} + +TEST_F(ManifestStoreGeneratorTest, + GenerateNegativeMaxManifestStoreSizeBytesFails) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + actions { + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + hard_binding { + data_hash { alg: "sha256" hash: "00000000000000000000000000000000" } + } + options { max_manifest_store_size_bytes: -1 } + )pb"); + EXPECT_THAT( + GenerateManifestStore(*store_generator_, request), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr("max_manifest_store_size_bytes must be non-negative"))); +} + +TEST_F(ManifestStoreGeneratorTest, NegativeMaxBytesToShiftFails) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + actions { created { action_metadata { description: "Test Action" } } } + hard_binding { + data_hash { alg: "sha256" hash: "00000000000000000000000000000000" } + } + options { max_bytes_to_shift: -1 } + )pb"); + EXPECT_THAT(GenerateManifestStore(*store_generator_, request), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("max_bytes_to_shift must be non-negative"))); +} + +TEST_F(ManifestStoreGeneratorTest, NonNegativeMaxBytesToShiftSucceeds) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + actions { created { action_metadata { description: "Test Action" } } } + hard_binding { + data_hash { alg: "sha256" hash: "00000000000000000000000000000000" } + } + options { max_bytes_to_shift: 0 } + )pb"); + ABSL_EXPECT_OK(GenerateManifestStore(*store_generator_, request)); +} + +TEST_F(ManifestStoreGeneratorTest, FinalizeNegativeManifestStoreSizeFails) { + FinalizeManifestStoreRequest finalize_request; + finalize_request.mutable_manifest_store_template()->set_generator_context( + "context"); + finalize_request.mutable_manifest_store_template()->set_manifest_store_size( + -1); + *finalize_request.mutable_hard_binding()->mutable_data_hash() = + ParseTextProtoOrDie(R"pb(alg: "sha256" + hash: "00000000000000000000000000000000")pb"); + + EXPECT_THAT( + FinalizeManifestStore(*store_generator_, finalize_request), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr("manifest_store_size in template must be non-negative"))); +} + +TEST_F(ManifestStoreGeneratorTest, ManifestStoreTransformerSuccess) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + actions { + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + hard_binding { + data_hash { alg: "sha256" hash: "00000000000000000000000000000000" } + } + )pb"); + + MockManifestStoreTransformer mock_transformer; + EXPECT_CALL(mock_transformer, Transform(testing::NotNull())) + .WillOnce([](jumbf::BuiltSuperBox* box) { + EXPECT_THAT(box, testing::NotNull()); + EXPECT_FALSE(box->Peek().empty()); + return absl::OkStatus(); + }); + + std::unique_ptr<ManifestStoreGenerator> generator = + CreateGenerator(&mock_transformer); + ABSL_EXPECT_OK(GenerateManifestStore(*generator, request)); +} + +TEST_F(ManifestStoreGeneratorTest, ManifestStoreTransformerModifiesSuperBox) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + actions { created { action_metadata { description: "Test Action" } } } + hard_binding { + data_hash { alg: "sha256" hash: "00000000000000000000000000000000" } + } + )pb"); + + jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {}); + ABSL_CHECK_OK(builder.AddContent(jumbf::kCborBoxType, + absl::Cord("custom transformed data"))); + absl::StatusOr<jumbf::BuiltSuperBox> transformed_box = + std::move(builder).Finalize(); + ABSL_ASSERT_OK(transformed_box); + std::string expected_transformed_store(transformed_box->Peek()); + + MockManifestStoreTransformer mock_transformer; + EXPECT_CALL(mock_transformer, Transform(testing::NotNull())) + .WillOnce([transformed_box = *std::move(transformed_box)]( + jumbf::BuiltSuperBox* box) mutable { + EXPECT_THAT(box, testing::NotNull()); + EXPECT_FALSE(box->Peek().empty()); + *box = std::move(transformed_box); + return absl::OkStatus(); + }); + + std::unique_ptr<ManifestStoreGenerator> generator = + CreateGenerator(&mock_transformer); + absl::StatusOr<GenerateManifestStoreResponse> response = + GenerateManifestStore(*generator, request); + ABSL_ASSERT_OK(response); + EXPECT_EQ(response->manifest_store(), expected_transformed_store); +} + +TEST_F(ManifestStoreGeneratorTest, ManifestStoreTransformerErrorPropagates) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + actions { created { action_metadata { description: "Test Action" } } } + hard_binding { + data_hash { alg: "sha256" hash: "00000000000000000000000000000000" } + } + )pb"); + + MockManifestStoreTransformer mock_transformer; + EXPECT_CALL(mock_transformer, Transform(testing::NotNull())) + .WillOnce(testing::Return(absl::InternalError("transform failed"))); + + std::unique_ptr<ManifestStoreGenerator> generator = + CreateGenerator(&mock_transformer); + EXPECT_THAT( + GenerateManifestStore(*generator, request), + StatusIs(absl::StatusCode::kInternal, HasSubstr("transform failed"))); +} + +TEST_F(ManifestStoreGeneratorTest, + ManifestStoreTransformerTemplateErrorPropagates) { + GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb( + actions { created { action_metadata { description: "Test Action" } } } + hard_binding { + data_hash { alg: "sha256" hash: "00000000000000000000000000000000" } + } + )pb"); + + MockManifestStoreTransformer mock_transformer; + EXPECT_CALL(mock_transformer, Transform(testing::NotNull())) + .WillOnce( + testing::Return(absl::InternalError("transform template failed"))); + + std::unique_ptr<ManifestStoreGenerator> generator = + CreateGenerator(&mock_transformer); + EXPECT_THAT(GenerateManifestStoreTemplate(*generator, request), + StatusIs(absl::StatusCode::kInternal, + HasSubstr("transform template failed"))); +} + +} // namespace +} // namespace credentio
diff --git a/generator/generator_options.h b/generator/generator_options.h new file mode 100644 index 0000000..db3c507 --- /dev/null +++ b/generator/generator_options.h
@@ -0,0 +1,59 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_OPTIONS_H_ +#define THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_OPTIONS_H_ + +#include "absl/base/nullability.h" +#include "assertion/assertion_encoder.h" +#include "claim/claim_cbor_encoder.h" +#include "claim/generator.h" +#include "claim/signer.h" +#include "constants/spec_version.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "generator/manifest_store_transformer.h" +#include "proto/generator_info.pb.h" +#include "uuid/uuid.h" + +namespace credentio { + +// Options for creating a ManifestStoreGenerator. +struct GeneratorOptions { + // Required. + GeneratorInfo generator_info; + // Required. Must outlive the created generator. + ClaimSigner* signing_claim_signer = nullptr; + // Required. Must outlive the created generator. + ClaimSigner* template_claim_signer = nullptr; + // Optional. If provided, must outlive the created generator. + const UuidGenerator* absl_nonnull uuid_generator = &UuidGenerator::Default(); + // Optional. If provided, must outlive the created generator. + const HasherFactory* absl_nonnull hash_factory = &DefaultHasherFactory(); + // Optional. If provided, must outlive the created generator. + const AssertionEncoder* assertion_cbor_encoder = nullptr; + // Optional. If provided, must outlive the created generator. + const ClaimCborEncoder* claim_cbor_encoder = nullptr; + // Optional. If provided, must outlive the created generator. + const ManifestStoreTransformer* manifest_store_transformer = nullptr; + // Optional. + ManifestAssemblerFactory manifest_assembler_factory = CreateManifestAssembler; + // The C2PA specification version the generated manifests conform to. + SpecVersion spec_version = SpecVersion::kC2pa_2_2; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_OPTIONS_H_
diff --git a/generator/manifest_store_transformer.h b/generator/manifest_store_transformer.h new file mode 100644 index 0000000..dfe5678 --- /dev/null +++ b/generator/manifest_store_transformer.h
@@ -0,0 +1,34 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_GENERATOR_MANIFEST_STORE_TRANSFORMER_H_ +#define THIRD_PARTY_CREDENTIO_GENERATOR_MANIFEST_STORE_TRANSFORMER_H_ + +#include "absl/status/status.h" +#include "jumbf/box_builder.h" + +namespace credentio { + +// Interface for transforming manifest store JUMBF BuiltSuperBox structures +// before final output. +class ManifestStoreTransformer { + public: + virtual ~ManifestStoreTransformer() = default; + virtual absl::Status Transform(jumbf::BuiltSuperBox* box) const = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_GENERATOR_MANIFEST_STORE_TRANSFORMER_H_
diff --git a/jumbf/BUILD b/jumbf/BUILD index a22c1e4..e30a42e 100644 --- a/jumbf/BUILD +++ b/jumbf/BUILD
@@ -167,6 +167,7 @@ deps = [ ":box", ":constants", + "//jumbf/internal:encode", "//jumbf/internal:serialization_utils", "//uuid", "@abseil-cpp//absl/functional:overload", @@ -174,7 +175,9 @@ "@abseil-cpp//absl/status:status_macros", "@abseil-cpp//absl/status:statusor", "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:cord_backward_writer", ], ) @@ -202,6 +205,7 @@ "@abseil-cpp//absl/strings:cord", "@abseil-cpp//absl/strings:string_view", "@googletest//:gtest_main", + "@riegeli//riegeli/endian:endian_reading", "@riegeli//riegeli/endian:endian_writing", ], )
diff --git a/jumbf/README.md b/jumbf/README.md index 98a041d..055c58d 100644 --- a/jumbf/README.md +++ b/jumbf/README.md
@@ -9,9 +9,9 @@ ### Parsing -- [`ConsumeSuperBox(...)`](http://c2pa/jumbf/parse.h): Recursively parses a - superbox and all contained boxes into - [view-only structs](http://c2pa/jumbf/box.h). +- [`ConsumeSuperBox(...)`](http://credentio/jumbf/parse.h): Recursively parses + a superbox and all contained boxes into + [view-only structs](http://credentio/jumbf/box.h). - Basic box structure: Complete @@ -34,8 +34,8 @@ ## Serialization -- [`SuperBoxBuilder`](http://c2pa/jumbf/box_builder.h): Stateful builder for - creating superboxes. +- [`SuperBoxBuilder`](http://credentio/jumbf/box_builder.h): Stateful builder + for creating superboxes. - Basic box / superbox structure: Complete @@ -68,9 +68,9 @@ ## Directory structure -The public API is contained in the top-level directory, c2pa/jumbf. Internal -parsing functions are located in the -["internal" subdirectory](http://c2pa/jumbf/internal). +The public API is contained in the top-level directory, credentio/jumbf. +Internal parsing functions are located in the +["internal" subdirectory](http://credentio/jumbf/internal). As a general rule, recursive parsing and assembling of the output format happens in the top-level directory, while functions in the internal subdirectory handle
diff --git a/jumbf/box_builder.h b/jumbf/box_builder.h index 003f1d4..a17f647 100644 --- a/jumbf/box_builder.h +++ b/jumbf/box_builder.h
@@ -16,7 +16,7 @@ // SuperBoxEncoder: Stateful JUMBF encoder. // // This class tracks box lengths to correctly write headers. See -// c2pa/jumbf/box_builder_test.cc for usage +// credentio/jumbf/box_builder_test.cc for usage // examples. #ifndef THIRD_PARTY_CREDENTIO_JUMBF_BOX_BUILDER_H_
diff --git a/jumbf/box_builder_test.cc b/jumbf/box_builder_test.cc index fbe0f5f..e7ec491 100644 --- a/jumbf/box_builder_test.cc +++ b/jumbf/box_builder_test.cc
@@ -60,6 +60,26 @@ EXPECT_EQ(super_box.contents[0].Get<CborBox>().payload, "this is not CBOR"); } +TEST(BoxBuilderTest, JsonContentBox) { + jumbf::SuperBoxBuilder builder(jumbf::kJsonBoxTypeUuid, {}); + ABSL_ASSERT_OK(builder.AddContent( + jumbf::kJsonBoxType, jumbf::CordFromConstexpr("{\"key\": \"value\"}"))); + + auto built_box_or = std::move(builder).Finalize(); + ABSL_ASSERT_OK(built_box_or); + auto built_box = std::move(*built_box_or); + absl::Cord serialized = std::move(built_box).AsCord(); + absl::string_view serialized_view = serialized.Flatten(); + auto super_box_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/-1); + ABSL_ASSERT_OK(super_box_or); + SuperBox super_box = std::move(*super_box_or); + EXPECT_EQ(super_box.description.type_uuid, kJsonBoxTypeUuid); + ASSERT_EQ(super_box.contents.size(), 1); + ASSERT_TRUE(super_box.contents[0].Holds<JsonBox>()); + EXPECT_EQ(super_box.contents[0].Get<JsonBox>().payload, + "{\"key\": \"value\"}"); +} + TEST(BoxBuilderTest, InspectLabelAfterFinalize) { jumbf::SuperBoxBuilder with_label(jumbf::kCborBoxTypeUuid, {.label = CordFromConstexpr("boxLabel")});
diff --git a/jumbf/constants.h b/jumbf/constants.h index c3c6de7..f876766 100644 --- a/jumbf/constants.h +++ b/jumbf/constants.h
@@ -65,6 +65,9 @@ inline constexpr auto kCborBoxTypeUuid = credentio::Uuid::FromStringOrDie("63626F72-0011-0010-8000-00AA00389B71"); +inline constexpr auto kJsonBoxTypeUuid = + credentio::Uuid::FromStringOrDie("6A736F6E-0011-0010-8000-00AA00389B71"); + inline constexpr auto kC2PARedactionUuid = credentio::Uuid::FromStringOrDie("CAA98EEE-9D4D-F80E-86AD-4DFFCA263973");
diff --git a/jumbf/internal/encode.h b/jumbf/internal/encode.h index 3990565..f2e672d 100644 --- a/jumbf/internal/encode.h +++ b/jumbf/internal/encode.h
@@ -31,7 +31,7 @@ // &superbox_size)); // // The public `SuperBoxBuilder` class in -// c2pa/jumbf/box_builder.h is easier to use. +// credentio/jumbf/box_builder.h is easier to use. #ifndef THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_ENCODE_H_ #define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_ENCODE_H_
diff --git a/jumbf/utils.cc b/jumbf/utils.cc index 97e1555..2104dfb 100644 --- a/jumbf/utils.cc +++ b/jumbf/utils.cc
@@ -25,11 +25,14 @@ #include "absl/status/status.h" #include "absl/status/status_macros.h" #include "absl/status/statusor.h" +#include "absl/strings/cord.h" #include "absl/strings/string_view.h" #include "absl/strings/strip.h" #include "jumbf/box.h" #include "jumbf/constants.h" +#include "jumbf/internal/encode.h" #include "jumbf/internal/serialization_utils.h" +#include "riegeli/bytes/cord_backward_writer.h" #include "uuid/uuid.h" namespace jumbf { @@ -142,4 +145,14 @@ return content_size; } +absl::StatusOr<absl::Cord> CreateC2paSaltBox(absl::string_view salt) { + riegeli::CordBackwardWriter<absl::Cord> writer; + ABSL_RETURN_IF_ERROR( + EncodeContentBox(kC2paSaltBoxType, absl::Cord(salt), &writer)); + if (!writer.Close()) { + return writer.status(); + } + return std::move(writer.dest()); +} + } // namespace jumbf
diff --git a/jumbf/utils.h b/jumbf/utils.h index de09428..f0143b7 100644 --- a/jumbf/utils.h +++ b/jumbf/utils.h
@@ -19,6 +19,7 @@ #include <cstdint> #include "absl/status/statusor.h" +#include "absl/strings/cord.h" #include "absl/strings/string_view.h" #include "jumbf/box.h" #include "uuid/uuid.h" @@ -45,6 +46,9 @@ // Returns the size of the box payload. int GetContextBoxSize(const ContentBox& box); +// Serializes a C2PA Salt Box ('c2sh') with the given salt payload. +absl::StatusOr<absl::Cord> CreateC2paSaltBox(absl::string_view salt); + } // namespace jumbf #endif // THIRD_PARTY_CREDENTIO_JUMBF_UTILS_H_
diff --git a/jumbf/utils_test.cc b/jumbf/utils_test.cc index 00fb275..390c6fe 100644 --- a/jumbf/utils_test.cc +++ b/jumbf/utils_test.cc
@@ -36,6 +36,7 @@ #include "jumbf/constants.h" #include "jumbf/parse.h" #include "jumbf/test_utils.h" +#include "riegeli/endian/endian_reading.h" #include "riegeli/endian/endian_writing.h" #include "uuid/uuid.h" @@ -437,5 +438,20 @@ EXPECT_EQ(std::string(buf + 16, 16), kUuid1.ToProtoBytes()); } +TEST(UtilsTest, CreateC2paSaltBox) { + constexpr absl::string_view kSalt = "0123456789abcdef"; + auto salt_box_cord = CreateC2paSaltBox(kSalt); + ABSL_ASSERT_OK(salt_box_cord); + std::string salt_box_bytes = std::string(*salt_box_cord); + + absl::string_view sv = salt_box_bytes; + ASSERT_GE(sv.size(), 8); + uint32_t lbox = riegeli::ReadBigEndian<uint32_t>(&sv[0]); + uint32_t tbox = riegeli::ReadBigEndian<uint32_t>(&sv[4]); + EXPECT_EQ(lbox, 8 + kSalt.size()); + EXPECT_EQ(tbox, kC2paSaltBoxType); + EXPECT_EQ(sv.substr(8), kSalt); +} + } // namespace } // namespace jumbf
diff --git a/kokoro/gcp_ubuntu/build_and_test.sh b/kokoro/gcp_ubuntu/build_and_test.sh index da5757e..1ccefa6 100755 --- a/kokoro/gcp_ubuntu/build_and_test.sh +++ b/kokoro/gcp_ubuntu/build_and_test.sh
@@ -19,13 +19,65 @@ # Navigate to the repository root # KOKORO_ARTIFACTS_DIR is set to /tmpfs/src by default in the Kokoro Ubuntu image. export KOKORO_ARTIFACTS_DIR="${KOKORO_ARTIFACTS_DIR:-/tmpfs/src}" +# Default to running instrumentation tests when Android environment is present. +export RUN_INSTRUMENTATION_TESTS="${RUN_INSTRUMENTATION_TESTS:-1}" cd "${KOKORO_ARTIFACTS_DIR}/git/staging" # Run Bazel build and test all targets bazel build ... bazel test ... -# --- Run Gradle build and unit tests for C2PA Android SDK --- +function create_and_start_emulator() { + local emulator_name=c2pa_emulator + if ! sdkmanager --list_installed 2>/dev/null | grep -q "system-images;android-35;google_apis;x86_64"; then + echo "Emulator system image not pre-installed; downloading..." + echo y | sdkmanager "platforms;android-35" "emulator" "system-images;android-35;google_apis;x86_64" || true + fi + + echo no | avdmanager create avd -n "${emulator_name}" \ + --package "system-images;android-35;google_apis;x86_64" \ + --force + + mkdir -p "${KOKORO_ARTIFACTS_DIR}/emulator-logs" + "${ANDROID_HOME}/emulator/emulator" -avd "${emulator_name}" \ + -no-window \ + -gpu swiftshader_indirect \ + -no-snapshot \ + -no-audio \ + -no-boot-anim > "${KOKORO_ARTIFACTS_DIR}/emulator-logs/emulator_${emulator_name}.log" 2>&1 & +} + +function wait_for_emulator_to_boot() { + local retries=60 # 5 minutes (60 * 5s) + while [[ $retries -gt 0 ]]; do + local boot_completed + boot_completed=$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') + if [[ "${boot_completed}" == "1" ]]; then + break + fi + echo "Waiting for emulator to boot..." + sleep 5 + ((retries--)) + done + + if [[ $retries -eq 0 ]]; then + echo "Emulator failed to boot in time." + exit 1 + fi + + echo "Emulator booted." +} + +function cleanup() { + set +e + if [ "${RUN_INSTRUMENTATION_TESTS:-0}" = "1" ] || [ "${RUN_INSTRUMENTATION_TESTS:-false}" = "true" ]; then + echo "Stopping emulator..." + adb emu kill 2>/dev/null || true + fi +} +trap cleanup EXIT + +# --- Run Gradle build and tests for C2PA Android SDK --- if [ -d "android" ] && [ -x "android/gradlew" ]; then if ! java -version 2>&1 | grep -q "17\."; then echo "Installing OpenJDK 17..." @@ -36,7 +88,16 @@ fi cd android - ./gradlew assembleDebug testDebugUnitTest --no-daemon --stacktrace + + gradle_tasks=(assembleDebug testDebugUnitTest) + if [ "${RUN_INSTRUMENTATION_TESTS:-0}" = "1" ] || [ "${RUN_INSTRUMENTATION_TESTS:-false}" = "true" ]; then + echo "Starting Android emulator for instrumentation tests..." + create_and_start_emulator + wait_for_emulator_to_boot + gradle_tasks+=(connectedAndroidTest) + fi + + ./gradlew "${gradle_tasks[@]}" --no-daemon --stacktrace else echo "No android/gradlew found; skipping Android Gradle build." fi
diff --git a/kokoro/gcp_ubuntu/continuous.cfg b/kokoro/gcp_ubuntu/continuous.cfg index b03c640..d75130f 100644 --- a/kokoro/gcp_ubuntu/continuous.cfg +++ b/kokoro/gcp_ubuntu/continuous.cfg
@@ -5,4 +5,5 @@ container_properties { docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/c2pa/builder:latest" + docker_privileged: true }
diff --git a/kokoro/gcp_ubuntu/presubmit.cfg b/kokoro/gcp_ubuntu/presubmit.cfg index b03c640..d75130f 100644 --- a/kokoro/gcp_ubuntu/presubmit.cfg +++ b/kokoro/gcp_ubuntu/presubmit.cfg
@@ -5,4 +5,5 @@ container_properties { docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/c2pa/builder:latest" + docker_privileged: true }
diff --git a/kokoro/macos/build_and_test.sh b/kokoro/macos/build_and_test.sh new file mode 100755 index 0000000..49b2aa9 --- /dev/null +++ b/kokoro/macos/build_and_test.sh
@@ -0,0 +1,35 @@ +#!/bin/bash +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Builds and tests Credentio on a Kokoro macOS VM. +set -euo pipefail + +export KOKORO_ARTIFACTS_DIR="${KOKORO_ARTIFACTS_DIR:-/tmpfs/src}" +cd "${KOKORO_ARTIFACTS_DIR}/git/staging" + +if command -v bazelisk >/dev/null 2>&1; then + BAZEL=bazelisk +else + BAZEL=bazel +fi + +# A Bazel server left running keeps the VM session open until the Kokoro +# timeout, so always shut it down. +trap '"${BAZEL}" shutdown || true' EXIT + +"${BAZEL}" version +"${BAZEL}" build ... +"${BAZEL}" test --test_output=errors ...
diff --git a/kokoro/macos/continuous.cfg b/kokoro/macos/continuous.cfg new file mode 100644 index 0000000..f0d534b --- /dev/null +++ b/kokoro/macos/continuous.cfg
@@ -0,0 +1,4 @@ +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + +build_file: "staging/kokoro/macos/build_and_test.sh"
diff --git a/kokoro/macos/presubmit.cfg b/kokoro/macos/presubmit.cfg new file mode 100644 index 0000000..f0d534b --- /dev/null +++ b/kokoro/macos/presubmit.cfg
@@ -0,0 +1,4 @@ +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + +build_file: "staging/kokoro/macos/build_and_test.sh"
diff --git a/proto/BUILD b/proto/BUILD index 1a6731c..f8bf783 100644 --- a/proto/BUILD +++ b/proto/BUILD
@@ -702,3 +702,22 @@ name = "ingredient_validation_result_java_proto_lite", deps = [":ingredient_validation_result_proto"], ) + +proto_library( + name = "generator_proto", + srcs = ["generator.proto"], + deps = [ + ":bmff_based_hash_assertion_proto", + ":boxes_hash_assertion_proto", + ":collection_data_hash_assertion_proto", + ":data_hash_assertion_proto", + ":generator_info_proto", + ":ingredient_validation_result_proto", + ":multi_asset_hash_assertion_proto", + ], +) + +cc_proto_library( + name = "generator_cc_proto", + deps = [":generator_proto"], +)
diff --git a/proto/generator.proto b/proto/generator.proto new file mode 100644 index 0000000..c32b400 --- /dev/null +++ b/proto/generator.proto
@@ -0,0 +1,451 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +edition = "2023"; + +package credentio; + +import "proto/bmff_based_hash_assertion.proto"; +import "proto/boxes_hash_assertion.proto"; +import "proto/collection_data_hash_assertion.proto"; +import "proto/data_hash_assertion.proto"; +import "proto/generator_info.proto"; +import "proto/ingredient_validation_result.proto"; +import "proto/multi_asset_hash_assertion.proto"; + +option java_multiple_files = true; + +// Metadata to attach to an action. +message ActionMetadata { + GeneratorInfo software_agent = 1; + string description = 2; + // Was this action performed multiple times? + bool multiple_instances = 3; +} + +// See description of c2pa.created at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message CreatedAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; + // Optional. Indexes (within GenerateManifestStoreRequest.ingredients) of + // ingredients to associate with this action. + repeated int64 input_ingredient_indexes = 3; +} + +// See description of c2pa.opened at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message OpenedAction { + ActionMetadata action_metadata = 1; + // Required. Index (within GenerateManifestStoreRequest.ingredients) of the + // ingredient to associate with this action. + int64 ingredient_index = 2; +} + +// See description of c2pa.addedText at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message AddedTextAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; +} + +// See description of c2pa.adjustedColor at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message AdjustedColorAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; +} + +// See description of c2pa.changedSpeed at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message ChangedSpeedAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; +} + +// See description of c2pa.converted at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message ConvertedAction { + ActionMetadata action_metadata = 1; +} + +// See description of c2pa.cropped at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message CroppedAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; +} + +// See description of c2pa.deleted at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message DeletedAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; +} + +// See description of c2pa.drawing at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message DrawingAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; +} + +// See description of c2pa.dubbed at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message DubbedAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; +} + +// See description of c2pa.edited at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message EditedAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; + // Optional. Indexes (within GenerateManifestStoreRequest.ingredients) of + // ingredients to associate with this action, using an `inputTo` relationship + // (https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_relationship). + repeated int64 input_ingredient_indexes = 3; +} + +// See description of c2pa.edited.metadata at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message EditedMetadataAction { + ActionMetadata action_metadata = 1; +} + +// See description of c2pa.enhanced at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message EnhancedAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; +} + +// See description of c2pa.filtered at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message FilteredAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; + // Optional. Indexes (within GenerateManifestStoreRequest.ingredients) of + // ingredients to associate with this action, using an `inputTo` relationship + // (https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_relationship). + repeated int64 input_ingredient_indexes = 3; +} + +// See description of c2pa.orientation at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message OrientationAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; +} + +// See description of c2pa.placed at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message PlacedAction { + ActionMetadata action_metadata = 1; + // Required. Indexes (within GenerateManifestStoreRequest.ingredients) of + // ingredients to associate with this action. + repeated int64 ingredient_indexes = 3; + + reserved 2; +} + +message RepackagedAction { + ActionMetadata action_metadata = 1; +} + +// See description of c2pa.resized at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message ResizedAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; +} + +// See description of c2pa.transcoded at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message TranscodedAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; +} + +// See description of c2pa.trimmed at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message TrimmedAction { + ActionMetadata action_metadata = 1; + string digital_source_type = 2; +} + +// See description of c2pa.unknown at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions. +message UnknownAction { + ActionMetadata action_metadata = 1; +} + +// Describes an action that was taken on an asset. +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions +// for more details. +message Action { + // Required. + oneof action { + AddedTextAction added_text = 2; // c2pa.addedText + AdjustedColorAction adjusted_color = 3; // c2pa.adjustedColor + ChangedSpeedAction changed_speed = 4; // c2pa.changedSpeed + ConvertedAction converted = 5; // c2pa.converted + CroppedAction cropped = 6; // c2pa.cropped + DeletedAction deleted = 7; // c2pa.deleted + DrawingAction drawing = 8; // c2pa.drawing + DubbedAction dubbed = 9; // c2pa.dubbed + EditedAction edited = 10; // c2pa.edited + EditedMetadataAction edited_metadata = 11; // c2pa.edited.metadata + EnhancedAction enhanced = 12; // c2pa.enhanced + FilteredAction filtered = 13; // c2pa.filtered + OrientationAction orientation = 14; // c2pa.orientation + PlacedAction placed = 1; // c2pa.placed + RepackagedAction repackaged = 15; // c2pa.repackaged + ResizedAction resized = 16; // c2pa.resized + TranscodedAction transcoded = 17; // c2pa.transcoded + TrimmedAction trimmed = 19; // c2pa.trimmed + UnknownAction unknown = 20; // c2pa.unknown + } + + reserved 18, 21; +} + +// Describes a collection of Action(s) that were taken on an asset. +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions +// for more details. +message Actions { + // Required. The first action taken to create or import the asset. + oneof inception_action { + CreatedAction created = 1; + OpenedAction opened = 2; + } + + // Optional. List of additional actions taken on the asset. + repeated Action actions = 3; + + // If true, indicates that all actions performed are included in an actions + // assertion. + bool all_actions_included = 4; +} + +// Describes an ingredient input to claim generation. Will be used to generate +// ingredient Assertions. All ingredients must be referenced by an action. +message IngredientInput { + // Optional but recommended. Metadata about the ingredient. + IngredientInputMetadata metadata = 8; + + // Optional. Ingredient validation results returned by the validator. + IngredientValidationResultProto ingredient_validation_results = 6; + + reserved 1, 2, 3, 4, 5, 7; +} + +// Metadata about an ingredient. +message IngredientInputMetadata { + // Optional. IANA Media Type of the ingredient. + // Instead of providing a value here, it is better to provide the media type + // as a parameter to the `ValidateIngredient` call. + string media_type = 1; + + // Optional. Name of the ingredient. + string title = 2; + + // Optional. Unique identifier such as the value of the ingredient's + // `xmpMM:InstanceID`. If omitted, a randomly generated UUID will be provided. + string instance_id = 3; + + // Optional. Additional description of the ingredient. + string description = 4; + + // Optional. URI to an informational page about the ingredient or its data. + string informational_uri = 5; +} + +// Describes a hard binding assertion. See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_hard_bindings +// for more details. +message HardBinding { + oneof binding { + BmffBasedHashAssertion bmff_based_hash = 1; + BoxesHashAssertion box_hash = 2; + DataHashAssertion data_hash = 3; + CollectionDataHashAssertion collection_data_hash = 5; + } + + // Bindings for each part of a multi-part asset, in the order in which they + // appear in the asset. + repeated PartBinding part_bindings = 4; +} + +// Describes a binding assertion for a part of a multi-part asset. +// This is a simplified structure, the generator will be responsible for +// converting this into the appropriate binding assertion for the format. See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_multi_asset_hash +// for more details. +message PartBinding { + MultiAssetPartLocation location = 1; + bool is_optional = 2; + + oneof binding { + BmffBasedHashAssertion bmff_based_hash = 4; + BoxesHashAssertion box_hash = 5; + DataHashAssertion data_hash = 6; + } + + reserved 3; +} + +message GenerateManifestStoreOptions { + enum TimeStampingMode { + // If not specified, it behaves as TIME_STAMPING_OPTIONAL. + TIME_STAMPING_MODE_UNSPECIFIED = 0; + // Time-stamping is optional and fail-open. For instance, if the + // time-stamping service is unavailable, no timestamp will be included and a + // padding space is reserved. + TIME_STAMPING_OPTIONAL = 1; + // Time-stamping is required and fail-close. For instance, the request will + // be failed if the time-stamping service is unavailable. + TIME_STAMPING_REQUIRED = 2; + // Time-stamping is omitted and a padding space is reserved. + TIME_STAMPING_OMITTED_WITH_PADDING = 3; + } + TimeStampingMode time_stamping_mode = 2; + + // Controls whether the multi-asset hash assertion should be generated. + // If unset or false, no multi-asset hash assertion will be generated. + // If true, the generator will generate a multi-asset hash assertion for + // the asset. + // Impact per format: + // - JPEG: If true, the generator will generate a multi-asset hash assertion + // for the asset when applicable (Android Motion Photo, MPF, AMP, Ultra HDR, + // ...), marking any non-JPEG parts optional. + // Relevant only for container-level generation APIs. + bool generate_multi_asset_hash = 3; + + // If nonzero, generation will fail if the generated C2PA manifest store would + // exceed this size. If zero, a default limit will be used. + int64 max_manifest_store_size_bytes = 4; + + // If set and greater than or equal to 0, generation will fail if embedding + // the manifest requires shifting more bytes in the asset than this limit. + // This limit is compared against the file size (in bytes) minus the offset of + // the first INSERT instruction. + // NOTE: This option is currently only supported and enforced by the BMFF/MP4 + // encoder. + int64 max_bytes_to_shift = 11; + + reserved 1, 5, 6, 7, 8, 9, 10; +} + +// Describes a gathered assertion to be included in the manifest. +message GatheredAssertion { + // The content type of the gathered assertion. + enum ContentType { + CONTENT_TYPE_UNSPECIFIED = 0; + CONTENT_TYPE_JSON = 1; + } + + // Label of the assertion within the assertion store. + string label = 1; + + // Raw payload bytes of the assertion. + bytes data = 2; + + // The content type of the data. Required. + ContentType content_type = 3; +} + +// Describes a request to the claim generator for standard manifests. +message GenerateManifestStoreRequest { + // Optional. Unique identifier for the asset version. + // If the asset contains XMP, then the asset’s xmpMM:InstanceID should be + // used as the instanceID. + // If omitted, the claim generator will generate a new instance ID. + string instance_id = 7; + + // Required for "bare metal" APIs, prohibited for "container-level" APIs + // (e.g., PngEncoder, JpegEncoder). + // + // See + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_hard_bindings + // for more details. + HardBinding hard_binding = 6; + // Required. See + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions + // for more details. + Actions actions = 4; + + // Optional. Ingredient inputs to be included in the generated manifest. + // All ingredients must be referenced by an action. + repeated IngredientInput ingredients = 5; + + // Optional. Gathered assertions to be included in the generated manifest. + repeated GatheredAssertion gathered_assertions = 11; + + reserved 1, 2, 3, 9, 10; + + // Optional. Options to configure the behavior of the generator. + GenerateManifestStoreOptions options = 8; +} + +// The result of generating a C2PA manifest store. +message GenerateManifestStoreResponse { + // The generated manifest store. + bytes manifest_store = 1; + // Indicates whether the generated claim has been timestamped or not. + bool is_timestamped = 2; + reserved 3; +} + +// A temporate manifest store is returned by the generator during size +// estimation, which will be used to generate the final manifest store. +message ManifestStoreTemplate { + // The expected byte size of the to-be-generated manifest store (`c2pa` JUMBF + // superbox). An asset writer should use this size to reserve space for the + // C2PA data. + // + // This size does not include format-specific wrapping such as the JPEG APP11 + // segment header(s) or BMFF `uuid` superbox header. + int64 manifest_store_size = 1; + // An opaque information returned by the generator, which will be used + // subsequently to generate the final manifest store. + bytes generator_context = 2; +} + +// A request to finalize a manifest store template into a complete manifest +// store. +message FinalizeManifestStoreRequest { + // Required. The manifest store template returned by the generator during size + // estimation. + ManifestStoreTemplate manifest_store_template = 1; + // Required. See + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_hard_bindings + // for more details. + HardBinding hard_binding = 5; + reserved 2, 3, 4; +} + +// The result of finalizing a C2PA manifest store. +message FinalizeManifestStoreResponse { + // The generated manifest store. + bytes manifest_store = 1; + // Indicates whether the generated claim has been timestamped or not. + bool is_timestamped = 2; + + reserved 3; +}
diff --git a/salt/BUILD b/salt/BUILD new file mode 100644 index 0000000..5531c49 --- /dev/null +++ b/salt/BUILD
@@ -0,0 +1,23 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +load("@rules_cc//cc:defs.bzl", "cc_library") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "salt_generator", + hdrs = ["salt_generator.h"], +)
diff --git a/salt/salt_generator.h b/salt/salt_generator.h new file mode 100644 index 0000000..b274c75 --- /dev/null +++ b/salt/salt_generator.h
@@ -0,0 +1,43 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_SALT_SALT_GENERATOR_H_ +#define THIRD_PARTY_CREDENTIO_SALT_SALT_GENERATOR_H_ + +#include <cstddef> +#include <string> + +namespace credentio { + +// Default salt length in bytes (128 bits of entropy) per C2PA specification +// Section 8.3.1.3 and Section 8.4.2.3. +inline constexpr size_t kDefaultSaltLength = 16; + +// Abstract interface for generating assertion salt bytes. +// +// Salts are embedded into the private 'c2sh' box within an assertion's JUMBF +// Description Box to ensure that redacted assertions cannot be easily +// dictionary-attacked or pre-image brute-forced. +class SaltGenerator { + public: + virtual ~SaltGenerator() = default; + + // Generates random salt bytes of length `kDefaultSaltLength`. + virtual std::string Generate() const = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_SALT_SALT_GENERATOR_H_
diff --git a/testing/BUILD b/testing/BUILD index 94b3444..74e0186 100644 --- a/testing/BUILD +++ b/testing/BUILD
@@ -119,10 +119,12 @@ cc_library( name = "crypto_testing", + testonly = False, srcs = ["crypto_testing.cc"], hdrs = ["crypto_testing.h"], deps = [ - "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/strings:string_view", "@abseil-cpp//absl/time", "@boringssl//:crypto", ], @@ -175,17 +177,27 @@ srcs = ["pem.cc"], hdrs = ["pem.h"], deps = [ + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/functional:overload", "@abseil-cpp//absl/status", "@abseil-cpp//absl/status:status_macros", "@abseil-cpp//absl/status:statusor", "@abseil-cpp//absl/strings:string_view", "@boringssl//:crypto", - "@tink_cc//proto:common_cc_proto", - "@tink_cc//tink:cleartext_keyset_handle", + "@tink_cc//tink:insecure_secret_key_access", + "@tink_cc//tink:key_status", "@tink_cc//tink:keyset_handle", + "@tink_cc//tink:partial_key_access", "@tink_cc//tink:public_key_sign", + "@tink_cc//tink/pem:signature_key_parser", "@tink_cc//tink/signature:config_v0", - "@tink_cc//tink/signature:signature_pem_keyset_reader", + "@tink_cc//tink/signature:ecdsa_parameters", + "@tink_cc//tink/signature:ecdsa_private_key", + "@tink_cc//tink/signature:ed25519_parameters", + "@tink_cc//tink/signature:ed25519_private_key", + "@tink_cc//tink/signature:rsa_ssa_pss_parameters", + "@tink_cc//tink/signature:rsa_ssa_pss_private_key", + "@tink_cc//tink/signature:signature_config", ], ) @@ -199,7 +211,9 @@ "@abseil-cpp//absl/status:status_matchers", "@abseil-cpp//absl/strings:string_view", "@googletest//:gtest_main", - "@tink_cc//tink/signature:signature_pem_keyset_reader", + "@tink_cc//tink/signature:ecdsa_parameters", + "@tink_cc//tink/signature:ed25519_parameters", + "@tink_cc//tink/signature:rsa_ssa_pss_parameters", ], ) @@ -280,7 +294,7 @@ name = "proto_test_utils", hdrs = ["proto_test_utils.h"], deps = [ - "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/log:absl_check", "@abseil-cpp//absl/strings:string_view", "@googletest//:gtest", "@protobuf", @@ -301,3 +315,174 @@ "@riegeli//riegeli/bytes:reader", ], ) + +cc_library( + name = "local_key_crypto_write_handler", + testonly = False, + srcs = ["local_key_crypto_write_handler.cc"], + hdrs = ["local_key_crypto_write_handler.h"], + deps = [ + "//async:async_context", + "//crypto:algorithms", + "//crypto:crypto_write_handler", + "//crypto:timestamp_write_handler", + "//tsp:test_helpers", + "@abseil-cpp//absl/base:core_headers", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/time:clock_interface", + "@tink_cc//tink:insecure_secret_key_access", + "@tink_cc//tink:key_status", + "@tink_cc//tink:keyset_handle", + "@tink_cc//tink:partial_key_access", + "@tink_cc//tink:public_key_sign", + "@tink_cc//tink/pem:signature_key_parser", + "@tink_cc//tink/signature:config_v0", + "@tink_cc//tink/signature:ecdsa_parameters", + "@tink_cc//tink/signature:ecdsa_private_key", + "@tink_cc//tink/signature:signature_config", + ], +) + +cc_library( + name = "create_test_crypto_write_handler", + testonly = False, + srcs = ["create_test_crypto_write_handler.cc"], + hdrs = ["create_test_crypto_write_handler.h"], + deps = [ + ":crypto_testing", + ":local_key_crypto_write_handler", + "//crypto:crypto_write_handler", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/time:clock_interface", + ], +) + +cc_test( + name = "create_test_crypto_write_handler_test", + srcs = ["create_test_crypto_write_handler_test.cc"], + deps = [ + ":create_test_crypto_write_handler", + ":local_key_crypto_write_handler", + ":test_validation_tracker", + "//cose:simple_cms_parser", + "//crypto:algorithms", + "//crypto:crypto_write_handler", + "//crypto/default:trust_store", + "//tsp:parsed_timestamp_token", + "//tsp:timestamp_verifier", + "//tsp:verified_timestamp", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/time:simulated_clock", + "@boringssl//:pki", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "manifest_store_test_utils", + srcs = ["manifest_store_test_utils.cc"], + hdrs = ["manifest_store_test_utils.h"], + deps = [ + ":create_test_crypto_write_handler", + ":proto_test_utils", + ":test_validation_tracker", + "//claim:local_signer", + "//claim:validator", + "//cose:verifier", + "//crypto:crypto_read_handler", + "//crypto:crypto_write_handler", + "//crypto/default:default_crypto_read_handler", + "//crypto/default:hasher", + "//generator", + "//generator:generator_factory", + "//jumbf:box", + "//jumbf:parse", + "//proto:generator_cc_proto", + "//proto:generator_info_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//uuid", + "//validator:manifest_store_validator_impl", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time:clock_interface", + ], +) + +cc_library( + name = "nonprod_generator", + srcs = ["nonprod_generator.cc"], + hdrs = ["nonprod_generator.h"], + deps = [ + ":create_test_crypto_write_handler", + "//async:async_context", + "//claim:local_signer", + "//crypto:crypto_write_handler", + "//crypto:hash", + "//crypto:templated_crypto_write_handler", + "//crypto/default:hasher", + "//generator", + "//generator:generator_factory", + "//generator:generator_options", + "//proto:generator_cc_proto", + "//proto:generator_info_cc_proto", + "//uuid", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/time:clock_interface", + ], +) + +cc_library( + name = "nonprod_validator", + srcs = ["nonprod_validator.cc"], + hdrs = ["nonprod_validator.h"], + deps = [ + ":create_test_crypto_write_handler", + ":crypto_testing", + "//crypto:crypto_read_handler", + "//crypto/default:default_crypto_read_handler", + "//validator:asset_validator", + "//validator:asset_validator_impl", + "//validator:validator_options", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/time:clock_interface", + ], +) + +cc_library( + name = "fake_manifest_store_generator", + hdrs = ["fake_manifest_store_generator.h"], + deps = [ + "//async:async_context", + "//generator", + "//proto:generator_cc_proto", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +)
diff --git a/testing/create_test_crypto_write_handler.cc b/testing/create_test_crypto_write_handler.cc new file mode 100644 index 0000000..725e0c5 --- /dev/null +++ b/testing/create_test_crypto_write_handler.cc
@@ -0,0 +1,64 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "testing/create_test_crypto_write_handler.h" + +#include <memory> +#include <utility> + +#include "absl/base/no_destructor.h" +#include "absl/status/statusor.h" +#include "crypto/crypto_write_handler.h" +#include "testing/crypto_testing.h" +#include "testing/local_key_crypto_write_handler.h" + +namespace credentio { + +const TestPki& DefaultTestPki() { + static const absl::NoDestructor<TestPki> kPki(CreateTestPki()); + return *kPki; +} + +LocalKeyClaimSignerCredentials TestClaimSignerCredentials() { + const TestPki& pki = DefaultTestPki(); + return LocalKeyClaimSignerCredentials{ + .key_pem = pki.claim_signer.key_pem, + .certificate_chain = {pki.claim_signer.cert_der}, + .ocsp_responses = {"test_ocsp_response"}, + }; +} + +LocalKeyTsaCredentials TestTsaCredentials() { + const TestPki& pki = DefaultTestPki(); + return LocalKeyTsaCredentials{ + .key_pem = pki.tsa.key_pem, + .cert_pem = pki.tsa.cert_pem, + }; +} + +absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> +CreateTestCryptoWriteHandler(TestCryptoWriteHandlerOptions options) { + return CreateLocalKeyCryptoWriteHandler({ + .claim_signer = options.claim_signer.has_value() + ? *std::move(options.claim_signer) + : TestClaimSignerCredentials(), + .tsa = options.tsa.has_value() ? *std::move(options.tsa) + : TestTsaCredentials(), + .clock = options.clock, + .disable_timestamping = options.disable_timestamping, + }); +} + +} // namespace credentio
diff --git a/testing/create_test_crypto_write_handler.h b/testing/create_test_crypto_write_handler.h new file mode 100644 index 0000000..5d99c37 --- /dev/null +++ b/testing/create_test_crypto_write_handler.h
@@ -0,0 +1,61 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_CREATE_TEST_CRYPTO_WRITE_HANDLER_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_CREATE_TEST_CRYPTO_WRITE_HANDLER_H_ + +#include <memory> +#include <optional> + +#include "absl/status/statusor.h" +#include "absl/time/clock_interface.h" +#include "crypto/crypto_write_handler.h" +#include "testing/crypto_testing.h" +#include "testing/local_key_crypto_write_handler.h" + +namespace credentio { + +// Returns the default `TestPki` used by `TestClaimSignerCredentials()` and +// `TestTsaCredentials()`. +const TestPki& DefaultTestPki(); + +// Returns the claim signer credentials of `DefaultTestPki()`. +LocalKeyClaimSignerCredentials TestClaimSignerCredentials(); + +// Returns the timestamp authority credentials of `DefaultTestPki()`. +LocalKeyTsaCredentials TestTsaCredentials(); + +struct TestCryptoWriteHandlerOptions { + // If not provided, `absl::Clock::GetRealClock()` is used. + absl::Clock* clock = nullptr; + // If true, timestamping is disabled and returns UNAVAILABLE. + bool disable_timestamping = false; + // Credentials to sign claims with. If not provided, + // TestClaimSignerCredentials() is used. + std::optional<LocalKeyClaimSignerCredentials> claim_signer; + // Credentials to sign timestamps with. If not provided, + // TestTsaCredentials() is used. + std::optional<LocalKeyTsaCredentials> tsa; +}; + +// Creates a CryptoWriteHandler for testing. By default it uses claim and +// timestamp signers from `DefaultTestPki()`; see TestCryptoWriteHandlerOptions +// to supply your own. +absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> +CreateTestCryptoWriteHandler(TestCryptoWriteHandlerOptions options = {}); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_CREATE_TEST_CRYPTO_WRITE_HANDLER_H_
diff --git a/testing/create_test_crypto_write_handler_test.cc b/testing/create_test_crypto_write_handler_test.cc new file mode 100644 index 0000000..caaa26c --- /dev/null +++ b/testing/create_test_crypto_write_handler_test.cc
@@ -0,0 +1,177 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "testing/create_test_crypto_write_handler.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/notification.h" +#include "absl/time/simulated_clock.h" +#include "absl/time/time.h" +#include "cose/simple_cms_parser.h" +#include "crypto/algorithms.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/default/trust_store.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "openssl/pki/verify.h" +#include "testing/local_key_crypto_write_handler.h" +#include "testing/test_validation_tracker.h" +#include "tsp/parsed_timestamp_token.h" +#include "tsp/timestamp_verifier.h" +#include "tsp/verified_timestamp.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::ElementsAreArray; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::Not; + +absl::StatusOr<std::string> GetTimestamp(CryptoWriteHandler& handler, + HashAlgorithm hash_algorithm, + absl::string_view hashed_message) { + absl::Notification done; + absl::StatusOr<std::string> result; + handler.CreateTimestamp(hash_algorithm, std::string(hashed_message), + /*options=*/{}, + [&result, &done](absl::StatusOr<std::string> res) { + result = std::move(res); + done.Notify(); + }); + done.WaitForNotification(); + return result; +} + +TEST(CreateTestCryptoWriteHandlerTest, CreateTimestamp) { + absl::SimulatedClock clock; + absl::Time now = absl::FromUnixSeconds(1739221301); // within validity period + clock.SetTime(now); + std::string message_imprint = "message_imprint"; + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> writer = + CreateTestCryptoWriteHandler({.clock = &clock}); + ABSL_ASSERT_OK(writer); + absl::StatusOr<std::string> created_timestamp = + GetTimestamp(**writer, HashAlgorithm::kSha384, message_imprint); + ABSL_ASSERT_OK(created_timestamp); + + absl::StatusOr<std::unique_ptr<bssl::VerifyTrustStore>> trust_store = + LoadTrustStore(DefaultTestPki().root_ca.cert_pem); + ABSL_ASSERT_OK(trust_store); + TimestampVerifier verifier(trust_store->get()); + SimpleCmsParser parser; + + TestValidationTracker t; + absl::StatusOr<std::unique_ptr<ParsedTimestampToken>> parsed_token = + parser.ParseTimestampToken(*created_timestamp); + ABSL_ASSERT_OK(parsed_token); + absl::StatusOr<VerifiedTimestamp> verified_timestamp = + verifier.VerifyTimestampToken(**parsed_token); + ABSL_ASSERT_OK(verified_timestamp); + ASSERT_THAT(t.GetFailureStatuses(), IsEmpty()); + EXPECT_EQ(verified_timestamp->message_imprint_hash(), message_imprint); + EXPECT_EQ(verified_timestamp->asserted_time(), now); +} + +TEST(CreateTestCryptoWriteHandlerTest, HasCertChainAndOcspResponse) { + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> writer = + CreateTestCryptoWriteHandler({}); + ABSL_ASSERT_OK(writer); + EXPECT_THAT((*writer)->GetSigner().certificate_chain(), Not(IsEmpty())); + EXPECT_THAT((*writer)->GetSigner().ocsp_responses(), Not(IsEmpty())); +} + +TEST(CreateTestCryptoWriteHandlerTest, + UsesConfiguredCertChainAndOcspResponses) { + LocalKeyClaimSignerCredentials credentials = TestClaimSignerCredentials(); + credentials.ocsp_responses.clear(); + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> writer = + CreateTestCryptoWriteHandler({.claim_signer = std::move(credentials)}); + ABSL_ASSERT_OK(writer); + + EXPECT_THAT((*writer)->GetSigner().certificate_chain(), + ElementsAreArray(TestClaimSignerCredentials().certificate_chain)); + EXPECT_THAT((*writer)->GetSigner().ocsp_responses(), IsEmpty()); +} + +TEST(CreateTestCryptoWriteHandlerTest, UsesConfiguredSigningKey) { + // Any other valid EC P-256 key will do; reuse the TSA's. + LocalKeyClaimSignerCredentials credentials = TestClaimSignerCredentials(); + credentials.key_pem = TestTsaCredentials().key_pem; + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> writer = + CreateTestCryptoWriteHandler({.claim_signer = std::move(credentials)}); + ABSL_ASSERT_OK(writer); + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> default_writer = + CreateTestCryptoWriteHandler({}); + ABSL_ASSERT_OK(default_writer); + + absl::StatusOr<SignResponse> response = + (*writer)->GetSigner().Sign("to_be_signed"); + ABSL_ASSERT_OK(response); + absl::StatusOr<SignResponse> default_response = + (*default_writer)->GetSigner().Sign("to_be_signed"); + ABSL_ASSERT_OK(default_response); + EXPECT_NE(response->signature, default_response->signature); +} + +TEST(CreateTestCryptoWriteHandlerTest, UsesConfiguredTsa) { + absl::SimulatedClock clock; + clock.SetTime(absl::FromUnixSeconds(1739221301)); // within validity period + absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> writer = + CreateTestCryptoWriteHandler( + {.clock = &clock, .tsa = TestTsaCredentials()}); + ABSL_ASSERT_OK(writer); + + EXPECT_THAT(GetTimestamp(**writer, HashAlgorithm::kSha384, "message_imprint"), + IsOk()); +} + +TEST(CreateTestCryptoWriteHandlerTest, + RejectsIncompleteClaimSignerCredentials) { + EXPECT_THAT(CreateTestCryptoWriteHandler({.claim_signer = {{}}}), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Claim signer key"))); + + LocalKeyClaimSignerCredentials no_chain = TestClaimSignerCredentials(); + no_chain.certificate_chain.clear(); + EXPECT_THAT( + CreateTestCryptoWriteHandler({.claim_signer = std::move(no_chain)}), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Claim signer certificate chain"))); +} + +TEST(CreateTestCryptoWriteHandlerTest, RejectsIncompleteTsaCredentials) { + EXPECT_THAT( + CreateTestCryptoWriteHandler({.tsa = {{}}}), + StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("TSA key"))); + + LocalKeyTsaCredentials no_cert = TestTsaCredentials(); + no_cert.cert_pem.clear(); + EXPECT_THAT(CreateTestCryptoWriteHandler({.tsa = std::move(no_cert)}), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("TSA certificate"))); +} + +} // namespace +} // namespace credentio
diff --git a/testing/crypto_testing.cc b/testing/crypto_testing.cc index 7df4c10..2038bb4 100644 --- a/testing/crypto_testing.cc +++ b/testing/crypto_testing.cc
@@ -16,125 +16,267 @@ #include "testing/crypto_testing.h" #include <cstddef> +#include <cstdint> #include <string> +#include <utility> -#include "absl/log/check.h" +#include "absl/log/absl_check.h" +#include "absl/strings/string_view.h" #include "absl/time/time.h" #include "openssl/asn1.h" #include "openssl/base.h" +#include "openssl/bio.h" #include "openssl/bn.h" #include "openssl/digest.h" #include "openssl/ec_key.h" #include "openssl/evp.h" +#include "openssl/mem.h" +#include "openssl/nid.h" +#include "openssl/obj.h" +#include "openssl/pem.h" #include "openssl/rsa.h" +#include "openssl/stack.h" #include "openssl/x509.h" namespace credentio { bssl::UniquePtr<EVP_PKEY> CreateTestEcKey(int nid) { bssl::UniquePtr<EC_KEY> key(EC_KEY_new_by_curve_name(nid)); - CHECK_NE(key, nullptr); + ABSL_CHECK_NE(key, nullptr); - CHECK(EC_KEY_generate_key(key.get())); + ABSL_CHECK(EC_KEY_generate_key(key.get())); bssl::UniquePtr<EVP_PKEY> pkey(EVP_PKEY_new()); - CHECK_NE(pkey, nullptr); - CHECK(EVP_PKEY_set1_EC_KEY(pkey.get(), key.get())); + ABSL_CHECK_NE(pkey, nullptr); + ABSL_CHECK(EVP_PKEY_set1_EC_KEY(pkey.get(), key.get())); return pkey; } bssl::UniquePtr<EVP_PKEY> CreateTestRsaKey(size_t num_bits) { bssl::UniquePtr<BIGNUM> rsa_f4(BN_new()); - CHECK_NE(rsa_f4, nullptr); - CHECK(BN_set_word(rsa_f4.get(), RSA_F4)); + ABSL_CHECK_NE(rsa_f4, nullptr); + ABSL_CHECK(BN_set_word(rsa_f4.get(), RSA_F4)); bssl::UniquePtr<RSA> rsa(RSA_new()); - CHECK_NE(rsa, nullptr); + ABSL_CHECK_NE(rsa, nullptr); - CHECK(RSA_generate_key_ex(rsa.get(), num_bits, rsa_f4.get(), nullptr)); + ABSL_CHECK(RSA_generate_key_ex(rsa.get(), num_bits, rsa_f4.get(), nullptr)); bssl::UniquePtr<EVP_PKEY> pkey(EVP_PKEY_new()); - CHECK_NE(pkey, nullptr); - CHECK(EVP_PKEY_set1_RSA(pkey.get(), rsa.get())); + ABSL_CHECK_NE(pkey, nullptr); + ABSL_CHECK(EVP_PKEY_set1_RSA(pkey.get(), rsa.get())); return pkey; } bssl::UniquePtr<EVP_PKEY> CreateTestEdKey() { bssl::UniquePtr<EVP_PKEY_CTX> ctx( EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, nullptr)); - CHECK_NE(ctx, nullptr); - CHECK(EVP_PKEY_keygen_init(ctx.get())); + ABSL_CHECK_NE(ctx, nullptr); + ABSL_CHECK(EVP_PKEY_keygen_init(ctx.get())); EVP_PKEY* pkey = nullptr; - CHECK(EVP_PKEY_keygen(ctx.get(), &pkey)); + ABSL_CHECK(EVP_PKEY_keygen(ctx.get(), &pkey)); return bssl::UniquePtr<EVP_PKEY>(pkey); } bssl::UniquePtr<X509> CreateTestCertificate( const TestCertificateOptions& options) { // Options validation. - CHECK(!options.subject_common_name.empty()); - CHECK(!options.issuer_common_name.empty()); - CHECK(options.signing_key != nullptr); - CHECK(options.private_key != nullptr); + ABSL_CHECK(!options.subject_common_name.empty()); + ABSL_CHECK(!options.issuer_common_name.empty()); + ABSL_CHECK(options.signing_key != nullptr); + ABSL_CHECK(options.private_key != nullptr); // Create the X509 object. bssl::UniquePtr<X509> x509(X509_new()); - CHECK(X509_set_version(x509.get(), 2)); + ABSL_CHECK(X509_set_version(x509.get(), 2)); // Set the not_before/not_after fields. bssl::UniquePtr<ASN1_TIME> not_before(ASN1_TIME_new()); - CHECK(ASN1_TIME_set(not_before.get(), absl::ToTimeT(options.not_before))); - CHECK(X509_set1_notBefore(x509.get(), not_before.get())); + ABSL_CHECK(ASN1_TIME_set_posix(not_before.get(), + absl::ToUnixSeconds(options.not_before))); + ABSL_CHECK(X509_set1_notBefore(x509.get(), not_before.get())); bssl::UniquePtr<ASN1_TIME> not_after(ASN1_TIME_new()); - CHECK(ASN1_TIME_set(not_after.get(), absl::ToTimeT(options.not_after))); - CHECK(X509_set1_notAfter(x509.get(), not_after.get())); + ABSL_CHECK(ASN1_TIME_set_posix(not_after.get(), + absl::ToUnixSeconds(options.not_after))); + ABSL_CHECK(X509_set1_notAfter(x509.get(), not_after.get())); // Set the subject DN. bssl::UniquePtr<X509_NAME> subject_name(X509_NAME_new()); - CHECK(X509_NAME_add_entry_by_txt(subject_name.get(), /*field=*/"CN", - MBSTRING_ASC, - reinterpret_cast<const unsigned char*>( - options.subject_common_name.c_str()), - /*len=*/-1, /*loc=*/-1, - /*set=*/0)); - CHECK(X509_NAME_add_entry_by_txt( + ABSL_CHECK(X509_NAME_add_entry_by_txt( + subject_name.get(), /*field=*/"CN", MBSTRING_ASC, + reinterpret_cast<const unsigned char*>( + options.subject_common_name.c_str()), + /*len=*/-1, /*loc=*/-1, + /*set=*/0)); + ABSL_CHECK(X509_NAME_add_entry_by_txt( subject_name.get(), /*field=*/"O", MBSTRING_ASC, - reinterpret_cast<const unsigned char*>("Google"), + reinterpret_cast<const unsigned char*>("Test Org"), /*len=*/-1, /*loc=*/-1, /*set=*/0)); - CHECK(X509_NAME_add_entry_by_txt( + ABSL_CHECK(X509_NAME_add_entry_by_txt( subject_name.get(), /*field=*/"OU", MBSTRING_ASC, - reinterpret_cast<const unsigned char*>("Google OU"), + reinterpret_cast<const unsigned char*>("Test Unit"), /*len=*/-1, /*loc=*/-1, /*set=*/0)); - CHECK(X509_set_subject_name(x509.get(), subject_name.get())); + ABSL_CHECK(X509_set_subject_name(x509.get(), subject_name.get())); // Set the issuer DN. bssl::UniquePtr<X509_NAME> issuer_name(X509_NAME_new()); - CHECK(X509_NAME_add_entry_by_txt(issuer_name.get(), /*field=*/"CN", - MBSTRING_ASC, - reinterpret_cast<const unsigned char*>( - options.issuer_common_name.c_str()), - /*len=*/-1, /*loc=*/-1, - /*set=*/0)); - CHECK(X509_NAME_add_entry_by_txt( + ABSL_CHECK(X509_NAME_add_entry_by_txt(issuer_name.get(), /*field=*/"CN", + MBSTRING_ASC, + reinterpret_cast<const unsigned char*>( + options.issuer_common_name.c_str()), + /*len=*/-1, /*loc=*/-1, + /*set=*/0)); + ABSL_CHECK(X509_NAME_add_entry_by_txt( issuer_name.get(), /*field=*/"O", MBSTRING_ASC, - reinterpret_cast<const unsigned char*>("Google"), + reinterpret_cast<const unsigned char*>("Test Org"), /*len=*/-1, /*loc=*/-1, /*set=*/0)); - CHECK(X509_NAME_add_entry_by_txt( + ABSL_CHECK(X509_NAME_add_entry_by_txt( issuer_name.get(), /*field=*/"OU", MBSTRING_ASC, - reinterpret_cast<const unsigned char*>("Google OU"), + reinterpret_cast<const unsigned char*>("Test Unit"), /*len=*/-1, /*loc=*/-1, /*set=*/0)); - CHECK(X509_set_issuer_name(x509.get(), issuer_name.get())); + ABSL_CHECK(X509_set_issuer_name(x509.get(), issuer_name.get())); + + if (options.serial_number.has_value()) { + ABSL_CHECK(ASN1_INTEGER_set_int64(X509_get_serialNumber(x509.get()), + *options.serial_number)); + } + + if (options.is_ca.has_value()) { + bssl::UniquePtr<BASIC_CONSTRAINTS> basic_constraints( + BASIC_CONSTRAINTS_new()); + basic_constraints->ca = *options.is_ca ? 1 : 0; + ABSL_CHECK(X509_add1_ext_i2d(x509.get(), NID_basic_constraints, + basic_constraints.get(), /*crit=*/1, + /*flags=*/0)); + + bssl::UniquePtr<ASN1_BIT_STRING> key_usage(ASN1_BIT_STRING_new()); + if (*options.is_ca) { + ABSL_CHECK( + ASN1_BIT_STRING_set_bit(key_usage.get(), /*n=*/5, /*value=*/1)); + ABSL_CHECK( + ASN1_BIT_STRING_set_bit(key_usage.get(), /*n=*/6, /*value=*/1)); + } else { + ABSL_CHECK( + ASN1_BIT_STRING_set_bit(key_usage.get(), /*n=*/0, /*value=*/1)); + } + ABSL_CHECK(X509_add1_ext_i2d(x509.get(), NID_key_usage, key_usage.get(), + /*crit=*/1, /*flags=*/0)); + } + + if (!options.extended_key_usage_oids.empty()) { + bssl::UniquePtr<EXTENDED_KEY_USAGE> eku(EXTENDED_KEY_USAGE_new()); + for (const std::string& oid : options.extended_key_usage_oids) { + bssl::UniquePtr<ASN1_OBJECT> obj( + OBJ_txt2obj(oid.c_str(), /*dont_search_names=*/1)); + ABSL_CHECK(obj != nullptr); + ABSL_CHECK(bssl::PushToStack(eku.get(), std::move(obj))); + } + ABSL_CHECK(X509_add1_ext_i2d(x509.get(), NID_ext_key_usage, eku.get(), + options.extended_key_usage_critical ? 1 : 0, + /*flags=*/0)); + } // Set the public key and sign the certificate. - CHECK(X509_set_pubkey(x509.get(), options.private_key)); - CHECK(X509_sign(x509.get(), options.signing_key, EVP_sha256())); + ABSL_CHECK(X509_set_pubkey(x509.get(), options.private_key)); + ABSL_CHECK(X509_sign(x509.get(), options.signing_key, EVP_sha256())); return x509; } +std::string KeyToPem(EVP_PKEY* key) { + bssl::UniquePtr<BIO> bio(BIO_new(BIO_s_mem())); + ABSL_CHECK(bio != nullptr); + ABSL_CHECK(PEM_write_bio_PrivateKey(bio.get(), key, /*enc=*/nullptr, + /*pass=*/nullptr, /*pass_len=*/0, + /*cb=*/nullptr, /*userdata=*/nullptr)); + const uint8_t* data = nullptr; + size_t len = 0; + ABSL_CHECK(BIO_mem_contents(bio.get(), &data, &len)); + return std::string(reinterpret_cast<const char*>(data), len); +} + +std::string CertToPem(X509* cert) { + bssl::UniquePtr<BIO> bio(BIO_new(BIO_s_mem())); + ABSL_CHECK(bio != nullptr); + ABSL_CHECK(PEM_write_bio_X509(bio.get(), cert)); + const uint8_t* data = nullptr; + size_t len = 0; + ABSL_CHECK(BIO_mem_contents(bio.get(), &data, &len)); + return std::string(reinterpret_cast<const char*>(data), len); +} + +std::string CertToDer(X509* cert) { + int der_len = i2d_X509(cert, nullptr); + ABSL_CHECK_GT(der_len, 0); + std::string der(der_len, '\0'); + unsigned char* p = reinterpret_cast<unsigned char*>(&der[0]); + ABSL_CHECK_EQ(i2d_X509(cert, &p), der_len); + return der; +} + +TestPki CreateTestPki() { + constexpr absl::string_view kRootCaCommonName = "Test Root CA"; + + bssl::UniquePtr<EVP_PKEY> ca_key = CreateTestEcKey(NID_X9_62_prime256v1); + bssl::UniquePtr<X509> ca_cert = CreateTestCertificate({ + .subject_common_name = std::string(kRootCaCommonName), + .issuer_common_name = std::string(kRootCaCommonName), + .signing_key = ca_key.get(), + .private_key = ca_key.get(), + .serial_number = 1, + .is_ca = true, + }); + + bssl::UniquePtr<EVP_PKEY> claim_signer_key = + CreateTestEcKey(NID_X9_62_prime256v1); + bssl::UniquePtr<X509> claim_signer_cert = CreateTestCertificate({ + .subject_common_name = "Test Claim Signer", + .issuer_common_name = std::string(kRootCaCommonName), + .signing_key = ca_key.get(), + .private_key = claim_signer_key.get(), + .serial_number = 2, + .is_ca = false, + .extended_key_usage_oids = {std::string(kC2paClaimSigningEkuOid)}, + }); + + bssl::UniquePtr<EVP_PKEY> tsa_key = CreateTestEcKey(NID_X9_62_prime256v1); + bssl::UniquePtr<X509> tsa_cert = CreateTestCertificate({ + .subject_common_name = "Test TSA", + .issuer_common_name = std::string(kRootCaCommonName), + .signing_key = ca_key.get(), + .private_key = tsa_key.get(), + .serial_number = 3, + .is_ca = false, + // The time stamping EKU must be marked critical per RFC 3161. + .extended_key_usage_oids = {std::string(kTimeStampingEkuOid)}, + .extended_key_usage_critical = true, + }); + + return TestPki{ + .root_ca = + { + .key_pem = KeyToPem(ca_key.get()), + .cert_pem = CertToPem(ca_cert.get()), + .cert_der = CertToDer(ca_cert.get()), + }, + .claim_signer = + { + .key_pem = KeyToPem(claim_signer_key.get()), + .cert_pem = CertToPem(claim_signer_cert.get()), + .cert_der = CertToDer(claim_signer_cert.get()), + }, + .tsa = + { + .key_pem = KeyToPem(tsa_key.get()), + .cert_pem = CertToPem(tsa_cert.get()), + .cert_der = CertToDer(tsa_cert.get()), + }, + }; +} + } // namespace credentio
diff --git a/testing/crypto_testing.h b/testing/crypto_testing.h index 946cf4e..cf21b67 100644 --- a/testing/crypto_testing.h +++ b/testing/crypto_testing.h
@@ -18,21 +18,44 @@ #define THIRD_PARTY_CREDENTIO_TESTING_CRYPTO_TESTING_H_ #include <cstddef> +#include <cstdint> +#include <optional> #include <string> +#include <vector> +#include "absl/strings/string_view.h" #include "absl/time/time.h" #include "openssl/base.h" #include "openssl/x509.h" namespace credentio { +// OID of the C2PA claim signing EKU. See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_trust_lists. +inline constexpr absl::string_view kC2paClaimSigningEkuOid = + "1.3.6.1.4.1.62558.2.1"; +// OID of the `id-kp-timeStamping` EKU (RFC 5280). +inline constexpr absl::string_view kTimeStampingEkuOid = "1.3.6.1.5.5.7.3.8"; + struct TestCertificateOptions { std::string subject_common_name; std::string issuer_common_name; absl::Time not_before = absl::FromUnixSeconds(1); - absl::Time not_after = absl::FromUnixSeconds(86400L * 365 * 100); + absl::Time not_after = absl::FromUnixSeconds(int64_t{86400} * 365 * 100); EVP_PKEY* signing_key = nullptr; EVP_PKEY* private_key = nullptr; + + // If set, the certificate's serial number is set to this value. + std::optional<int64_t> serial_number; + + // If set, adds a critical `basicConstraints` extension with the given `cA` + // value, along with a critical `keyUsage` extension asserting `keyCertSign` + // and `cRLSign` when true, or `digitalSignature` when false. + std::optional<bool> is_ca; + + // If non-empty, adds an `extendedKeyUsage` extension with these OIDs. + std::vector<std::string> extended_key_usage_oids; + bool extended_key_usage_critical = false; }; // Generates an X.509 certificate based on `options`. @@ -45,6 +68,36 @@ bssl::UniquePtr<EVP_PKEY> CreateTestEdKey(); +// Returns the PEM encoding of `key`. CHECK-fails on error. +std::string KeyToPem(EVP_PKEY* key); + +// Returns the PEM encoding of `cert`. CHECK-fails on error. +std::string CertToPem(X509* cert); + +// Returns the DER encoding of `cert`. CHECK-fails on error. +std::string CertToDer(X509* cert); + +// A generated private key with its certificate, in the encodings that C2PA +// APIs consume. +struct TestKeyPair { + std::string key_pem; + std::string cert_pem; + std::string cert_der; +}; + +// A self-signed root CA along with the leaf certificates it issued, suitable +// for exercising C2PA claim signer and TSA trust checks end to end. +struct TestPki { + TestKeyPair root_ca; + // Leaf with the C2PA claim signing EKU, issued by `root_ca`. + TestKeyPair claim_signer; + // Leaf with the time stamping EKU, issued by `root_ca`. + TestKeyPair tsa; +}; + +// Creates a `TestPki` using freshly generated EC P-256 keys. +TestPki CreateTestPki(); + } // namespace credentio #endif // THIRD_PARTY_CREDENTIO_TESTING_CRYPTO_TESTING_H_
diff --git a/testing/fake_manifest_store_generator.h b/testing/fake_manifest_store_generator.h new file mode 100644 index 0000000..e7f8956 --- /dev/null +++ b/testing/fake_manifest_store_generator.h
@@ -0,0 +1,147 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_FAKE_MANIFEST_STORE_GENERATOR_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_FAKE_MANIFEST_STORE_GENERATOR_H_ + +#include <string> +#include <utility> + +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "async/async_context.h" +#include "generator/generator.h" +#include "proto/generator.pb.h" + +namespace credentio { + +// A fake implementation of ManifestStoreGenerator for testing. +// By default, it returns a fixed magic string as the manifest store. +// Allows configuring custom response protos or injecting error status. +class FakeManifestStoreGenerator : public ManifestStoreGenerator { + public: + static constexpr absl::string_view kDefaultManifestStore = + "fake_manifest_bytes_magic_string"; + + explicit FakeManifestStoreGenerator( + absl::string_view manifest_store = kDefaultManifestStore) { + set_manifest_store(std::string(manifest_store)); + } + + void set_manifest_store(std::string manifest_store) { + generate_response_.set_manifest_store(manifest_store); + template_.set_manifest_store_size(manifest_store.size()); + finalize_response_.set_manifest_store(std::move(manifest_store)); + } + + const std::string& manifest_store() const { + return finalize_response_.manifest_store(); + } + + void set_generate_manifest_store_response( + GenerateManifestStoreResponse response) { + generate_response_ = std::move(response); + } + + GenerateManifestStoreResponse* mutable_generate_manifest_store_response() { + return &generate_response_; + } + + const GenerateManifestStoreResponse& generate_manifest_store_response() + const { + return generate_response_; + } + + void set_manifest_store_template(ManifestStoreTemplate response) { + template_ = std::move(response); + } + + ManifestStoreTemplate* mutable_manifest_store_template() { + return &template_; + } + + const ManifestStoreTemplate& manifest_store_template() const { + return template_; + } + + void set_finalize_manifest_store_response( + FinalizeManifestStoreResponse response) { + finalize_response_ = std::move(response); + } + + FinalizeManifestStoreResponse* mutable_finalize_manifest_store_response() { + return &finalize_response_; + } + + const FinalizeManifestStoreResponse& finalize_manifest_store_response() + const { + return finalize_response_; + } + + void set_status(absl::Status status) { status_ = std::move(status); } + + void set_fail_generate_manifest_store(bool fail) { + if (fail) { + status_ = absl::InternalError("Manifest generation failed."); + } else { + status_ = absl::OkStatus(); + } + } + + void GenerateManifestStore( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&> + cb) const override { + if (!status_.ok()) { + std::move(cb)(status_); + return; + } + std::move(cb)(generate_response_); + } + + void GenerateManifestStoreTemplate( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb) + const override { + if (!status_.ok()) { + std::move(cb)(status_); + return; + } + std::move(cb)(template_); + } + + void FinalizeManifestStore( + FinalizeManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&> + cb) const override { + if (!status_.ok()) { + std::move(cb)(status_); + return; + } + std::move(cb)(finalize_response_); + } + + private: + GenerateManifestStoreResponse generate_response_; + ManifestStoreTemplate template_; + FinalizeManifestStoreResponse finalize_response_; + absl::Status status_ = absl::OkStatus(); +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_FAKE_MANIFEST_STORE_GENERATOR_H_
diff --git a/testing/local_key_crypto_write_handler.cc b/testing/local_key_crypto_write_handler.cc new file mode 100644 index 0000000..944bb4f --- /dev/null +++ b/testing/local_key_crypto_write_handler.cc
@@ -0,0 +1,270 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "testing/local_key_crypto_write_handler.h" + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/no_destructor.h" +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/log/absl_check.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/mutex.h" +#include "absl/time/clock_interface.h" +#include "absl/time/time.h" +#include "async/async_context.h" +#include "crypto/algorithms.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/timestamp_write_handler.h" +#include "tink/insecure_secret_key_access.h" +#include "tink/key_status.h" +#include "tink/keyset_handle.h" +#include "tink/partial_key_access.h" +#include "tink/pem/signature_key_parser.h" +#include "tink/public_key_sign.h" +#include "tink/signature/config_v0.h" +#include "tink/signature/ecdsa_parameters.h" +#include "tink/signature/ecdsa_private_key.h" +#include "tink/signature/signature_config.h" +#include "tsp/test_helpers.h" + +namespace credentio { +namespace { + +using ::crypto::tink::EcdsaParameters; +using ::crypto::tink::EcdsaPrivateKey; +using ::crypto::tink::GetPartialKeyAccess; +using ::crypto::tink::InsecureSecretKeyAccess; +using ::crypto::tink::KeysetHandle; +using ::crypto::tink::KeysetHandleBuilder; +using ::crypto::tink::KeyStatus; +using ::crypto::tink::PublicKeySign; +using ::crypto::tink::SignatureConfig; + +// Extra bytes reserved on top of a measured signature or timestamp size, since +// DER-encoded ECDSA signatures vary in length. +constexpr uint64_t kSizeMargin = 10; + +// OCSP responses are dropped once this much time has passed since the signer +// was created. +constexpr absl::Duration kOcspResponseLifetime = absl::Hours(24 * 7); + +// Returns the OID of `hash_algorithm` (see RFC 5754). +absl::StatusOr<absl::string_view> HashAlgorithmOid( + HashAlgorithm hash_algorithm) { + switch (hash_algorithm) { + case HashAlgorithm::kSha256: + return "2.16.840.1.101.3.4.2.1"; + case HashAlgorithm::kSha384: + return "2.16.840.1.101.3.4.2.2"; + case HashAlgorithm::kSha512: + return "2.16.840.1.101.3.4.2.3"; + default: + return absl::InvalidArgumentError( + absl::StrCat("Unsupported hash algorithm: ", hash_algorithm)); + } +} + +// Returns an ES256 signer for the PEM-encoded EC P-256 private key `key_pem`. +absl::StatusOr<std::unique_ptr<PublicKeySign>> CreateEs256Signer( + absl::string_view key_pem, + EcdsaParameters::SignatureEncoding signature_encoding) { + // Populates the global registry that the config-less + // `KeysetHandleBuilder::Build()` below relies on. + static const absl::NoDestructor<absl::Status> registration_status( + SignatureConfig::Register()); + ABSL_RETURN_IF_ERROR(*registration_status); + + ABSL_ASSIGN_OR_RETURN(EcdsaParameters parameters, + EcdsaParameters::Builder() + .SetCurveType(EcdsaParameters::CurveType::kNistP256) + .SetHashType(EcdsaParameters::HashType::kSha256) + .SetSignatureEncoding(signature_encoding) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build()); + ABSL_ASSIGN_OR_RETURN(EcdsaPrivateKey private_key, + tink_pem::PemToEcdsaPrivateKey( + key_pem, parameters, InsecureSecretKeyAccess::Get(), + GetPartialKeyAccess())); + ABSL_ASSIGN_OR_RETURN( + KeysetHandle keyset_handle, + KeysetHandleBuilder() + .AddEntry(KeysetHandleBuilder::Entry::CreateFromCopyableKey( + std::move(private_key), KeyStatus::kEnabled, + /*is_primary=*/true)) + .Build()); + return keyset_handle.GetPrimitive<PublicKeySign>( + crypto::tink::ConfigSignatureV0()); +} + +// Returns a DER-encoded RFC 3161 TimeStampToken over `hashed_message`, signed +// by `tsa_signer` at the current time on `clock`. +absl::StatusOr<std::string> CreateTimeStampToken( + HashAlgorithm hash_algorithm, absl::string_view hashed_message, + absl::Clock& clock, const PublicKeySign& tsa_signer, + absl::string_view tsa_cert_pem) { + ABSL_ASSIGN_OR_RETURN(absl::string_view oid, + HashAlgorithmOid(hash_algorithm)); + const std::string gen_time = + absl::FormatTime("%Y%m%d%H%M%SZ", clock.TimeNow(), absl::UTCTimeZone()); + const std::string tst_info = CreateTstInfo(gen_time, hashed_message, oid); + return CreateTimeStampTokenEcdsa({tst_info}, tsa_signer, tsa_cert_pem, + /*embed_cert=*/true); +} + +} // namespace + +LocalKeySigner::LocalKeySigner( + std::unique_ptr<PublicKeySign> absl_nonnull signer, + std::vector<std::string> certificate_chain, + std::vector<std::string> ocsp_responses, absl::Clock* absl_nonnull clock, + SigningAlgorithm algorithm) + : signer_(std::move(ABSL_DIE_IF_NULL(signer))), + certificate_chain_(std::move(certificate_chain)), + ocsp_responses_(std::move(ocsp_responses)), + clock_(ABSL_DIE_IF_NULL(clock)), + create_time_(clock_->TimeNow()), + algorithm_(algorithm) {} + +const std::vector<std::string>& LocalKeySigner::ocsp_responses() const { + if (clock_->TimeNow() > create_time_ + kOcspResponseLifetime) { + static const absl::NoDestructor<std::vector<std::string>> kNoResponses; + return *kNoResponses; + } + return ocsp_responses_; +} + +uint64_t LocalKeySigner::max_signature_byte_size() const { + absl::MutexLock lock(mutex_); + if (!max_signature_byte_size_.has_value()) { + // The interface cannot report errors, and a signer that cannot sign is + // unusable, so crash. + absl::StatusOr<SignResponse> response = Sign("measure signature size"); + ABSL_CHECK_OK(response); + max_signature_byte_size_ = response->signature.size() + kSizeMargin; + } + return *max_signature_byte_size_; +} + +absl::StatusOr<SignResponse> LocalKeySigner::Sign( + absl::string_view to_be_signed) const { + ABSL_ASSIGN_OR_RETURN(std::string signature, signer_->Sign(to_be_signed)); + return SignResponse{.signature = std::move(signature)}; +} + +LocalKeyCryptoWriteHandler::LocalKeyCryptoWriteHandler( + absl::Clock* absl_nonnull clock, + std::unique_ptr<PublicKeySign> absl_nonnull signer, + std::unique_ptr<PublicKeySign> absl_nullable tsa_signer, + bool is_timestamping_disabled, std::vector<std::string> certificate_chain, + std::vector<std::string> ocsp_responses, SigningAlgorithm algorithm, + std::string tsa_cert_pem) + : clock_(ABSL_DIE_IF_NULL(clock)), + signer_(std::move(signer), std::move(certificate_chain), + std::move(ocsp_responses), clock, algorithm), + tsa_signer_(std::move(tsa_signer)), + is_timestamping_disabled_(is_timestamping_disabled), + tsa_cert_pem_(std::move(tsa_cert_pem)) {} + +void LocalKeyCryptoWriteHandler::CreateTimestamp( + HashAlgorithm hash_algorithm, std::string hashed_message, + TimestampWriteHandler::Options options, + absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> cb) const { + AsyncResolver<std::string> resolver(options.async_context, std::move(cb)); + if (is_timestamping_disabled_ || tsa_signer_ == nullptr) { + resolver.Return(absl::UnavailableError("TSA is disabled.")); + return; + } + resolver.Return(CreateTimeStampToken(hash_algorithm, hashed_message, *clock_, + *tsa_signer_, tsa_cert_pem_)); +} + +uint64_t LocalKeyCryptoWriteHandler::max_timestamp_byte_size( + HashAlgorithm hash_algorithm) const { + if (tsa_signer_ == nullptr) { + return 0; + } + absl::MutexLock lock(mutex_); + auto [it, inserted] = + max_timestamp_byte_size_map_.try_emplace(hash_algorithm); + if (inserted) { + // The interface cannot report errors, and a TSA that cannot sign is + // unusable, so crash. + absl::StatusOr<std::string> token = CreateTimeStampToken( + hash_algorithm, std::string(HashSizeBytes(hash_algorithm), '\0'), + *clock_, *tsa_signer_, tsa_cert_pem_); + ABSL_CHECK_OK(token); + it->second = token->size() + kSizeMargin; + } + return it->second; +} + +absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> +CreateLocalKeyCryptoWriteHandler(LocalKeyCryptoWriteHandlerOptions options) { + if (options.claim_signer.key_pem.empty()) { + return absl::InvalidArgumentError("Claim signer key is missing."); + } + if (options.claim_signer.certificate_chain.empty()) { + return absl::InvalidArgumentError( + "Claim signer certificate chain is missing."); + } + if (!options.disable_timestamping) { + if (options.tsa.key_pem.empty()) { + return absl::InvalidArgumentError("TSA key is missing."); + } + if (options.tsa.cert_pem.empty()) { + return absl::InvalidArgumentError("TSA certificate is missing."); + } + } else if (options.tsa.key_pem.empty() != options.tsa.cert_pem.empty()) { + return absl::InvalidArgumentError( + "TSA key and certificate must either both be provided or both be " + "omitted."); + } + + // COSE requires raw (IEEE P1363) ECDSA signatures, while CMS in RFC 3161 + // timestamps requires DER. + ABSL_ASSIGN_OR_RETURN( + std::unique_ptr<PublicKeySign> signer, + CreateEs256Signer(options.claim_signer.key_pem, + EcdsaParameters::SignatureEncoding::kIeeeP1363)); + std::unique_ptr<PublicKeySign> tsa_signer; + if (!options.tsa.key_pem.empty()) { + ABSL_ASSIGN_OR_RETURN( + tsa_signer, + CreateEs256Signer(options.tsa.key_pem, + EcdsaParameters::SignatureEncoding::kDer)); + } + absl::Clock* clock = + options.clock != nullptr ? options.clock : &absl::Clock::GetRealClock(); + return std::make_unique<LocalKeyCryptoWriteHandler>( + clock, std::move(signer), std::move(tsa_signer), + options.disable_timestamping, + std::move(options.claim_signer.certificate_chain), + std::move(options.claim_signer.ocsp_responses), SigningAlgorithm::kEs256, + std::move(options.tsa.cert_pem)); +} + +} // namespace credentio
diff --git a/testing/local_key_crypto_write_handler.h b/testing/local_key_crypto_write_handler.h new file mode 100644 index 0000000..b851e37 --- /dev/null +++ b/testing/local_key_crypto_write_handler.h
@@ -0,0 +1,174 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_LOCAL_KEY_CRYPTO_WRITE_HANDLER_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_LOCAL_KEY_CRYPTO_WRITE_HANDLER_H_ + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/base/thread_annotations.h" +#include "absl/container/flat_hash_map.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/mutex.h" +#include "absl/time/clock_interface.h" +#include "absl/time/time.h" +#include "crypto/algorithms.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/timestamp_write_handler.h" +#include "tink/public_key_sign.h" + +namespace credentio { + +// Test signer that uses a fixed key and a caller-supplied certificate chain. +// +// To mimic OCSP responses going stale, `ocsp_responses()` returns an empty list +// once a week has passed on `clock` since construction. +// +// This class is thread-safe. +class LocalKeySigner final : public FixedKeySigner { + public: + // `certificate_chain` and `ocsp_responses` are DER-encoded, leaf first. + // `clock` must outlive this object. + LocalKeySigner( + std::unique_ptr<crypto::tink::PublicKeySign> absl_nonnull signer, + std::vector<std::string> certificate_chain, + std::vector<std::string> ocsp_responses, absl::Clock* absl_nonnull clock, + SigningAlgorithm algorithm = SigningAlgorithm::kEs256); + + LocalKeySigner(const LocalKeySigner&) = delete; + LocalKeySigner& operator=(const LocalKeySigner&) = delete; + + SigningAlgorithm algorithm() const override { return algorithm_; } + const std::vector<std::string>& certificate_chain() const override { + return certificate_chain_; + } + const std::vector<std::string>& ocsp_responses() const override; + uint64_t max_signature_byte_size() const override; + absl::StatusOr<SignResponse> Sign( + absl::string_view to_be_signed) const override; + + private: + const std::unique_ptr<crypto::tink::PublicKeySign> absl_nonnull signer_; + const std::vector<std::string> certificate_chain_; + const std::vector<std::string> ocsp_responses_; + absl::Clock* absl_nonnull const clock_; + const absl::Time create_time_; + const SigningAlgorithm algorithm_; + + mutable absl::Mutex mutex_; + mutable std::optional<uint64_t> max_signature_byte_size_ + ABSL_GUARDED_BY(mutex_); +}; + +// Test implementation of CryptoWriteHandler that uses a fixed claim signer and +// a fixed timestamp signer, both supplied by the caller. +// +// For local testing only: keys are held in process memory and timestamps are +// issued locally from the supplied TSA key rather than by an RFC 3161 +// time-stamping authority. +// +// This class is thread-safe. +class LocalKeyCryptoWriteHandler final : public CryptoWriteHandler { + public: + // `clock` must outlive this object. If `tsa_signer` is null or + // `is_timestamping_disabled` is true, `CreateTimestamp()` fails with + // UNAVAILABLE. `tsa_cert_pem` is the PEM-encoded certificate of `tsa_signer`. + LocalKeyCryptoWriteHandler( + absl::Clock* absl_nonnull clock, + std::unique_ptr<crypto::tink::PublicKeySign> absl_nonnull signer, + std::unique_ptr<crypto::tink::PublicKeySign> absl_nullable tsa_signer, + bool is_timestamping_disabled, std::vector<std::string> certificate_chain, + std::vector<std::string> ocsp_responses, SigningAlgorithm algorithm, + std::string tsa_cert_pem); + + LocalKeyCryptoWriteHandler(const LocalKeyCryptoWriteHandler&) = delete; + LocalKeyCryptoWriteHandler& operator=(const LocalKeyCryptoWriteHandler&) = + delete; + + const FixedKeySigner& GetSigner() override { return signer_; } + + void CreateTimestamp(HashAlgorithm hash_algorithm, std::string hashed_message, + TimestampWriteHandler::Options options, + absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> + cb) const override; + + uint64_t max_timestamp_byte_size(HashAlgorithm hash_algorithm) const override; + + private: + bool is_timestamping_enabled() const { + return !is_timestamping_disabled_ && tsa_signer_ != nullptr; + } + + absl::Clock* absl_nonnull const clock_; + LocalKeySigner signer_; + const std::unique_ptr<crypto::tink::PublicKeySign> absl_nullable tsa_signer_; + const bool is_timestamping_disabled_; + const std::string tsa_cert_pem_; + + mutable absl::Mutex mutex_; + mutable absl::flat_hash_map<HashAlgorithm, uint64_t> + max_timestamp_byte_size_map_ ABSL_GUARDED_BY(mutex_); +}; + +// Credentials for the test claim signer. +struct LocalKeyClaimSignerCredentials { + // The signing key, as a PEM-encoded EC P-256 private key. + std::string key_pem; + // The certificate chain for `key_pem`, DER-encoded, leaf first. + std::vector<std::string> certificate_chain; + // DER-encoded OCSP responses covering `certificate_chain`. May be empty. + std::vector<std::string> ocsp_responses; +}; + +// Credentials for the test timestamp authority. +struct LocalKeyTsaCredentials { + // The signing key, as a PEM-encoded EC P-256 private key. + std::string key_pem; + // The certificate for `key_pem`, PEM-encoded. + std::string cert_pem; +}; + +// Options for `CreateLocalKeyCryptoWriteHandler()`. +struct LocalKeyCryptoWriteHandlerOptions { + // Credentials to sign claims with. Required. + LocalKeyClaimSignerCredentials claim_signer; + // Credentials to sign timestamps with. Required unless + // `disable_timestamping` is true. + LocalKeyTsaCredentials tsa; + // The clock to read the signing and timestamping time from. It must be set + // to a time within the validity period of the certificates above, and must + // outlive the returned handler. If null, the real clock is used. + absl::Clock* absl_nullable clock = nullptr; + // If true, timestamping is disabled and returns UNAVAILABLE. + bool disable_timestamping = false; +}; + +// Creates a CryptoWriteHandler that signs claims with ES256 and timestamps with +// the given test credentials. It performs no validation of its own, so the +// caller is responsible for pairing the keys with certificates that their +// verifier trusts. +absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> +CreateLocalKeyCryptoWriteHandler(LocalKeyCryptoWriteHandlerOptions options); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_LOCAL_KEY_CRYPTO_WRITE_HANDLER_H_
diff --git a/testing/manifest_store_test_utils.cc b/testing/manifest_store_test_utils.cc new file mode 100644 index 0000000..dc56727 --- /dev/null +++ b/testing/manifest_store_test_utils.cc
@@ -0,0 +1,138 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "testing/manifest_store_test_utils.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_join.h" +#include "absl/strings/string_view.h" +#include "claim/local_signer.h" +#include "claim/validator.h" +#include "cose/verifier.h" +#include "crypto/crypto_read_handler.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/default/default_crypto_read_handler.h" +#include "crypto/default/hasher.h" +#include "generator/generator.h" +#include "generator/generator_factory.h" +#include "jumbf/box.h" +#include "jumbf/parse.h" +#include "proto/generator.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "testing/create_test_crypto_write_handler.h" +#include "testing/proto_test_utils.h" +#include "testing/test_validation_tracker.h" +#include "validator/manifest_store_validator_impl.h" + +namespace credentio { + +absl::StatusOr<std::string> CreateSimpleManifestStoreBytes( + const SimpleManifestStoreOptions& options) { + ABSL_ASSIGN_OR_RETURN( + std::unique_ptr<CryptoWriteHandler> crypto_write_handler, + CreateTestCryptoWriteHandler({ + .clock = options.clock, + .disable_timestamping = options.disable_timestamping, + })); + LocalClaimSigner claim_signer(crypto_write_handler.get(), + &DefaultHasherFactory()); + GeneratorInfo generator_info; + if (options.generator_info.has_value()) { + generator_info = *options.generator_info; + } else { + generator_info.set_name("Test C2PA Generator"); + } + ABSL_ASSIGN_OR_RETURN( + std::unique_ptr<ManifestStoreGenerator> manifest_store_generator, + CreateGenerator({ + .generator_info = std::move(generator_info), + .signing_claim_signer = &claim_signer, + .template_claim_signer = &claim_signer, + .uuid_generator = options.uuid_generator, + })); + auto request = credentio_testing::ParseTextProtoOrDie< + GenerateManifestStoreRequest>(R"pb( + instance_id: "12345678-1234-1234-1234-123456789012" + hard_binding { + box_hash { + alg: "some alg" + boxes { names: "some name" names: "other name" hash: "some hash" } + } + } + actions { + created { + action_metadata { + software_agent { name: "test generator" } + description: "created for testing" + } + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"); + ABSL_ASSIGN_OR_RETURN( + auto generated, manifest_store_generator->GenerateManifestStore(request)); + return std::string(generated.manifest_store()); +} + +absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> +ValidateManifestStore(absl::string_view manifest_store_bytes) { + ABSL_ASSIGN_OR_RETURN( + std::unique_ptr<CryptoReadHandler> crypto_read_handler, + CreateDefaultCryptoReadHandler({ + .trust_environment = CryptoReadHandler::TrustEnvironment::kAny, + })); + ManifestStoreValidatorImpl validator({ + .crypto_read_handler = std::move(crypto_read_handler), + }); + return validator.Validate(manifest_store_bytes); +} + +absl::StatusOr<Claim> ValidateClaim(absl::string_view manifest_store_bytes) { + ABSL_ASSIGN_OR_RETURN(auto manifest_store_box, + jumbf::ConsumeSuperBox(&manifest_store_bytes, 9)); + ABSL_ASSIGN_OR_RETURN( + std::unique_ptr<CryptoReadHandler> crypto_read_handler, + CreateDefaultCryptoReadHandler({ + .trust_environment = CryptoReadHandler::TrustEnvironment::kAny, + })); + std::unique_ptr<CoseVerifier> cose_verifier = CreateCoseVerifier( + {.crypto_read_handler = std::move(crypto_read_handler)}); + ClaimValidatorImpl validator(cose_verifier.get()); + TestValidationTracker validation_tracker; + if (manifest_store_box.contents.empty()) { + return absl::InvalidArgumentError("Manifest store is empty"); + } + if (!manifest_store_box.contents.back().Holds<jumbf::SuperBox>()) { + return absl::InvalidArgumentError("Last box is not a super box"); + } + jumbf::SuperBox manifest_box = + manifest_store_box.contents.back().Get<jumbf::SuperBox>(); + auto result = validator.Validate(manifest_box, validation_tracker.tracker()); + if (!validation_tracker.GetFailures().empty()) { + return absl::InvalidArgumentError( + absl::StrJoin(validation_tracker.GetFailureStatuses(), ";")); + } + return result.value(); +} + +} // namespace credentio
diff --git a/testing/manifest_store_test_utils.h b/testing/manifest_store_test_utils.h new file mode 100644 index 0000000..73b857d --- /dev/null +++ b/testing/manifest_store_test_utils.h
@@ -0,0 +1,59 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// Utilities for creating and validating C2PA manifest stores in tests. +#ifndef THIRD_PARTY_CREDENTIO_TESTING_MANIFEST_STORE_TEST_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_MANIFEST_STORE_TEST_UTILS_H_ + +#include <memory> +#include <optional> +#include <string> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/time/clock_interface.h" +#include "proto/generator_info.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "uuid/uuid.h" + +namespace credentio { + +struct SimpleManifestStoreOptions { + // If omitted, default test claim generator info will be used. + std::optional<GeneratorInfo> generator_info = std::nullopt; + // Optional. If not provided, `absl::Clock::GetRealClock()` is used. + absl::Clock* clock = nullptr; + // Optional. If not provided, a default UuidGenerator will be used. + const UuidGenerator* uuid_generator = &UuidGenerator::Default(); + // If true, timestamping is disabled. + bool disable_timestamping = false; +}; + +// Creates a simple manifest store bytes for testing. +absl::StatusOr<std::string> CreateSimpleManifestStoreBytes( + const SimpleManifestStoreOptions& options = {}); + +// Validates the given manifest store bytes by returning the partial validation +// result. +absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> +ValidateManifestStore(absl::string_view manifest_store_bytes); + +// Validates the claim in the given manifest store bytes and returns the claim. +absl::StatusOr<Claim> ValidateClaim(absl::string_view manifest_store_bytes); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_MANIFEST_STORE_TEST_UTILS_H_
diff --git a/testing/nonprod_generator.cc b/testing/nonprod_generator.cc new file mode 100644 index 0000000..6fa099a --- /dev/null +++ b/testing/nonprod_generator.cc
@@ -0,0 +1,135 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "testing/nonprod_generator.h" + +#include <memory> +#include <utility> + +#include "absl/functional/any_invocable.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "async/async_context.h" +#include "claim/local_signer.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "crypto/templated_crypto_write_handler.h" +#include "generator/generator.h" +#include "generator/generator_factory.h" +#include "generator/generator_options.h" +#include "proto/generator.pb.h" +#include "proto/generator_info.pb.h" +#include "testing/create_test_crypto_write_handler.h" + +namespace credentio { +namespace { + +class NonProdManifestStoreGenerator : public ManifestStoreGenerator { + public: + NonProdManifestStoreGenerator( + std::unique_ptr<CryptoWriteHandler> signing_handler, + std::unique_ptr<TemplatedHasherFactory> template_hash_factory, + std::unique_ptr<TemplatedCryptoWriteHandler> + template_crypto_write_handler, + std::unique_ptr<LocalClaimSigner> template_claim_signer, + std::unique_ptr<LocalClaimSigner> signing_claim_signer, + std::unique_ptr<ManifestStoreGenerator> generator) + : signing_handler_(std::move(signing_handler)), + template_hash_factory_(std::move(template_hash_factory)), + template_crypto_write_handler_( + std::move(template_crypto_write_handler)), + template_claim_signer_(std::move(template_claim_signer)), + signing_claim_signer_(std::move(signing_claim_signer)), + generator_(std::move(generator)) {} + + void GenerateManifestStore( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&> + cb) const override { + generator_->GenerateManifestStore(std::move(request), std::move(context), + std::move(cb)); + } + + void GenerateManifestStoreTemplate( + GenerateManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb) + const override { + generator_->GenerateManifestStoreTemplate( + std::move(request), std::move(context), std::move(cb)); + } + + void FinalizeManifestStore( + FinalizeManifestStoreRequest request, AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&> + cb) const override { + generator_->FinalizeManifestStore(std::move(request), std::move(context), + std::move(cb)); + } + + private: + std::unique_ptr<CryptoWriteHandler> signing_handler_; + std::unique_ptr<TemplatedHasherFactory> template_hash_factory_; + std::unique_ptr<TemplatedCryptoWriteHandler> template_crypto_write_handler_; + std::unique_ptr<LocalClaimSigner> template_claim_signer_; + std::unique_ptr<LocalClaimSigner> signing_claim_signer_; + std::unique_ptr<ManifestStoreGenerator> generator_; +}; + +} // namespace + +absl::StatusOr<std::unique_ptr<ManifestStoreGenerator>> +CreateNonProdManifestStoreGenerator( + const NonProdManifestStoreGeneratorOptions& options) { + ABSL_ASSIGN_OR_RETURN( + std::unique_ptr<CryptoWriteHandler> crypto_write_handler, + CreateTestCryptoWriteHandler({ + .clock = options.clock, + .disable_timestamping = options.disable_timestamping, + })); + + const HasherFactory& hash_factory = DefaultHasherFactory(); + auto template_hash_factory = + std::make_unique<TemplatedHasherFactory>(hash_factory.algorithm()); + auto template_crypto_write_handler = + std::make_unique<TemplatedCryptoWriteHandler>(crypto_write_handler.get()); + auto template_claim_signer = std::make_unique<LocalClaimSigner>( + template_crypto_write_handler.get(), template_hash_factory.get()); + auto signing_claim_signer = std::make_unique<LocalClaimSigner>( + crypto_write_handler.get(), &hash_factory); + + GeneratorOptions generator_options; + if (options.generator_info.has_value()) { + generator_options.generator_info = *options.generator_info; + } else { + generator_options.generator_info.set_name("Google NonProd C2PA Generator"); + } + generator_options.signing_claim_signer = signing_claim_signer.get(); + generator_options.template_claim_signer = template_claim_signer.get(); + generator_options.hash_factory = &hash_factory; + if (options.uuid_generator != nullptr) { + generator_options.uuid_generator = options.uuid_generator; + } + + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<ManifestStoreGenerator> generator, + CreateGenerator(std::move(generator_options))); + return std::make_unique<NonProdManifestStoreGenerator>( + std::move(crypto_write_handler), std::move(template_hash_factory), + std::move(template_crypto_write_handler), + std::move(template_claim_signer), std::move(signing_claim_signer), + std::move(generator)); +} + +} // namespace credentio
diff --git a/testing/nonprod_generator.h b/testing/nonprod_generator.h new file mode 100644 index 0000000..44cdbfe --- /dev/null +++ b/testing/nonprod_generator.h
@@ -0,0 +1,52 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_NONPROD_GENERATOR_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_NONPROD_GENERATOR_H_ + +#include <memory> +#include <optional> + +#include "absl/status/statusor.h" +#include "absl/time/clock_interface.h" +#include "generator/generator.h" +#include "proto/generator_info.pb.h" +#include "uuid/uuid.h" + +namespace credentio { + +struct NonProdManifestStoreGeneratorOptions { + // If omitted, fake claim generator info will be used. + std::optional<GeneratorInfo> generator_info = std::nullopt; + // Optional. If not provided, the system clock will be used. + absl::Clock* clock = nullptr; + // Optional. If not provided, a default UuidGenerator will be used. + const UuidGenerator* uuid_generator = &UuidGenerator::Default(); + // If true, timestamping is disabled. + bool disable_timestamping = false; +}; + +// WARNING: DO NOT USE IN PRODUCTION. +// WARNING: This signs with UNTRUSTED keys. +// +// Creates a non-prod version of ManifestStoreGenerator, using claim and +// timestamp signers whose certificates are issued by `DefaultTestPki()`. +absl::StatusOr<std::unique_ptr<ManifestStoreGenerator>> +CreateNonProdManifestStoreGenerator( + const NonProdManifestStoreGeneratorOptions& options = {}); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_NONPROD_GENERATOR_H_
diff --git a/testing/nonprod_validator.cc b/testing/nonprod_validator.cc new file mode 100644 index 0000000..c533e20 --- /dev/null +++ b/testing/nonprod_validator.cc
@@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "testing/nonprod_validator.h" + +#include <memory> +#include <utility> + +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "crypto/crypto_read_handler.h" +#include "crypto/default/default_crypto_read_handler.h" +#include "testing/create_test_crypto_write_handler.h" +#include "testing/crypto_testing.h" +#include "validator/asset_validator.h" +#include "validator/asset_validator_impl.h" +#include "validator/validator_options.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<AssetValidator>> CreateNonProdValidator( + const NonProdValidatorOptions& options) { + const TestPki& pki = DefaultTestPki(); + DefaultCryptoReadHandlerOptions crypto_options; + crypto_options.claim_signer_trust_anchors_pem = pki.root_ca.cert_pem; + crypto_options.tsa_trust_anchors_pem = pki.root_ca.cert_pem; + crypto_options.clock = options.clock; + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<CryptoReadHandler> crypto_read_handler, + CreateDefaultCryptoReadHandler(crypto_options)); + return std::make_unique<AssetValidatorImpl>(ValidatorOptions{ + .crypto_read_handler = std::move(crypto_read_handler), + .clock = options.clock, + .max_chunk_bytes = options.max_chunk_bytes, + }); +} + +} // namespace credentio
diff --git a/testing/nonprod_validator.h b/testing/nonprod_validator.h new file mode 100644 index 0000000..b24358f --- /dev/null +++ b/testing/nonprod_validator.h
@@ -0,0 +1,47 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_NONPROD_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_NONPROD_VALIDATOR_H_ + +#include <cstdint> +#include <memory> + +#include "absl/status/statusor.h" +#include "absl/time/clock_interface.h" +#include "validator/asset_validator.h" + +namespace credentio { + +struct NonProdValidatorOptions { + // See ValidatorOptions::max_chunk_bytes in + // credentio/validator/validator_options.h. + int64_t max_chunk_bytes = 1048576; // 1 MiB + + // Clock to use for checking certificate validity. + // Should only be overridden for testing. + absl::Clock* clock = &absl::Clock::GetRealClock(); +}; + +// Creates a new `AssetValidator` instance with the given `options`, trusting +// certificates issued by `DefaultTestPki()`. +// +// Output of this implementation *must not be considered trustworthy*. +absl::StatusOr<std::unique_ptr<AssetValidator>> CreateNonProdValidator( + const NonProdValidatorOptions& options = {}); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_NONPROD_VALIDATOR_H_
diff --git a/testing/pem.cc b/testing/pem.cc index 9844e16..4dea5ac 100644 --- a/testing/pem.cc +++ b/testing/pem.cc
@@ -15,11 +15,12 @@ #include "testing/pem.h" -#include <cstdint> #include <memory> #include <string> -#include <utility> +#include <variant> +#include "absl/base/no_destructor.h" +#include "absl/functional/overload.h" #include "absl/status/status.h" #include "absl/status/status_macros.h" #include "absl/status/statusor.h" @@ -33,31 +34,77 @@ #include "openssl/obj.h" #include "openssl/pem.h" #include "openssl/x509.h" -#include "proto/common.pb.h" -#include "tink/cleartext_keyset_handle.h" +#include "tink/insecure_secret_key_access.h" +#include "tink/key_status.h" #include "tink/keyset_handle.h" +#include "tink/partial_key_access.h" +#include "tink/pem/signature_key_parser.h" #include "tink/public_key_sign.h" #include "tink/signature/config_v0.h" -#include "tink/signature/signature_pem_keyset_reader.h" +#include "tink/signature/ecdsa_parameters.h" +#include "tink/signature/ecdsa_private_key.h" +#include "tink/signature/ed25519_parameters.h" +#include "tink/signature/ed25519_private_key.h" +#include "tink/signature/rsa_ssa_pss_parameters.h" +#include "tink/signature/rsa_ssa_pss_private_key.h" +#include "tink/signature/signature_config.h" namespace credentio { -using ::crypto::tink::PemKeyParams; +using ::crypto::tink::EcdsaParameters; +using ::crypto::tink::Ed25519Parameters; +using ::crypto::tink::GetPartialKeyAccess; +using ::crypto::tink::InsecureSecretKeyAccess; +using ::crypto::tink::KeysetHandle; +using ::crypto::tink::KeysetHandleBuilder; using ::crypto::tink::PublicKeySign; -using ::crypto::tink::SignaturePemKeysetReaderBuilder; +using ::crypto::tink::RsaSsaPssParameters; absl::StatusOr<std::unique_ptr<crypto::tink::PublicKeySign>> LoadTestKeyFromPem( - absl::string_view pem, const PemKeyParams& key_params) { - crypto::tink::SignaturePemKeysetReaderBuilder builder( - SignaturePemKeysetReaderBuilder::PemReaderType::PUBLIC_KEY_SIGN); - builder.Add({ - .serialized_key = std::string(pem), - .parameters = key_params, - }); - ABSL_ASSIGN_OR_RETURN(auto reader, builder.Build()); - ABSL_ASSIGN_OR_RETURN(auto handle, crypto::tink::CleartextKeysetHandle::Read( - std::move(reader))); - return handle->GetPrimitive<crypto::tink::PublicKeySign>( + absl::string_view pem, + const std::variant<EcdsaParameters, Ed25519Parameters, RsaSsaPssParameters>& + key_params) { + static const absl::NoDestructor<absl::Status> registration_status( + []() { return crypto::tink::SignatureConfig::Register(); }()); + ABSL_RETURN_IF_ERROR(*registration_status); + using TinkPrivateKey = std::variant<crypto::tink::EcdsaPrivateKey, + crypto::tink::RsaSsaPssPrivateKey, + crypto::tink::Ed25519PrivateKey>; + absl::StatusOr<TinkPrivateKey> private_key = std::visit( + absl::Overload( + [&pem]( + const EcdsaParameters& params) -> absl::StatusOr<TinkPrivateKey> { + return tink_pem::PemToEcdsaPrivateKey( + pem, params, InsecureSecretKeyAccess::Get(), + GetPartialKeyAccess()); + }, + [&pem](const RsaSsaPssParameters& params) + -> absl::StatusOr<TinkPrivateKey> { + return tink_pem::PemToRsaSsaPssPrivateKey( + pem, params, InsecureSecretKeyAccess::Get(), + GetPartialKeyAccess()); + }, + [&pem](const Ed25519Parameters& params) + -> absl::StatusOr<TinkPrivateKey> { + return tink_pem::PemToEd25519PrivateKey( + pem, params, InsecureSecretKeyAccess::Get(), + GetPartialKeyAccess()); + }), + key_params); + if (!private_key.ok()) { + return private_key.status(); + } + + KeysetHandleBuilder builder; + std::visit( + [&builder](const auto& key) { + builder.AddEntry(KeysetHandleBuilder::Entry::CreateFromCopyableKey( + key, crypto::tink::KeyStatus::kEnabled, /*is_primary=*/true)); + }, + *private_key); + + ABSL_ASSIGN_OR_RETURN(KeysetHandle handle, builder.Build()); + return handle.GetPrimitive<crypto::tink::PublicKeySign>( crypto::tink::ConfigSignatureV0()); } @@ -73,7 +120,8 @@ char* name = nullptr; char* header = nullptr; unsigned char* data = nullptr; - int64_t len = 0; + long len = 0; // NOLINT: Can't use `int64_t` because on ARM that becomes + // long long which is incompatible with `PEM_read_bio(...)`. if (!PEM_read_bio(bio.get(), &name, &header, &data, &len)) { break; }
diff --git a/testing/pem.h b/testing/pem.h index 234fe7d..c56116b 100644 --- a/testing/pem.h +++ b/testing/pem.h
@@ -18,18 +18,24 @@ #include <memory> #include <string> +#include <variant> #include "absl/status/statusor.h" #include "absl/strings/string_view.h" #include "tink/public_key_sign.h" -#include "tink/signature/signature_pem_keyset_reader.h" +#include "tink/signature/ecdsa_parameters.h" +#include "tink/signature/ed25519_parameters.h" +#include "tink/signature/rsa_ssa_pss_parameters.h" namespace credentio { // Loads a TEST private key from an unencrypted PEM string, with explicit key // parameters. absl::StatusOr<std::unique_ptr<crypto::tink::PublicKeySign>> LoadTestKeyFromPem( - absl::string_view pem, const crypto::tink::PemKeyParams& key_params); + absl::string_view pem, + const std::variant<crypto::tink::EcdsaParameters, + crypto::tink::Ed25519Parameters, + crypto::tink::RsaSsaPssParameters>& key_params); // Returns a single DER-encoded OCSP response loaded from a PEM string with tag // "OCSP RESPONSE". Returns an error if multiple blocks are present.
diff --git a/testing/pem_test.cc b/testing/pem_test.cc index 7eaa0c9..a096e62 100644 --- a/testing/pem_test.cc +++ b/testing/pem_test.cc
@@ -23,24 +23,20 @@ #include "absl/strings/string_view.h" #include "gmock/gmock.h" #include "gtest/gtest.h" -#include "tink/signature/signature_pem_keyset_reader.h" +#include "tink/signature/ecdsa_parameters.h" +#include "tink/signature/ed25519_parameters.h" +#include "tink/signature/rsa_ssa_pss_parameters.h" namespace credentio { namespace { using ::absl_testing::IsOk; using ::absl_testing::StatusIs; -using ::crypto::tink::PemAlgorithm; -using ::crypto::tink::PemKeyType; -using ::google::crypto::tink::HashType; +using ::crypto::tink::EcdsaParameters; +using ::crypto::tink::Ed25519Parameters; +using ::crypto::tink::RsaSsaPssParameters; using ::testing::HasSubstr; -constexpr absl::string_view kPemKeyP224 = R"(-----BEGIN PRIVATE KEY----- -MHgCAQAwEAYHKoZIzj0CAQYFK4EEACEEYTBfAgEBBBxaHY9SBldD/sUFJQyS4bOU -2Hf8wXBCEzfEdZ6DoTwDOgAEF3QCwJRkgQxaYZ0cqZTb0FAYQ2alOv3lWlmlNLS1 -80rBbqgzt26PS8ysBhJy8zpTdmkKxdv2SpQ= ------END PRIVATE KEY-----)"; - constexpr absl::string_view kPemKeyP256 = R"(-----BEGIN PRIVATE KEY----- MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYUrTw5gDocgm/uqt 9Y0uHuYZ+qUORlv4tsVvsC+RafyhRANCAARhNXddv5HV8eddKtCGlJ2rFUjyDZWK @@ -208,110 +204,125 @@ -----END PRIVATE KEY-----)"; TEST(LoadTestKeyFromPemTest, FailsOnInvalidPem) { - EXPECT_THAT( - LoadTestKeyFromPem("not pem data", {.key_type = PemKeyType::PEM_EC, - .algorithm = PemAlgorithm::ECDSA_IEEE, - .key_size_in_bits = 256, - .hash_type = HashType::SHA256}), - StatusIs(absl::StatusCode::kInvalidArgument)); -} - -TEST(LoadTestKeyFromPemTest, FailsOnP224) { - // P224 is not supported by Tink. - EXPECT_THAT( - LoadTestKeyFromPem(kPemKeyP224, {.key_type = PemKeyType::PEM_EC, - .algorithm = PemAlgorithm::ECDSA_IEEE, - .key_size_in_bits = 224, - .hash_type = HashType::SHA256}), - StatusIs(absl::StatusCode::kInvalidArgument)); + absl::StatusOr<EcdsaParameters> params = + EcdsaParameters::Builder() + .SetCurveType(EcdsaParameters::CurveType::kNistP256) + .SetHashType(EcdsaParameters::HashType::kSha256) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(params); + EXPECT_THAT(LoadTestKeyFromPem("not pem data", *params), + StatusIs(absl::StatusCode::kInvalidArgument)); } TEST(LoadTestKeyFromPemTest, P256) { - auto signer = - LoadTestKeyFromPem(kPemKeyP256, {.key_type = PemKeyType::PEM_EC, - .algorithm = PemAlgorithm::ECDSA_IEEE, - .key_size_in_bits = 256, - .hash_type = HashType::SHA256}); + absl::StatusOr<EcdsaParameters> params = + EcdsaParameters::Builder() + .SetCurveType(EcdsaParameters::CurveType::kNistP256) + .SetHashType(EcdsaParameters::HashType::kSha256) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(params); + auto signer = LoadTestKeyFromPem(kPemKeyP256, *params); ABSL_ASSERT_OK(signer); EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); } TEST(LoadTestKeyFromPemTest, P384) { - auto signer = - LoadTestKeyFromPem(kPemKeyP384, {.key_type = PemKeyType::PEM_EC, - .algorithm = PemAlgorithm::ECDSA_IEEE, - .key_size_in_bits = 384, - .hash_type = HashType::SHA384}); + absl::StatusOr<EcdsaParameters> params = + EcdsaParameters::Builder() + .SetCurveType(EcdsaParameters::CurveType::kNistP384) + .SetHashType(EcdsaParameters::HashType::kSha384) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(params); + auto signer = LoadTestKeyFromPem(kPemKeyP384, *params); ABSL_ASSERT_OK(signer); EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); } TEST(LoadTestKeyFromPemTest, P521) { - auto signer = - LoadTestKeyFromPem(kPemKeyP521, {.key_type = PemKeyType::PEM_EC, - .algorithm = PemAlgorithm::ECDSA_IEEE, - .key_size_in_bits = 521, - .hash_type = HashType::SHA512}); + absl::StatusOr<EcdsaParameters> params = + EcdsaParameters::Builder() + .SetCurveType(EcdsaParameters::CurveType::kNistP521) + .SetHashType(EcdsaParameters::HashType::kSha512) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(params); + auto signer = LoadTestKeyFromPem(kPemKeyP521, *params); ABSL_ASSERT_OK(signer); EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); } -TEST(LoadTestKeyFromPemTest, FailsOnRSA1024) { - // RSA1024 not supported by Tink. - EXPECT_THAT(LoadTestKeyFromPem(kPemKeyRsa1024, - { - .key_type = PemKeyType::PEM_RSA, - .algorithm = PemAlgorithm::RSASSA_PSS, - .key_size_in_bits = 1024, - .hash_type = HashType::SHA256, - }), - StatusIs(absl::StatusCode::kInvalidArgument)); +TEST(LoadTestKeyFromPemTest, ED25519) { + absl::StatusOr<Ed25519Parameters> params = + Ed25519Parameters::Create(Ed25519Parameters::Variant::kNoPrefix); + ABSL_ASSERT_OK(params); + auto signer = LoadTestKeyFromPem(kPemKeyEd25519, *params); + ABSL_ASSERT_OK(signer); + EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); } TEST(LoadTestKeyFromPemTest, RSA2048) { - auto signer = LoadTestKeyFromPem(kPemKeyRsa2048, - { - .key_type = PemKeyType::PEM_RSA, - .algorithm = PemAlgorithm::RSASSA_PSS, - .key_size_in_bits = 2048, - .hash_type = HashType::SHA256, - }); + absl::StatusOr<RsaSsaPssParameters> params = + RsaSsaPssParameters::Builder() + .SetModulusSizeInBits(2048) + .SetSigHashType(RsaSsaPssParameters::HashType::kSha256) + .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha256) + .SetSaltLengthInBytes(32) + .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(params); + auto signer = LoadTestKeyFromPem(kPemKeyRsa2048, *params); ABSL_ASSERT_OK(signer); EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); } TEST(LoadTestKeyFromPemTest, RSA3072) { - auto signer = LoadTestKeyFromPem(kPemKeyRsa3072, - { - .key_type = PemKeyType::PEM_RSA, - .algorithm = PemAlgorithm::RSASSA_PSS, - .key_size_in_bits = 3072, - .hash_type = HashType::SHA256, - }); + absl::StatusOr<RsaSsaPssParameters> params = + RsaSsaPssParameters::Builder() + .SetModulusSizeInBits(3072) + .SetSigHashType(RsaSsaPssParameters::HashType::kSha256) + .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha256) + .SetSaltLengthInBytes(32) + .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(params); + auto signer = LoadTestKeyFromPem(kPemKeyRsa3072, *params); ABSL_ASSERT_OK(signer); EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); } TEST(LoadTestKeyFromPemTest, RSA4096) { - auto signer = LoadTestKeyFromPem(kPemKeyRsa4096, - { - .key_type = PemKeyType::PEM_RSA, - .algorithm = PemAlgorithm::RSASSA_PSS, - .key_size_in_bits = 4096, - .hash_type = HashType::SHA256, - }); + absl::StatusOr<RsaSsaPssParameters> params = + RsaSsaPssParameters::Builder() + .SetModulusSizeInBits(4096) + .SetSigHashType(RsaSsaPssParameters::HashType::kSha256) + .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha256) + .SetSaltLengthInBytes(32) + .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(params); + auto signer = LoadTestKeyFromPem(kPemKeyRsa4096, *params); ABSL_ASSERT_OK(signer); EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); } TEST(LoadTestKeyFromPemTest, WrongKeySize_Fails) { - EXPECT_THAT(LoadTestKeyFromPem(kPemKeyRsa4096, - { - .key_type = PemKeyType::PEM_RSA, - .algorithm = PemAlgorithm::RSASSA_PSS, - .key_size_in_bits = 3072, - .hash_type = HashType::SHA256, - }), + absl::StatusOr<RsaSsaPssParameters> params = + RsaSsaPssParameters::Builder() + .SetModulusSizeInBits(3072) + .SetSigHashType(RsaSsaPssParameters::HashType::kSha256) + .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha256) + .SetSaltLengthInBytes(32) + .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix) + .Build(); + ABSL_ASSERT_OK(params); + EXPECT_THAT(LoadTestKeyFromPem(kPemKeyRsa4096, *params), StatusIs(absl::StatusCode::kInvalidArgument)); }
diff --git a/testing/proto_test_utils.h b/testing/proto_test_utils.h index bf8ba88..987281d 100644 --- a/testing/proto_test_utils.h +++ b/testing/proto_test_utils.h
@@ -16,7 +16,9 @@ #ifndef THIRD_PARTY_CREDENTIO_TESTING_PROTO_TEST_UTILS_H_ #define THIRD_PARTY_CREDENTIO_TESTING_PROTO_TEST_UTILS_H_ -#include "absl/log/check.h" +#include <type_traits> + +#include "absl/log/absl_check.h" #include "absl/strings/string_view.h" #include "gmock/gmock.h" #include "google/protobuf/text_format.h" @@ -24,21 +26,58 @@ namespace credentio_testing { -MATCHER_P(EqualsProto, expected, "") { - return google::protobuf::util::MessageDifferencer::Equals(arg, expected); +template <typename T, typename M> +bool CompareProtos(const M& arg, const T& expected) { + if constexpr (std::is_convertible_v<T, absl::string_view>) { + M expected_msg; + ABSL_CHECK( + google::protobuf::TextFormat::ParseFromString(expected, &expected_msg)); + return google::protobuf::util::MessageDifferencer::Equals(arg, + expected_msg); + } else { + return google::protobuf::util::MessageDifferencer::Equals(arg, expected); + } } +MATCHER(EqualsProto, "") { + return CompareProtos(std::get<0>(arg), std::get<1>(arg)); +} + +MATCHER_P(EqualsProto, expected, "") { return CompareProtos(arg, expected); } + MATCHER_P(PartiallyEqualsProto, expected, "") { google::protobuf::util::MessageDifferencer differencer; differencer.set_scope(google::protobuf::util::MessageDifferencer::PARTIAL); return differencer.Compare(expected, arg); } -template <typename T> +class ParseTextProtoOrDieProxy { + public: + explicit ParseTextProtoOrDieProxy(absl::string_view text_proto) + : text_proto_(text_proto) {} + + template <typename T> + operator T() const { // NOLINT(google-explicit-constructor) + T proto; + ABSL_CHECK( + google::protobuf::TextFormat::ParseFromString(text_proto_, &proto)); + return proto; + } + + private: + absl::string_view text_proto_; +}; + +template <typename T = ParseTextProtoOrDieProxy> T ParseTextProtoOrDie(absl::string_view text_proto) { - T proto; - CHECK(google::protobuf::TextFormat::ParseFromString(text_proto, &proto)); - return proto; + if constexpr (std::is_same_v<T, ParseTextProtoOrDieProxy>) { + return ParseTextProtoOrDieProxy(text_proto); + } else { + T proto; + ABSL_CHECK( + google::protobuf::TextFormat::ParseFromString(text_proto, &proto)); + return proto; + } } } // namespace credentio_testing
diff --git a/tools/BUILD b/tools/BUILD index 5b96d35..cf83f83 100644 --- a/tools/BUILD +++ b/tools/BUILD
@@ -13,7 +13,9 @@ # limitations under the License. # -load("@rules_cc//cc:defs.bzl", "cc_binary") +load("@rules_cc//cc:cc_binary.bzl", "cc_binary") +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") package(default_visibility = ["//visibility:public"]) @@ -23,7 +25,6 @@ deps = [ "//crypto:crypto_read_handler", "//crypto/default:default_crypto_read_handler", - "//formats:core_registry", "//utils:crjson", "//utils:media_type", "//validator:asset_validator_impl", @@ -34,12 +35,115 @@ "@abseil-cpp//absl/flags:usage", "@abseil-cpp//absl/log:initialize", "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", "@abseil-cpp//absl/status:statusor", "@abseil-cpp//absl/strings", "@abseil-cpp//absl/strings:string_view", "@nlohmann_json//:json", "@protobuf", "@riegeli//riegeli/bytes:cfile_reader", + ], +) + +cc_library( + name = "asset_writer", + srcs = ["asset_writer.cc"], + hdrs = ["asset_writer.h"], + deps = [ + "//claim:local_signer", + "//crypto:crypto_write_handler", + "//crypto:hash", + "//crypto:templated_crypto_write_handler", + "//crypto/default:hasher", + "//formats/bmff:writer", + "//formats/gif:writer", + "//formats/id3:writer", + "//formats/jpeg:writer", + "//formats/pdf:writer", + "//formats/png:writer", + "//formats/riff:writer", + "//formats/tiff:writer", + "//formats/zip:writer", + "//generator", + "//generator:generator_factory", + "//generator:generator_options", + "//proto:generator_cc_proto", + "//testing:local_key_crypto_write_handler", + "//utils:media_type", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:cfile_reader", + "@riegeli//riegeli/bytes:cfile_writer", "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_binary( + name = "asset_write", + srcs = ["asset_write_main.cc"], + deps = [ + ":asset_writer", + "//crypto/default:pem", + "//proto:generator_cc_proto", + "//testing:local_key_crypto_write_handler", + "@abseil-cpp//absl/flags:flag", + "@abseil-cpp//absl/flags:parse", + "@abseil-cpp//absl/flags:usage", + "@abseil-cpp//absl/log:initialize", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@protobuf", + ], +) + +cc_test( + name = "asset_writer_test", + srcs = ["asset_writer_test.cc"], + data = [ + "//formats/zip/testing:assets", + ], + deps = [ + ":asset_writer", + "//crypto/default:default_crypto_read_handler", + "//formats/bmff:extractor", + "//formats/bmff:test_utils", + "//formats/gif:constants", + "//formats/gif:extractor", + "//formats/gif:test_builder", + "//formats/id3:extractor", + "//formats/jpeg:extractor", + "//formats/jpeg/testing:jpeg_creator", + "//formats/pdf:extractor", + "//formats/png:extractor", + "//formats/png:test_utils", + "//formats/riff:create_riff", + "//formats/riff:extractor", + "//formats/tiff:constants", + "//formats/tiff:extractor", + "//formats/zip:extractor", + "//proto:generator_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:crypto_testing", + "//testing:local_key_crypto_write_handler", + "//testing:test_file_utils", + "//validator:asset_validator_impl", + "//validator:validator_options", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@boringssl//:crypto", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:cfile_reader", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/endian:endian_writing", ], )
diff --git a/tools/asset_validator_main.cc b/tools/asset_validator_main.cc index 8c2aaa8..71f9a25 100644 --- a/tools/asset_validator_main.cc +++ b/tools/asset_validator_main.cc
@@ -17,6 +17,7 @@ #include <iostream> #include <memory> #include <optional> +#include <ostream> #include <sstream> #include <string> #include <utility> @@ -26,16 +27,15 @@ #include "absl/flags/usage.h" #include "absl/log/initialize.h" #include "absl/status/status.h" +#include "absl/status/status_macros.h" #include "absl/status/statusor.h" #include "absl/strings/str_cat.h" #include "absl/strings/string_view.h" #include "crypto/crypto_read_handler.h" #include "crypto/default/default_crypto_read_handler.h" -#include "formats/core_registry.h" #include "google/protobuf/text_format.h" #include "nlohmann/json_fwd.hpp" #include "riegeli/bytes/cfile_reader.h" -#include "riegeli/bytes/reader.h" #include "utils/crjson.h" #include "utils/media_type.h" #include "validator/asset_validator_impl.h" @@ -55,6 +55,9 @@ ABSL_FLAG(std::string, output_format, "crjson", "The format to output the validation results in. [txtpb, crjson] " "(Default: crjson)"); +ABSL_FLAG(std::string, output_file, "", + "Path to the output file to write validation results to (Optional; " + "defaults to stdout)."); namespace { @@ -69,43 +72,23 @@ return buffer.str(); } -absl::Status PrintResultInTxtpb(const credentio::ValidationResult& result) { +absl::Status PrintResultInTxtpb(const credentio::ValidationResult& result, + std::ostream& out) { std::string text_format; if (google::protobuf::TextFormat::PrintToString(result.proto(), &text_format)) { - std::cout << "Validation Result:\n" << text_format << "\n"; + out << text_format; } else { return absl::InternalError("Failed to convert result proto to text format"); } return absl::OkStatus(); } -absl::Status PrintResultInCrJson( - riegeli::Reader& reader, std::optional<absl::string_view> media_type_opt, - const credentio::ValidationResult& result) { - if (!media_type_opt.has_value()) { - return absl::InvalidArgumentError("Media type is required"); - } - - auto registry = credentio::CreateCoreFormatRegistry(); - auto format = registry->GetFormat(*media_type_opt); - if (!format.ok()) { - return format.status(); - } - if (!reader.Seek(0) || reader.pos() != 0) { - return absl::DataLossError("Failed to seek to start of reader"); - } - auto manifest_store = (*format)->extractor()->ExtractManifestStore(reader); - if (!manifest_store.ok()) { - return manifest_store.status(); - } - - absl::StatusOr<nlohmann::json> crjson = - credentio::ConvertToCrJson(*manifest_store, result.proto()); - if (!crjson.ok()) { - return crjson.status(); - } - std::cout << "Validation Result (crjson):\n" << crjson->dump(2) << "\n"; +absl::Status PrintResultInCrJson(const credentio::ValidationResult& result, + std::ostream& out) { + ABSL_ASSIGN_OR_RETURN(nlohmann::json crjson, + credentio::ConvertToCrJson(result)); + out << crjson.dump(2); return absl::OkStatus(); } @@ -116,7 +99,7 @@ "Validates C2PA asset files and prints validation results.\n" "Usage:\n c2pa_validate --asset=<path_to_asset> " "[--claim_signer_trust=<pem_path>] [--tsa_trust=<pem_path>] " - "[--output_format=<txtpb, crjson>]"); + "[--output_format=<txtpb, crjson>] [--output_file=<output_path>]"); absl::InitializeLog(); absl::ParseCommandLine(argc, argv); @@ -205,18 +188,29 @@ return 1; } - std::cout << "Validation successful!\n"; + const std::string output_path = absl::GetFlag(FLAGS_output_file); - if (output_format == "crjson") { - absl::Status status = PrintResultInCrJson(reader, media_type_opt, **result); - if (status.ok()) { - return 0; + std::ofstream file_out; + std::ostream* out = &std::cout; + if (!output_path.empty()) { + file_out.open(output_path); + if (!file_out.is_open()) { + std::cerr << "Error: Failed to open output file: " << output_path << "\n"; + return 1; } - std::cerr << "Failed to print result in crjson, reverting to txtpb: " - << status << "\n"; + out = &file_out; } - absl::Status status = PrintResultInTxtpb(**result); + if (output_format == "crjson") { + absl::Status status = PrintResultInCrJson(**result, *out); + if (!status.ok()) { + std::cerr << "Failed to print result in crjson: " << status << "\n"; + return 1; + } + return 0; + } + + absl::Status status = PrintResultInTxtpb(**result, *out); if (!status.ok()) { std::cerr << "Failed to print result in txtpb: " << status << "\n"; return 1;
diff --git a/tools/asset_write_main.cc b/tools/asset_write_main.cc new file mode 100644 index 0000000..9785e52 --- /dev/null +++ b/tools/asset_write_main.cc
@@ -0,0 +1,213 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include <fstream> +#include <iostream> +#include <sstream> +#include <string> +#include <utility> +#include <vector> + +#include "absl/flags/flag.h" +#include "absl/flags/parse.h" +#include "absl/flags/usage.h" +#include "absl/log/initialize.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "crypto/default/pem.h" +#include "google/protobuf/text_format.h" +#include "proto/generator.pb.h" +#include "testing/local_key_crypto_write_handler.h" +#include "tools/asset_writer.h" + +ABSL_FLAG(std::string, input, "", + "Path to the input asset file to read from (Required)."); +ABSL_FLAG(std::string, output, "", + "Path to the output asset file to write to (Required)."); +ABSL_FLAG(std::string, claim_signer_key, "", + "Path to PEM file containing the claim signer's EC P-256 private key " + "(Required)."); +ABSL_FLAG(std::string, claim_signer_cert, "", + "Path to PEM file containing the claim signer's certificate chain, " + "leaf first (Required)."); +ABSL_FLAG(std::string, claim_signer_ocsp, "", + "Path to DER file containing an OCSP response for the claim signer " + "certificate (Optional)."); +ABSL_FLAG(std::string, tsa_key, "", + "Path to PEM file containing the TSA's EC P-256 private key " + "(Optional; required unless timestamping is disabled)."); +ABSL_FLAG(std::string, tsa_cert, "", + "Path to PEM file containing the TSA's certificate " + "(Optional; required unless timestamping is disabled)."); +ABSL_FLAG(bool, disable_timestamping, false, + "If true, disables timestamping when writing the C2PA manifest store " + "(Default: false)."); +ABSL_FLAG(std::string, request_txtpb, "", + "Path to textproto file containing GenerateManifestStoreRequest " + "(Optional; defaults to a basic 'created' action)."); + +namespace { + +absl::StatusOr<std::string> GetContents(absl::string_view file_path) { + std::ifstream f(std::string(file_path), std::ios::binary); + if (!f.is_open()) { + return absl::NotFoundError( + absl::StrCat("Failed to open file: ", file_path)); + } + std::stringstream buffer; + buffer << f.rdbuf(); + return buffer.str(); +} + +} // namespace + +int main(int argc, char* argv[]) { + absl::SetProgramUsageMessage( + "Writes C2PA manifest stores into asset files.\n" + "Usage:\n asset_write --input=<input_path> --output=<output_path> " + "--claim_signer_key=<pem_path> --claim_signer_cert=<pem_path> " + "[--claim_signer_ocsp=<der_path>] [--tsa_key=<pem_path>] " + "[--tsa_cert=<pem_path>] [--disable_timestamping] " + "[--request_txtpb=<txtpb_path>]"); + absl::InitializeLog(); + absl::ParseCommandLine(argc, argv); + + const std::string input_path = absl::GetFlag(FLAGS_input); + if (input_path.empty()) { + std::cerr << "Error: --input flag is required.\n"; + return 1; + } + + const std::string output_path = absl::GetFlag(FLAGS_output); + if (output_path.empty()) { + std::cerr << "Error: --output flag is required.\n"; + return 1; + } + + const std::string claim_signer_key_path = + absl::GetFlag(FLAGS_claim_signer_key); + if (claim_signer_key_path.empty()) { + std::cerr << "Error: --claim_signer_key flag is required.\n"; + return 1; + } + + const std::string claim_signer_cert_path = + absl::GetFlag(FLAGS_claim_signer_cert); + if (claim_signer_cert_path.empty()) { + std::cerr << "Error: --claim_signer_cert flag is required.\n"; + return 1; + } + + credentio::LocalKeyCryptoWriteHandlerOptions crypto_options; + + absl::StatusOr<std::string> key_pem = GetContents(claim_signer_key_path); + if (!key_pem.ok()) { + std::cerr << "Error reading claim signer key file: " << key_pem.status() + << "\n"; + return 1; + } + crypto_options.claim_signer.key_pem = *std::move(key_pem); + + absl::StatusOr<std::string> cert_pem = GetContents(claim_signer_cert_path); + if (!cert_pem.ok()) { + std::cerr << "Error reading claim signer cert file: " << cert_pem.status() + << "\n"; + return 1; + } + absl::StatusOr<std::vector<std::string>> cert_chain = + credentio::LoadCertsFromPem(*cert_pem); + if (!cert_chain.ok()) { + std::cerr << "Error parsing claim signer certificate chain: " + << cert_chain.status() << "\n"; + return 1; + } + crypto_options.claim_signer.certificate_chain = *std::move(cert_chain); + + const std::string claim_signer_ocsp_path = + absl::GetFlag(FLAGS_claim_signer_ocsp); + if (!claim_signer_ocsp_path.empty()) { + absl::StatusOr<std::string> ocsp_der = GetContents(claim_signer_ocsp_path); + if (!ocsp_der.ok()) { + std::cerr << "Error reading claim signer OCSP file: " << ocsp_der.status() + << "\n"; + return 1; + } + crypto_options.claim_signer.ocsp_responses.push_back(*std::move(ocsp_der)); + } + + const std::string tsa_key_path = absl::GetFlag(FLAGS_tsa_key); + const std::string tsa_cert_path = absl::GetFlag(FLAGS_tsa_cert); + bool disable_timestamping = absl::GetFlag(FLAGS_disable_timestamping); + + if (!disable_timestamping && tsa_key_path.empty() && tsa_cert_path.empty()) { + std::cerr << "WARNING: --tsa_key and --tsa_cert are not provided. " + "Disabling timestamping.\n"; + disable_timestamping = true; + } + + crypto_options.disable_timestamping = disable_timestamping; + if (!disable_timestamping) { + if (tsa_key_path.empty() || tsa_cert_path.empty()) { + std::cerr << "Error: Both --tsa_key and --tsa_cert must be provided when " + "timestamping is enabled.\n"; + return 1; + } + absl::StatusOr<std::string> tsa_key_pem = GetContents(tsa_key_path); + if (!tsa_key_pem.ok()) { + std::cerr << "Error reading TSA key file: " << tsa_key_pem.status() + << "\n"; + return 1; + } + crypto_options.tsa.key_pem = *std::move(tsa_key_pem); + + absl::StatusOr<std::string> tsa_cert_pem = GetContents(tsa_cert_path); + if (!tsa_cert_pem.ok()) { + std::cerr << "Error reading TSA cert file: " << tsa_cert_pem.status() + << "\n"; + return 1; + } + crypto_options.tsa.cert_pem = *std::move(tsa_cert_pem); + } + + credentio::GenerateManifestStoreRequest request; + const std::string request_txtpb_path = absl::GetFlag(FLAGS_request_txtpb); + if (!request_txtpb_path.empty()) { + absl::StatusOr<std::string> request_txtpb = GetContents(request_txtpb_path); + if (!request_txtpb.ok()) { + std::cerr << "Error reading request txtpb file: " + << request_txtpb.status() << "\n"; + return 1; + } + if (!google::protobuf::TextFormat::ParseFromString(*request_txtpb, + &request)) { + std::cerr << "Error parsing GenerateManifestStoreRequest from txtpb.\n"; + return 1; + } + } else { + request.mutable_actions()->mutable_created(); + } + + absl::Status status = credentio::WriteAssetFile( + std::move(crypto_options), std::move(request), input_path, output_path); + if (!status.ok()) { + std::cerr << "Asset write failed: " << status << "\n"; + return 1; + } + + std::cout << "Successfully wrote C2PA asset to " << output_path << "\n"; + return 0; +}
diff --git a/tools/asset_writer.cc b/tools/asset_writer.cc new file mode 100644 index 0000000..a177688 --- /dev/null +++ b/tools/asset_writer.cc
@@ -0,0 +1,182 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "tools/asset_writer.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "claim/local_signer.h" +#include "crypto/crypto_write_handler.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "crypto/templated_crypto_write_handler.h" +#include "formats/bmff/writer.h" +#include "formats/gif/writer.h" +#include "formats/id3/writer.h" +#include "formats/jpeg/writer.h" +#include "formats/pdf/writer.h" +#include "formats/png/writer.h" +#include "formats/riff/writer.h" +#include "formats/tiff/writer.h" +#include "formats/zip/writer.h" +#include "generator/generator.h" +#include "generator/generator_factory.h" +#include "generator/generator_options.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/cfile_reader.h" +#include "riegeli/bytes/cfile_writer.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "testing/local_key_crypto_write_handler.h" +#include "utils/media_type.h" + +namespace credentio { + +absl::Status WriteAsset(LocalKeyCryptoWriteHandlerOptions crypto_options, + GenerateManifestStoreRequest request, + absl::string_view media_type, riegeli::Reader& source, + riegeli::Writer& destination) { + ABSL_ASSIGN_OR_RETURN( + std::unique_ptr<CryptoWriteHandler> crypto_write_handler, + CreateLocalKeyCryptoWriteHandler(std::move(crypto_options))); + + const HasherFactory& hash_factory = DefaultHasherFactory(); + TemplatedHasherFactory template_hash_factory(hash_factory.algorithm()); + TemplatedCryptoWriteHandler template_crypto_write_handler( + crypto_write_handler.get()); + + LocalClaimSigner template_claim_signer(&template_crypto_write_handler, + &template_hash_factory); + LocalClaimSigner signing_claim_signer(crypto_write_handler.get(), + &hash_factory); + + GeneratorOptions generator_options; + generator_options.generator_info.set_name("c2pa_asset_write"); + generator_options.signing_claim_signer = &signing_claim_signer; + generator_options.template_claim_signer = &template_claim_signer; + generator_options.hash_factory = &hash_factory; + + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<ManifestStoreGenerator> generator, + CreateGenerator(std::move(generator_options))); + + if (!request.has_actions()) { + request.mutable_actions()->mutable_created(); + } + + if (media_type == "image/png") { + PngWriter writer(generator.get()); + return writer.Write(std::move(request), &source, &destination, + /*output_metadata=*/nullptr); + } + if (media_type == "image/jpeg") { + JpegWriter writer(generator.get()); + return writer.Write(std::move(request), &source, &destination, + /*output_metadata=*/nullptr); + } + if (media_type == "image/gif") { + GifWriter writer(generator.get()); + return writer.Write(std::move(request), &source, &destination, + /*output_metadata=*/nullptr); + } + if (media_type == "video/mp4" || media_type == "video/quicktime" || + media_type == "image/heic" || media_type == "image/heif" || + media_type == "image/avif" || media_type == "audio/mp4") { + BmffWriter writer(generator.get()); + return writer.Write(std::move(request), &source, &destination, + /*output_metadata=*/nullptr); + } + if (media_type == "image/tiff" || media_type == "image/tiff-fx" || + media_type == "image/x-adobe-dng") { + TiffWriter writer(generator.get()); + return writer.Write(std::move(request), &source, &destination, + /*output_metadata=*/nullptr); + } + if (media_type == "image/webp" || media_type == "audio/wav" || + media_type == "audio/x-wav" || media_type == "audio/vnd.wave" || + media_type == "video/x-msvideo" || media_type == "video/avi") { + RiffWriter writer(generator.get()); + return writer.Write(std::move(request), &source, &destination, + /*output_metadata=*/nullptr); + } + if (media_type == "audio/mpeg" || media_type == "audio/mp3" || + media_type == "audio/flac" || media_type == "audio/x-flac") { + Id3Writer writer(generator.get()); + return writer.Write(std::move(request), &source, &destination); + } + if (media_type == "application/pdf") { + PdfWriter writer(generator.get()); + return writer.Write(std::move(request), &source, &destination); + } + if (media_type == "application/zip" || + media_type == + "application/" + "vnd.openxmlformats-officedocument.presentationml.presentation" || + media_type == + "application/" + "vnd.openxmlformats-officedocument.wordprocessingml.document" || + media_type == + "application/" + "vnd.openxmlformats-officedocument.spreadsheetml.sheet") { + ZipWriter writer(generator.get()); + return writer.Write(request, &source, &destination); + } + + return absl::UnimplementedError( + absl::StrCat("Unsupported media type for asset_write: ", media_type)); +} + +absl::Status WriteAssetFile(LocalKeyCryptoWriteHandlerOptions crypto_options, + GenerateManifestStoreRequest request, + absl::string_view input_path, + absl::string_view output_path) { + // Writing in place would truncate the input asset before it is read. + if (input_path == output_path) { + return absl::InvalidArgumentError(absl::StrCat( + "In-place writing is not supported; the output path must differ from " + "the input path: ", + input_path)); + } + + ABSL_ASSIGN_OR_RETURN(std::string media_type, MediaType(input_path)); + + riegeli::CFileReader<> source(input_path); + if (!source.ok()) { + return source.status(); + } + + riegeli::CFileWriter<> destination(output_path); + if (!destination.ok()) { + return destination.status(); + } + + ABSL_RETURN_IF_ERROR(WriteAsset(std::move(crypto_options), std::move(request), + media_type, source, destination)); + + if (!source.Close()) { + return source.status(); + } + if (!destination.Close()) { + return destination.status(); + } + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/tools/asset_writer.h b/tools/asset_writer.h new file mode 100644 index 0000000..360db88 --- /dev/null +++ b/tools/asset_writer.h
@@ -0,0 +1,48 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_TOOLS_ASSET_WRITER_H_ +#define THIRD_PARTY_CREDENTIO_TOOLS_ASSET_WRITER_H_ + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "proto/generator.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "testing/local_key_crypto_write_handler.h" + +namespace credentio { + +// Writes a C2PA manifest store into `destination` based on `source` and +// `request`, using `LocalKeyCryptoWriteHandler` configured with +// `crypto_options`. +// +// `media_type` specifies the MIME type of the asset (e.g., "image/png"). +// See the "Supported Media Formats" section of README.md for supported types. +absl::Status WriteAsset(LocalKeyCryptoWriteHandlerOptions crypto_options, + GenerateManifestStoreRequest request, + absl::string_view media_type, riegeli::Reader& source, + riegeli::Writer& destination); + +// Convenience wrapper that opens `input_path` and `output_path` and infers +// the media type from `input_path`. +absl::Status WriteAssetFile(LocalKeyCryptoWriteHandlerOptions crypto_options, + GenerateManifestStoreRequest request, + absl::string_view input_path, + absl::string_view output_path); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TOOLS_ASSET_WRITER_H_
diff --git a/tools/asset_writer_test.cc b/tools/asset_writer_test.cc new file mode 100644 index 0000000..75c3654 --- /dev/null +++ b/tools/asset_writer_test.cc
@@ -0,0 +1,533 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "tools/asset_writer.h" + +#include <cstdint> +#include <fstream> +#include <ios> +#include <iterator> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/absl_check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "crypto/default/default_crypto_read_handler.h" +#include "formats/bmff/extractor.h" +#include "formats/bmff/test_utils.h" +#include "formats/gif/constants.h" +#include "formats/gif/extractor.h" +#include "formats/gif/test_builder.h" +#include "formats/id3/extractor.h" +#include "formats/jpeg/extractor.h" +#include "formats/jpeg/testing/jpeg_creator.h" +#include "formats/pdf/extractor.h" +#include "formats/png/extractor.h" +#include "formats/png/test_utils.h" +#include "formats/riff/create_riff.h" +#include "formats/riff/extractor.h" +#include "formats/tiff/constants.h" +#include "formats/tiff/extractor.h" +#include "formats/zip/extractor.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/generator.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_status.pb.h" +#include "riegeli/bytes/cfile_reader.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/endian/endian_writing.h" +#include "testing/crypto_testing.h" +#include "testing/local_key_crypto_write_handler.h" +#include "testing/test_file_utils.h" +#include "validator/asset_validator_impl.h" +#include "validator/validator_options.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::IsEmpty; +using ::testing::Not; + +LocalKeyCryptoWriteHandlerOptions CreateWriteOptions( + const TestPki& pki, bool disable_timestamping) { + LocalKeyCryptoWriteHandlerOptions options; + options.claim_signer.key_pem = pki.claim_signer.key_pem; + options.claim_signer.certificate_chain = {pki.claim_signer.cert_der}; + options.disable_timestamping = disable_timestamping; + if (!disable_timestamping) { + options.tsa.key_pem = pki.tsa.key_pem; + options.tsa.cert_pem = pki.tsa.cert_pem; + } + return options; +} + +// Returns a validator that enforces claim signer and TSA trust against the +// root CA of `pki`. +std::unique_ptr<AssetValidatorImpl> CreateTestValidator(const TestPki& pki) { + DefaultCryptoReadHandlerOptions crypto_options; + crypto_options.claim_signer_trust_anchors_pem = pki.root_ca.cert_pem; + crypto_options.tsa_trust_anchors_pem = pki.root_ca.cert_pem; + auto crypto_read_handler = CreateDefaultCryptoReadHandler(crypto_options); + ABSL_CHECK_OK(crypto_read_handler); + return std::make_unique<AssetValidatorImpl>(ValidatorOptions{ + .crypto_read_handler = *std::move(crypto_read_handler), + }); +} + +TEST(AssetWriterTest, WritesPngManifestStore) { + auto png_or = CreatePng({Chunk{.type = 0x49444154, .payload = "pixel_data"}}); + ABSL_ASSERT_OK(png_or); + + riegeli::StringReader<> source(*png_or); + std::string output_bytes; + riegeli::StringWriter<> destination(&output_bytes); + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + TestPki pki = CreateTestPki(); + EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true), + std::move(request), "image/png", source, destination), + IsOk()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader<> result_reader(output_bytes); + PngExtractor extractor; + auto extracted = extractor.ExtractManifestStore(result_reader); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(*extracted, Not(IsEmpty())); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validator = CreateTestValidator(pki); + auto validation_result = validator->Validate(result_reader, "image/png"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST(AssetWriterTest, WritesJpegManifestStore) { + std::string jpeg_bytes = CreateJpeg({}); + riegeli::StringReader<> source(jpeg_bytes); + std::string output_bytes; + riegeli::StringWriter<> destination(&output_bytes); + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + TestPki pki = CreateTestPki(); + EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true), + std::move(request), "image/jpeg", source, destination), + IsOk()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader<> result_reader(output_bytes); + JpegExtractor extractor; + auto extracted = extractor.ExtractManifestStore(result_reader); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(*extracted, Not(IsEmpty())); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validator = CreateTestValidator(pki); + auto validation_result = validator->Validate(result_reader, "image/jpeg"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST(AssetWriterTest, WritesGifManifestStore) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication, + "other_app_info", "misc_data"); + builder.AddBlock(kGifTrailerIntroducer); + std::string gif_bytes = builder.GetAsset(); + + riegeli::StringReader<> source(gif_bytes); + std::string output_bytes; + riegeli::StringWriter<> destination(&output_bytes); + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + TestPki pki = CreateTestPki(); + EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true), + std::move(request), "image/gif", source, destination), + IsOk()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader<> result_reader(output_bytes); + GifExtractor extractor; + auto extracted = extractor.ExtractManifestStore(result_reader); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(*extracted, Not(IsEmpty())); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validator = CreateTestValidator(pki); + auto validation_result = validator->Validate(result_reader, "image/gif"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST(AssetWriterTest, WritesBmffManifestStore) { + std::string bmff_bytes = absl::StrCat( + credentio_testing::Box("ftyp", {"isom\x00\x00\x02\x00isom", 12}), + credentio_testing::Box("moov", ""), + credentio_testing::Box("mdat", "video_payload")); + + riegeli::StringReader<> source(bmff_bytes); + std::string output_bytes; + riegeli::StringWriter<> destination(&output_bytes); + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + TestPki pki = CreateTestPki(); + EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true), + std::move(request), "video/mp4", source, destination), + IsOk()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader<> result_reader(output_bytes); + BmffExtractor extractor; + auto extracted = extractor.ExtractManifestStore(result_reader); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(*extracted, Not(IsEmpty())); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validator = CreateTestValidator(pki); + auto validation_result = validator->Validate(result_reader, "video/mp4"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST(AssetWriterTest, WritesTiffManifestStore) { + std::string tiff_bytes; + riegeli::StringWriter<> writer(&tiff_bytes); + riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer); + riegeli::WriteBigEndian<uint32_t>(8, writer); // Offset to first IFD + riegeli::WriteBigEndian<uint16_t>(1, writer); // 1 entry + riegeli::WriteBigEndian<uint16_t>(0x0100, writer); + riegeli::WriteBigEndian<uint16_t>(kTiffTypeShort, writer); + riegeli::WriteBigEndian<uint32_t>(1, writer); + riegeli::WriteBigEndian<uint32_t>(10, writer); + riegeli::WriteBigEndian<uint32_t>(0, writer); // next IFD offset + ASSERT_TRUE(writer.Close()); + + riegeli::StringReader<> source(tiff_bytes); + std::string output_bytes; + riegeli::StringWriter<> destination(&output_bytes); + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + TestPki pki = CreateTestPki(); + EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true), + std::move(request), "image/tiff", source, destination), + IsOk()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader<> result_reader(output_bytes); + TiffExtractor extractor; + auto extracted = extractor.ExtractManifestStore(result_reader); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(*extracted, Not(IsEmpty())); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validator = CreateTestValidator(pki); + auto validation_result = validator->Validate(result_reader, "image/tiff"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST(AssetWriterTest, WritesRiffManifestStore) { + std::string riff_bytes = credentio_riff::RiffChunk( + "WEBP", credentio_riff::Chunk("VP8 ", "image_data")); + + riegeli::StringReader<> source(riff_bytes); + std::string output_bytes; + riegeli::StringWriter<> destination(&output_bytes); + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + TestPki pki = CreateTestPki(); + EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true), + std::move(request), "image/webp", source, destination), + IsOk()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader<> result_reader(output_bytes); + RiffExtractor extractor; + auto extracted = extractor.ExtractManifestStore(result_reader); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(*extracted, Not(IsEmpty())); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validator = CreateTestValidator(pki); + auto validation_result = validator->Validate(result_reader, "image/webp"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST(AssetWriterTest, WritesId3ManifestStore) { + constexpr char kMp3Bytes[] = {'\xFF', '\xF3', '\x40', '\xC0', + '\x01', '\x02', '\x03', '\x04'}; + std::string id3_bytes(kMp3Bytes, sizeof(kMp3Bytes)); + + riegeli::StringReader<> source(id3_bytes); + std::string output_bytes; + riegeli::StringWriter<> destination(&output_bytes); + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + TestPki pki = CreateTestPki(); + EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true), + std::move(request), "audio/mpeg", source, destination), + IsOk()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader<> result_reader(output_bytes); + Id3Extractor extractor; + auto extracted = extractor.ExtractManifestStore(result_reader); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(*extracted, Not(IsEmpty())); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validator = CreateTestValidator(pki); + auto validation_result = validator->Validate(result_reader, "audio/mpeg"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST(AssetWriterTest, WritesPdfManifestStore) { + constexpr absl::string_view kPdfBytes = R"(%PDF-1.7 +%%%%% +1 0 obj +/test_name +endobj +2 0 obj +123.456 +endobj +3 0 obj +<</Type /Catalog>> +endobj +xref +0 4 +0000000000 65535 f +0000000015 00000 n +0000000041 00000 n +0000000064 00000 n +trailer +<</Root 3 0 R /Size 4>> +startxref +98 +%%EOF +)"; + + riegeli::StringReader<> source(kPdfBytes); + std::string output_bytes; + riegeli::StringWriter<> destination(&output_bytes); + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + TestPki pki = CreateTestPki(); + EXPECT_THAT( + WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true), + std::move(request), "application/pdf", source, destination), + IsOk()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader<> result_reader(output_bytes); + PdfExtractor extractor; + auto extracted = extractor.ExtractManifestStore(result_reader); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(*extracted, Not(IsEmpty())); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validator = CreateTestValidator(pki); + auto validation_result = + validator->Validate(result_reader, "application/pdf"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST(AssetWriterTest, WritesZipManifestStore) { + absl::StatusOr<std::string> pptx_bytes = credentio_testing::GetContents( + "credentio/formats/zip/testing/" + "no_c2pa_032_asset.pptx"); + ABSL_ASSERT_OK(pptx_bytes); + riegeli::StringReader<> source(*pptx_bytes); + std::string output_bytes; + riegeli::StringWriter<> destination(&output_bytes); + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + constexpr absl::string_view kPptxMediaType = + "application/" + "vnd.openxmlformats-officedocument.presentationml.presentation"; + + TestPki pki = CreateTestPki(); + EXPECT_THAT( + WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true), + std::move(request), kPptxMediaType, source, destination), + IsOk()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader<> result_reader(output_bytes); + ZipExtractor extractor; + auto extracted = extractor.ExtractManifestStore(result_reader); + ABSL_ASSERT_OK(extracted); + EXPECT_THAT(*extracted, Not(IsEmpty())); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validator = CreateTestValidator(pki); + auto validation_result = validator->Validate(result_reader, kPptxMediaType); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST(AssetWriterTest, WritesPngWithTimestamp) { + auto png_or = CreatePng({Chunk{.type = 0x49444154, .payload = "pixel_data"}}); + ABSL_ASSERT_OK(png_or); + + riegeli::StringReader<> source(*png_or); + std::string output_bytes; + riegeli::StringWriter<> destination(&output_bytes); + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + TestPki pki = CreateTestPki(); + EXPECT_THAT( + WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/false), + std::move(request), "image/png", source, destination), + IsOk()); + ASSERT_TRUE(destination.Close()); + + riegeli::StringReader<> result_reader(output_bytes); + auto validator = CreateTestValidator(pki); + auto validation_result = validator->Validate(result_reader, "image/png"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST(AssetWriterTest, WriteAssetFileWritesAndValidatesPng) { + auto png_or = CreatePng({Chunk{.type = 0x49444154, .payload = "pixel_data"}}); + ABSL_ASSERT_OK(png_or); + + const std::string input_path = + absl::StrCat(testing::TempDir(), "/test_input.png"); + const std::string output_path = + absl::StrCat(testing::TempDir(), "/test_output.png"); + + { + std::ofstream out(input_path, std::ios::binary); + out << *png_or; + } + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + TestPki pki = CreateTestPki(); + EXPECT_THAT( + WriteAssetFile(CreateWriteOptions(pki, /*disable_timestamping=*/true), + std::move(request), input_path, output_path), + IsOk()); + + riegeli::CFileReader<> result_reader(output_path); + ASSERT_TRUE(result_reader.ok()); + auto validator = CreateTestValidator(pki); + auto validation_result = validator->Validate(result_reader, "image/png"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + +TEST(AssetWriterTest, RejectsInPlaceWrite) { + auto png_or = CreatePng({Chunk{.type = 0x49444154, .payload = "pixel_data"}}); + ABSL_ASSERT_OK(png_or); + + const std::string path = + absl::StrCat(testing::TempDir(), "/test_in_place.png"); + { + std::ofstream out(path, std::ios::binary); + out << *png_or; + } + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + TestPki pki = CreateTestPki(); + EXPECT_THAT( + WriteAssetFile(CreateWriteOptions(pki, /*disable_timestamping=*/true), + std::move(request), path, path), + StatusIs(absl::StatusCode::kInvalidArgument)); + + // The input asset must be left untouched. + std::ifstream in(path, std::ios::binary); + const std::string contents((std::istreambuf_iterator<char>(in)), + std::istreambuf_iterator<char>()); + EXPECT_EQ(contents, *png_or); +} + +TEST(AssetWriterTest, RejectsUnsupportedMediaType) { + riegeli::StringReader<> source("fake_unknown_bytes"); + std::string output_bytes; + riegeli::StringWriter<> destination(&output_bytes); + + GenerateManifestStoreRequest request; + request.mutable_actions()->mutable_created(); + + TestPki pki = CreateTestPki(); + EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true), + std::move(request), "application/octet-stream", source, + destination), + StatusIs(absl::StatusCode::kUnimplemented)); +} + +} // namespace +} // namespace credentio
diff --git a/tools/create_test_pki.sh b/tools/create_test_pki.sh new file mode 100755 index 0000000..0051d8c --- /dev/null +++ b/tools/create_test_pki.sh
@@ -0,0 +1,171 @@ +#!/bin/bash +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# +# Creates a private root CA and, issued by it, EC P-256 keys and certificates +# for a C2PA claim signer and a Time Stamping Authority (TSA). The output is +# intended for local testing of tools:asset_write and tools:c2pa_validate only. +# +# Usage: create_test_pki.sh [output_dir] +# +# output_dir defaults to ./test_pki and must not already contain keys. +# +# The certificates follow the C2PA certificate profile checked by the +# validator: X.509 v3 with the digitalSignature key usage. The claim signer +# certificate carries the C2PA claim signing EKU (1.3.6.1.4.1.62558.2.1) and no +# anyExtendedKeyUsage. The TSA certificate carries a critical timeStamping EKU +# as required by RFC 3161. +# +# Keep root_ca_key.pem private: anyone holding it can issue certificates that +# validators configured with root_ca_cert.pem will trust. +# +# Requires OpenSSL 1.1.1 or later. + +set -euo pipefail + +readonly CURVE="P-256" +readonly ROOT_CA_DAYS=3650 +readonly LEAF_DAYS=365 +readonly ORGANIZATION="Credentio Test" + +####################################### +# Writes the OpenSSL config with the certificate extensions for each profile. +# Arguments: +# Path of the config file to write. +####################################### +write_openssl_config() { + cat > "$1" <<'EOF' +[ req ] +distinguished_name = req_dn + +[ req_dn ] + +[ root_ca ] +basicConstraints = critical, CA:TRUE +keyUsage = critical, keyCertSign, cRLSign +subjectKeyIdentifier = hash + +# C2PA claim signer: digitalSignature KU and the C2PA claim signing EKU. +[ claim_signer ] +basicConstraints = critical, CA:FALSE +keyUsage = critical, digitalSignature +extendedKeyUsage = 1.3.6.1.4.1.62558.2.1 +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always + +# RFC 3161 TSA: timeStamping must be the only EKU and marked critical. +[ tsa ] +basicConstraints = critical, CA:FALSE +keyUsage = critical, digitalSignature +extendedKeyUsage = critical, timeStamping +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always +EOF +} + +####################################### +# Generates an EC private key in PKCS#8 PEM format. +# Arguments: +# Output key path. +####################################### +generate_key() { + openssl genpkey -algorithm EC -pkeyopt "ec_paramgen_curve:${CURVE}" \ + -out "$1" 2>/dev/null + chmod 600 "$1" +} + +####################################### +# Creates the self-signed root CA key and certificate. +# Arguments: +# Output directory. +####################################### +create_root_ca() { + local dir="$1" + generate_key "${dir}/root_ca_key.pem" + openssl req -x509 -new -key "${dir}/root_ca_key.pem" -sha256 \ + -days "${ROOT_CA_DAYS}" \ + -subj "/O=${ORGANIZATION}/CN=${ORGANIZATION} Root CA" \ + -config "${dir}/openssl.cnf" -extensions root_ca \ + -out "${dir}/root_ca_cert.pem" +} + +####################################### +# Creates a key and a certificate issued by the root CA. +# Arguments: +# Output directory. +# Name of the profile section in openssl.cnf; also the output file prefix. +# Common name of the certificate subject. +####################################### +issue_leaf() { + local dir="$1" + local profile="$2" + local common_name="$3" + + generate_key "${dir}/${profile}_key.pem" + openssl req -new -key "${dir}/${profile}_key.pem" \ + -subj "/O=${ORGANIZATION}/CN=${common_name}" \ + -config "${dir}/openssl.cnf" | + openssl x509 -req -sha256 -days "${LEAF_DAYS}" \ + -CA "${dir}/root_ca_cert.pem" -CAkey "${dir}/root_ca_key.pem" \ + -set_serial "0x$(openssl rand -hex 16)" \ + -extfile "${dir}/openssl.cnf" -extensions "${profile}" \ + -out "${dir}/${profile}_cert.pem" 2>/dev/null +} + +main() { + if [[ $# -gt 1 ]]; then + echo "Usage: $0 [output_dir]" >&2 + exit 1 + fi + local dir="${1:-test_pki}" + + if ! command -v openssl >/dev/null; then + echo "Error: openssl not found in PATH." >&2 + exit 1 + fi + if compgen -G "${dir}/*_key.pem" >/dev/null; then + echo "Error: ${dir} already contains keys; refusing to overwrite." >&2 + exit 1 + fi + + mkdir -p "${dir}" + write_openssl_config "${dir}/openssl.cnf" + create_root_ca "${dir}" + issue_leaf "${dir}" claim_signer "${ORGANIZATION} Claim Signer" + issue_leaf "${dir}" tsa "${ORGANIZATION} TSA" + openssl verify -CAfile "${dir}/root_ca_cert.pem" \ + "${dir}/claim_signer_cert.pem" "${dir}/tsa_cert.pem" >/dev/null + + local abs_dir + abs_dir="$(cd "${dir}" && pwd)" + cat <<EOF +Created test PKI in ${abs_dir}: + + asset_write flags: + --claim_signer_key=${abs_dir}/claim_signer_key.pem + --claim_signer_cert=${abs_dir}/claim_signer_cert.pem + --tsa_key=${abs_dir}/tsa_key.pem + --tsa_cert=${abs_dir}/tsa_cert.pem + + c2pa_validate flags: + --claim_signer_trust=${abs_dir}/root_ca_cert.pem + --tsa_trust=${abs_dir}/root_ca_cert.pem + +These credentials are for local testing only. Keep root_ca_key.pem private. +EOF +} + +main "$@"
diff --git a/tsp/BUILD b/tsp/BUILD index 626b70e..0d19e95 100644 --- a/tsp/BUILD +++ b/tsp/BUILD
@@ -120,6 +120,7 @@ "//crypto/default:hasher", "//testing:boringssl_utils", "//testing:cms", + "@abseil-cpp//absl/base:no_destructor", "@abseil-cpp//absl/base:nullability", "@abseil-cpp//absl/log", "@abseil-cpp//absl/log:absl_check", @@ -132,11 +133,16 @@ "@abseil-cpp//absl/types:span", "@boringssl//:crypto", "@tink_cc//proto:common_cc_proto", - "@tink_cc//tink:cleartext_keyset_handle", + "@tink_cc//tink:insecure_secret_key_access", + "@tink_cc//tink:key_status", "@tink_cc//tink:keyset_handle", + "@tink_cc//tink:partial_key_access", "@tink_cc//tink:public_key_sign", + "@tink_cc//tink/pem:signature_key_parser", "@tink_cc//tink/signature:config_v0", - "@tink_cc//tink/signature:signature_pem_keyset_reader", + "@tink_cc//tink/signature:ecdsa_parameters", + "@tink_cc//tink/signature:ecdsa_private_key", + "@tink_cc//tink/signature:signature_config", ], ) @@ -156,7 +162,7 @@ "//crypto:cbs_utils", "//testing:test_file_utils", "@abseil-cpp//absl/base:no_destructor", - "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/log:absl_check", "@abseil-cpp//absl/status", "@abseil-cpp//absl/status:status_matchers", "@abseil-cpp//absl/strings",
diff --git a/tsp/test_helpers.cc b/tsp/test_helpers.cc index 07f3432..0e734e1 100644 --- a/tsp/test_helpers.cc +++ b/tsp/test_helpers.cc
@@ -24,6 +24,7 @@ #include <variant> #include <vector> +#include "absl/base/no_destructor.h" #include "absl/base/nullability.h" #include "absl/log/absl_check.h" #include "absl/log/check.h" @@ -51,34 +52,47 @@ #include "proto/common.pb.h" #include "testing/boringssl_utils.h" #include "testing/cms.h" -#include "tink/cleartext_keyset_handle.h" +#include "tink/insecure_secret_key_access.h" +#include "tink/key_status.h" #include "tink/keyset_handle.h" +#include "tink/partial_key_access.h" +#include "tink/pem/signature_key_parser.h" #include "tink/public_key_sign.h" #include "tink/signature/config_v0.h" -#include "tink/signature/signature_pem_keyset_reader.h" +#include "tink/signature/ecdsa_parameters.h" +#include "tink/signature/ecdsa_private_key.h" +#include "tink/signature/signature_config.h" #include "tsp/constants.h" #include "tsp/status_codes.h" namespace credentio { namespace { +using ::crypto::tink::EcdsaParameters; +using ::crypto::tink::EcdsaPrivateKey; using ::crypto::tink::KeysetHandle; +using ::crypto::tink::KeysetHandleBuilder; using ::crypto::tink::PublicKeySign; -using ::crypto::tink::SignaturePemKeysetReaderBuilder; -absl::StatusOr<std::unique_ptr<crypto::tink::KeysetHandle>> -LoadEcdsaPrivateKeyFromPem(absl::string_view pem_data) { - crypto::tink::SignaturePemKeysetReaderBuilder builder( - SignaturePemKeysetReaderBuilder::PemReaderType::PUBLIC_KEY_SIGN); - builder.Add({.serialized_key = std::string(pem_data), - .parameters = { - .key_type = crypto::tink::PemKeyType::PEM_EC, - .algorithm = crypto::tink::PemAlgorithm::ECDSA_DER, - .key_size_in_bits = 256, - .hash_type = google::crypto::tink::HashType::SHA256, - }}); - ABSL_ASSIGN_OR_RETURN(auto reader, builder.Build()); - return crypto::tink::CleartextKeysetHandle::Read(std::move(reader)); +absl::StatusOr<KeysetHandle> LoadEcdsaPrivateKeyFromPem( + absl::string_view pem_data) { + ABSL_ASSIGN_OR_RETURN( + EcdsaParameters params, + EcdsaParameters::Builder() + .SetCurveType(EcdsaParameters::CurveType::kNistP256) + .SetHashType(EcdsaParameters::HashType::kSha256) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build()); + ABSL_ASSIGN_OR_RETURN( + EcdsaPrivateKey private_key, + tink_pem::PemToEcdsaPrivateKey( + pem_data, params, crypto::tink::InsecureSecretKeyAccess::Get(), + crypto::tink::GetPartialKeyAccess())); + KeysetHandleBuilder builder; + builder.AddEntry(KeysetHandleBuilder::Entry::CreateFromCopyableKey( + private_key, crypto::tink::KeyStatus::kEnabled, /*is_primary=*/true)); + return builder.Build(); } absl::StatusOr<std::vector<uint8_t>> SignWithBoringSsl( @@ -283,11 +297,14 @@ std::optional<std::vector<const X509*>> extra_certs, std::variant<absl::string_view, bool> signing_certificate_attribute, bool use_wrong_e_content_type) { - auto keyset_handle = LoadEcdsaPrivateKeyFromPem(key_pem); + static const absl::NoDestructor<absl::Status> registration_status( + []() { return crypto::tink::SignatureConfig::Register(); }()); + ABSL_CHECK_OK(*registration_status); + absl::StatusOr<KeysetHandle> keyset_handle = + LoadEcdsaPrivateKeyFromPem(key_pem); ABSL_CHECK_OK(keyset_handle); - auto signer = (*keyset_handle) - ->GetPrimitive<crypto::tink::PublicKeySign>( - crypto::tink::ConfigSignatureV0()); + auto signer = keyset_handle->GetPrimitive<crypto::tink::PublicKeySign>( + crypto::tink::ConfigSignatureV0()); ABSL_CHECK_OK(signer); std::unique_ptr<crypto::tink::PublicKeySign> signer_ptr = *std::move(signer);
diff --git a/tsp/timestamp_parsing_test.cc b/tsp/timestamp_parsing_test.cc index 46c985e..438124f 100644 --- a/tsp/timestamp_parsing_test.cc +++ b/tsp/timestamp_parsing_test.cc
@@ -20,7 +20,7 @@ #include <string> #include "absl/base/no_destructor.h" -#include "absl/log/check.h" +#include "absl/log/absl_check.h" #include "absl/status/status.h" #include "absl/status/status_matchers.h" #include "absl/strings/str_cat.h" @@ -53,7 +53,7 @@ constexpr absl::string_view kMessageImprintHash = "fake message imprint"; -constexpr absl::string_view kTestKeysDir = "c2pa/testing/testdata/keys/"; +constexpr absl::string_view kTestKeysDir = "credentio/testing/testdata/keys/"; absl::string_view ValidNonce() { static absl::NoDestructor<std::string> nonce(EncodeDerInteger(0xf00dbabe)); @@ -63,14 +63,14 @@ std::string LoadTestCryptoAsset(absl::string_view short_path) { std::string file_path = absl::StrCat(kTestKeysDir, short_path); auto contents = credentio_testing::GetContents(file_path); - CHECK_OK(contents.status()); + ABSL_CHECK_OK(contents.status()); return *contents; } absl::Time ParseGenTime(absl::string_view timestamp) { absl::Time time; std::string parse_error; - CHECK(absl::ParseTime(kGenTimeFormat, timestamp, &time, &parse_error)) + ABSL_CHECK(absl::ParseTime(kGenTimeFormat, timestamp, &time, &parse_error)) << "ParseTime(\"" << kGenTimeFormat << "\", \"" << timestamp << "\"): " << parse_error; return time; @@ -145,13 +145,13 @@ TEST(ParseTimeStampRespTest, InvalidPkiStatusInfo) { CBB cbb; - CHECK(CBB_init(&cbb, 1024)); + ABSL_CHECK(CBB_init(&cbb, 1024)); CBB req; - CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE)); - CHECK(CBB_add_bytes(&req, reinterpret_cast<const uint8_t*>("12345"), 5)); + ABSL_CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(CBB_add_bytes(&req, reinterpret_cast<const uint8_t*>("12345"), 5)); uint8_t* cbb_data; size_t cbb_len; - CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); + ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data); EXPECT_THAT(ParseTimestampResp(absl::string_view( @@ -162,16 +162,16 @@ TEST(ParseTimeStampRespTest, InvalidStatusCode) { CBB cbb; - CHECK(CBB_init(&cbb, 1024)); + ABSL_CHECK(CBB_init(&cbb, 1024)); CBB req; - CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE)); CBB pki_status_info; - CHECK(CBB_add_asn1(&req, &pki_status_info, CBS_ASN1_SEQUENCE)); - CHECK(CBB_add_bytes(&pki_status_info, - reinterpret_cast<const uint8_t*>("12345"), 5)); + ABSL_CHECK(CBB_add_asn1(&req, &pki_status_info, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(CBB_add_bytes(&pki_status_info, + reinterpret_cast<const uint8_t*>("12345"), 5)); uint8_t* cbb_data; size_t cbb_len; - CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); + ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data); EXPECT_THAT(ParseTimestampResp(absl::string_view( @@ -182,17 +182,17 @@ TEST(ParseTimeStampRespTest, InvalidTimestampToken) { CBB cbb; - CHECK(CBB_init(&cbb, 1024)); + ABSL_CHECK(CBB_init(&cbb, 1024)); CBB req; - CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE)); CBB pki_status_info; - CHECK(CBB_add_asn1(&req, &pki_status_info, CBS_ASN1_SEQUENCE)); - CHECK(CBB_add_asn1_int64( + ABSL_CHECK(CBB_add_asn1(&req, &pki_status_info, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(CBB_add_asn1_int64( &pki_status_info, static_cast<int64_t>(TspPkiStatus::kGrantedWithMods))); - CHECK(CBB_add_bytes(&req, reinterpret_cast<const uint8_t*>("12345"), 5)); + ABSL_CHECK(CBB_add_bytes(&req, reinterpret_cast<const uint8_t*>("12345"), 5)); uint8_t* cbb_data; size_t cbb_len; - CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); + ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data); EXPECT_THAT(ParseTimestampResp(absl::string_view( @@ -284,15 +284,15 @@ TEST(ParseTstInfoTest, InvalidVersion) { CBB cbb; - CHECK(CBB_init(&cbb, 1024)); + ABSL_CHECK(CBB_init(&cbb, 1024)); CBB tst_info; - CHECK(CBB_add_asn1(&cbb, &tst_info, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(CBB_add_asn1(&cbb, &tst_info, CBS_ASN1_SEQUENCE)); // Version - should be INTEGER - CHECK(CBB_add_asn1_octet_string(&tst_info, nullptr, 0)); + ABSL_CHECK(CBB_add_asn1_octet_string(&tst_info, nullptr, 0)); uint8_t* cbb_data; size_t cbb_len; - CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); + ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data); absl::Time time; @@ -309,17 +309,17 @@ TEST(ParseTstInfoTest, InvalidPolicy) { CBB cbb; - CHECK(CBB_init(&cbb, 1024)); + ABSL_CHECK(CBB_init(&cbb, 1024)); CBB tst_info; - CHECK(CBB_add_asn1(&cbb, &tst_info, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(CBB_add_asn1(&cbb, &tst_info, CBS_ASN1_SEQUENCE)); // Version - CHECK(CBB_add_asn1_int64(&tst_info, 1)); + ABSL_CHECK(CBB_add_asn1_int64(&tst_info, 1)); // Policy - should be OBJECT IDENTIFIER - CHECK(CBB_add_asn1_int64(&tst_info, 12345)); + ABSL_CHECK(CBB_add_asn1_int64(&tst_info, 12345)); uint8_t* cbb_data; size_t cbb_len; - CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); + ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data); absl::Time time;
diff --git a/utils/BUILD b/utils/BUILD index a8d25b0..3d84b03 100644 --- a/utils/BUILD +++ b/utils/BUILD
@@ -324,9 +324,11 @@ "//proto:validation_result_cc_proto", "//proto:validation_status_cc_proto", "//tsp:timestamp_verifier", + "//validator:result", "@abseil-cpp//absl/base:nullability", "@abseil-cpp//absl/container:flat_hash_map", "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", "@abseil-cpp//absl/status:statusor", "@abseil-cpp//absl/strings", "@abseil-cpp//absl/strings:string_view", @@ -371,3 +373,38 @@ "@nlohmann_json//:json", ], ) + +cc_library( + name = "cbor_padding", + srcs = ["cbor_padding.cc"], + hdrs = ["cbor_padding.h"], + deps = [ + "//cose:sig_structure", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:multi_asset_hash_assertion_cc_proto", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings", + "@libcppbor", + ], +) + +cc_test( + name = "cbor_padding_test", + srcs = ["cbor_padding_test.cc"], + deps = [ + ":cbor_padding", + "//cose:sig_structure", + "//crypto:algorithms", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cbor_proto", + "//proto:data_hash_assertion_cc_proto", + "//testing:proto_test_utils", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +)
diff --git a/utils/byte_instruction.cc b/utils/byte_instruction.cc index c04b707..030f3c7 100644 --- a/utils/byte_instruction.cc +++ b/utils/byte_instruction.cc
@@ -17,7 +17,9 @@ #include <algorithm> #include <cstdint> +#include <optional> #include <string> +#include <vector> #include "absl/base/nullability.h" #include "absl/status/status.h" @@ -283,4 +285,41 @@ return absl::OkStatus(); } +uint64_t CalculateBytesWritten( + const std::vector<ByteInstruction>& byte_instructions, uint64_t file_size, + bool is_copy) { + uint64_t sum_replace_before_insert = 0; + uint64_t sum_insert = 0; + std::optional<uint64_t> first_insert_offset = + is_copy ? std::make_optional<uint64_t>(0) : std::nullopt; + + for (const auto& byte_instruction : byte_instructions) { + if (byte_instruction.offset >= file_size) { + // Instructions at or past the end of the original file do not replace + // any existing bytes; they are simply appended to the destination. + sum_insert += byte_instruction.bytes.size(); + } else if (byte_instruction.operation == + ByteInstruction::Operation::kReplace) { + if (!first_insert_offset.has_value()) { + sum_replace_before_insert += byte_instruction.bytes.size(); + } + } else if (byte_instruction.operation == + ByteInstruction::Operation::kInsert) { + sum_insert += byte_instruction.bytes.size(); + if (!first_insert_offset.has_value()) { + first_insert_offset = byte_instruction.offset; + } + } + } + + uint64_t bytes_shifted = 0; + if (!first_insert_offset.has_value() || *first_insert_offset >= file_size) { + bytes_shifted = 0; + } else { + bytes_shifted = file_size - *first_insert_offset; + } + + return bytes_shifted + sum_insert + sum_replace_before_insert; +} + } // namespace credentio
diff --git a/utils/byte_instruction.h b/utils/byte_instruction.h index d72f3e5..8afcab8 100644 --- a/utils/byte_instruction.h +++ b/utils/byte_instruction.h
@@ -18,6 +18,7 @@ #include <cstdint> #include <string> +#include <vector> #include "absl/base/nullability.h" #include "absl/status/status.h" @@ -73,6 +74,11 @@ absl::Span<const credentio::ByteInstruction> instructions, riegeli::Writer* absl_nonnull writer); +// Calculates the number of bytes that will be written by ApplyByteInstructions. +uint64_t CalculateBytesWritten( + const std::vector<ByteInstruction>& byte_instructions, uint64_t file_size, + bool is_copy); + } // namespace credentio #endif // THIRD_PARTY_CREDENTIO_UTILS_BYTE_INSTRUCTION_H_
diff --git a/utils/byte_instruction_test.cc b/utils/byte_instruction_test.cc index 5d0c0b0..5ea9922 100644 --- a/utils/byte_instruction_test.cc +++ b/utils/byte_instruction_test.cc
@@ -212,9 +212,9 @@ TEST_P(SuccessfulApplicationTest, AppliesByteInstructions) { riegeli::StringReader<std::string> reader(GetParam().input); - ASSERT_THAT(reader.status(), IsOk()); + ABSL_ASSERT_OK(reader.status()); riegeli::StringWriter<std::string> writer; - ASSERT_THAT(writer.status(), IsOk()); + ABSL_ASSERT_OK(writer.status()); EXPECT_THAT(ApplyByteInstructions(&reader, GetParam().instructions, &writer), IsOk()); @@ -228,7 +228,7 @@ TEST_P(SuccessfulApplicationTest, AppliesByteInstructionsInPlace) { riegeli::StringWriter<std::string> writer( GetParam().input, riegeli::StringWriterBase::Options().set_append(true)); - ASSERT_THAT(writer.status(), IsOk()); + ABSL_ASSERT_OK(writer.status()); EXPECT_THAT(ApplyByteInstructions(GetParam().instructions, &writer), IsOk()); @@ -239,11 +239,11 @@ TEST(SuccessfulApplicationTest, ReaderNotAtStart) { riegeli::StringReader<> reader("hello world"); - ASSERT_THAT(reader.status(), IsOk()); + ABSL_ASSERT_OK(reader.status()); ASSERT_TRUE(reader.Seek(8)); riegeli::StringWriter<std::string> writer; - ASSERT_THAT(writer.status(), IsOk()); + ABSL_ASSERT_OK(writer.status()); EXPECT_THAT( ApplyByteInstructions(&reader, @@ -262,7 +262,7 @@ TEST(SuccessfulApplicationTest, WriterNotAtStart) { riegeli::StringWriter<std::string> writer( "hello world", riegeli::StringWriterBase::Options().set_append(true)); - ASSERT_THAT(writer.status(), IsOk()); + ABSL_ASSERT_OK(writer.status()); ASSERT_TRUE(writer.Seek(8)); EXPECT_THAT( @@ -279,11 +279,11 @@ TEST(FailedApplicationTest, ReaderClosed) { riegeli::StringReader<> reader("hello world"); - ASSERT_THAT(reader.status(), IsOk()); + ABSL_ASSERT_OK(reader.status()); reader.Close(); riegeli::StringWriter<std::string> writer; - ASSERT_THAT(writer.status(), IsOk()); + ABSL_ASSERT_OK(writer.status()); EXPECT_THAT(ApplyByteInstructions(&reader, {}, &writer), StatusIs(absl::StatusCode::kFailedPrecondition, @@ -292,10 +292,10 @@ TEST(FailedApplicationTest, WriterClosed) { riegeli::StringReader<> reader("hello world"); - ASSERT_THAT(reader.status(), IsOk()); + ABSL_ASSERT_OK(reader.status()); riegeli::StringWriter<std::string> writer; - ASSERT_THAT(writer.status(), IsOk()); + ABSL_ASSERT_OK(writer.status()); writer.Close(); EXPECT_THAT(ApplyByteInstructions(&reader, {}, &writer), @@ -305,10 +305,10 @@ TEST(FailedApplicationTest, InstructionOutOfOrder) { riegeli::StringReader<> reader("hello world"); - ASSERT_THAT(reader.status(), IsOk()); + ABSL_ASSERT_OK(reader.status()); riegeli::StringWriter<std::string> writer; - ASSERT_THAT(writer.status(), IsOk()); + ABSL_ASSERT_OK(writer.status()); EXPECT_THAT( ApplyByteInstructions(&reader, @@ -336,10 +336,10 @@ TEST(FailedApplicationTest, UnexpectedEofDuringCopy) { PrematureEofReader reader("hello", 100); - ASSERT_THAT(reader.status(), IsOk()); + ABSL_ASSERT_OK(reader.status()); riegeli::StringWriter<std::string> writer; - ASSERT_THAT(writer.status(), IsOk()); + ABSL_ASSERT_OK(writer.status()); EXPECT_THAT( ApplyByteInstructions(&reader, @@ -353,10 +353,10 @@ TEST(FailedApplicationTest, UnexpectedEofDuringSkip) { PrematureEofReader reader("hello", 100); - ASSERT_THAT(reader.status(), IsOk()); + ABSL_ASSERT_OK(reader.status()); riegeli::StringWriter<std::string> writer; - ASSERT_THAT(writer.status(), IsOk()); + ABSL_ASSERT_OK(writer.status()); EXPECT_THAT( ApplyByteInstructions(&reader, @@ -371,7 +371,7 @@ TEST(FailedApplicationInPlaceTest, WriterClosed) { riegeli::StringWriter<std::string> writer( "hello world", riegeli::StringWriterBase::Options().set_append(true)); - ASSERT_THAT(writer.status(), IsOk()); + ABSL_ASSERT_OK(writer.status()); writer.Close(); EXPECT_THAT(ApplyByteInstructions({}, &writer), @@ -382,7 +382,7 @@ TEST(FailedApplicationInPlaceTest, InstructionOutOfOrder) { riegeli::StringWriter<std::string> writer( "hello world", riegeli::StringWriterBase::Options().set_append(true)); - ASSERT_THAT(writer.status(), IsOk()); + ABSL_ASSERT_OK(writer.status()); EXPECT_THAT( ApplyByteInstructions({{.operation = ByteInstruction::Operation::kReplace, @@ -396,5 +396,281 @@ HasSubstr("must be in ascending order"))); } +TEST(CalculateBytesWrittenTest, IsCopyEmptyFile) { + EXPECT_EQ(CalculateBytesWritten({}, /*file_size=*/0, /*is_copy=*/true), 0); + EXPECT_EQ( + CalculateBytesWritten({{.operation = ByteInstruction::Operation::kInsert, + .offset = 0, + .bytes = "hello"}}, + /*file_size=*/0, /*is_copy=*/true), + 5); +} + +TEST(CalculateBytesWrittenTest, IsCopyNoInstructions) { + EXPECT_EQ(CalculateBytesWritten({}, /*file_size=*/100, /*is_copy=*/true), + 100); +} + +TEST(CalculateBytesWrittenTest, IsCopyOnlyInserts) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 10, + .bytes = "hello"}, + {.operation = ByteInstruction::Operation::kInsert, + .offset = 50, + .bytes = "world!"}, + }; + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/true), + 100 + 5 + 6); +} + +TEST(CalculateBytesWrittenTest, IsCopyOnlyReplaces) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 10, + .bytes = "hello"}, + {.operation = ByteInstruction::Operation::kReplace, + .offset = 50, + .bytes = "world!"}, + }; + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/true), + 100); +} + +TEST(CalculateBytesWrittenTest, IsCopyMixed) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 10, + .bytes = "replace"}, + {.operation = ByteInstruction::Operation::kInsert, + .offset = 40, + .bytes = "insert_data"}, + }; + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/true), + 100 + 11); +} + +TEST(CalculateBytesWrittenTest, InPlaceEmptyFile) { + EXPECT_EQ(CalculateBytesWritten({}, /*file_size=*/0, /*is_copy=*/false), 0); + EXPECT_EQ( + CalculateBytesWritten({{.operation = ByteInstruction::Operation::kInsert, + .offset = 0, + .bytes = "hello"}}, + /*file_size=*/0, /*is_copy=*/false), + 5); + EXPECT_EQ( + CalculateBytesWritten({{.operation = ByteInstruction::Operation::kReplace, + .offset = 0, + .bytes = "hello"}}, + /*file_size=*/0, /*is_copy=*/false), + 5); +} + +TEST(CalculateBytesWrittenTest, InPlaceNoInstructions) { + EXPECT_EQ(CalculateBytesWritten({}, /*file_size=*/100, /*is_copy=*/false), 0); +} + +TEST(CalculateBytesWrittenTest, InPlaceOnlyReplaces) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 5, + .bytes = "abc"}, + {.operation = ByteInstruction::Operation::kReplace, + .offset = 50, + .bytes = "12345"}, + }; + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false), + 3 + 5); +} + +TEST(CalculateBytesWrittenTest, InPlaceOnlyInsertsSingle) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 40, + .bytes = "12345"}, + }; + // Shifted bytes: (100 - 40) = 60, inserted bytes: 5 -> total 65 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false), + 65); +} + +TEST(CalculateBytesWrittenTest, InPlaceOnlyInsertsMultiple) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 20, + .bytes = "12345"}, + {.operation = ByteInstruction::Operation::kInsert, + .offset = 50, + .bytes = "abc"}, + }; + // First insert offset: 20 -> Shifted bytes: 80, inserts: 5 + 3 = 8 -> total + // 88 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false), + 88); +} + +TEST(CalculateBytesWrittenTest, InPlaceReplaceBeforeInsert) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 5, + .bytes = "1234567890"}, + {.operation = ByteInstruction::Operation::kInsert, + .offset = 40, + .bytes = "abcdefghij"}, + }; + // Replace before insert: 10, first insert offset: 40 -> shifted: 60, insert: + // 10 -> total 10 + 60 + 10 = 80 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false), + 80); +} + +TEST(CalculateBytesWrittenTest, InPlaceReplaceAfterInsert) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 10, + .bytes = "12345"}, + {.operation = ByteInstruction::Operation::kReplace, + .offset = 50, + .bytes = "abcdefghij"}, + }; + // First insert offset: 10 -> shifted: 90, insert: 5, replace before insert: 0 + // -> total 95 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false), + 95); +} + +TEST(CalculateBytesWrittenTest, InPlaceReplaceBeforeAndAfterInsert) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 2, + .bytes = "12345678"}, + {.operation = ByteInstruction::Operation::kInsert, + .offset = 20, + .bytes = "123456789012345"}, + {.operation = ByteInstruction::Operation::kReplace, + .offset = 60, + .bytes = "123456789012"}, + }; + // Replace before insert: 8, first insert offset: 20 -> shifted: 80, insert: + // 15 -> total 8 + 80 + 15 = 103 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false), + 103); +} + +TEST(CalculateBytesWrittenTest, InPlaceInsertPastEndOfFile) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 120, + .bytes = "1234567890"}, // 10 bytes past EOF (file_size = 100) + }; + // Offset 120 >= 100: no shifted original bytes -> bytes_shifted = 0, insert = + // 10 -> total 10 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false), + 10); +} + +TEST(CalculateBytesWrittenTest, InPlaceInsertPastEndOfFileWithPriorReplace) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 10, + .bytes = "12345"}, // 5 bytes replaced before insert + {.operation = ByteInstruction::Operation::kInsert, + .offset = 120, + .bytes = "1234567890"}, // 10 bytes past EOF + }; + // Replace before insert = 5, offset 120 >= 100: bytes_shifted = 0, insert = + // 10 -> total 15 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false), + 15); +} + +TEST(CalculateBytesWrittenTest, InPlaceInsertAtEndOfFileBoundary) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 100, + .bytes = "1234567890"}, // 10 bytes at exact EOF (file_size = 100) + }; + // Offset 100 >= 100: bytes_shifted = 0, insert = 10 -> total 10 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false), + 10); +} + +TEST(CalculateBytesWrittenTest, InPlaceInsertJustBeforeEndOfFile) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 99, + .bytes = "1234567890"}, // 10 bytes at offset 99 (file_size = 100) + }; + // Offset 99 < 100: bytes_shifted = 100 - 99 = 1, insert = 10 -> total 11 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false), + 11); +} + +TEST(CalculateBytesWrittenTest, IsCopyInsertPastEndOfFile) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 120, + .bytes = "1234567890"}, // 10 bytes past EOF (file_size = 100) + }; + // In copy mode, entire original file is written -> bytes_shifted = 100, + // insert = 10 -> total 110 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/true), + 110); +} + +TEST(CalculateBytesWrittenTest, IsCopyEmptyFileWithReplace) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 0, + .bytes = "12345"}, // Replace 5 bytes on file_size = 0 + }; + // file_size = 0, replace 5 bytes: instruction is written as append -> total 5 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/0, /*is_copy=*/true), + 5); +} + +TEST(CalculateBytesWrittenTest, InPlaceReplacePastEndOfFileNoInsert) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 120, + .bytes = "12345"}, // 5 bytes replace past EOF (file_size = 100) + }; + // No insert: bytes_shifted = 0, sum_replace_before_insert = 5 -> total 5 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false), + 5); +} + +TEST(CalculateBytesWrittenTest, InPlaceMultipleInsertsFirstPastEOF) { + std::vector<ByteInstruction> instructions = { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 120, + .bytes = "12345"}, // 5 bytes at offset 120 + {.operation = ByteInstruction::Operation::kInsert, + .offset = 150, + .bytes = "1234567890"}, // 10 bytes at offset 150 + }; + // First insert offset 120 >= 100: bytes_shifted = 0, sum_insert = 15 -> total + // 15 + EXPECT_EQ( + CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false), + 15); +} + } // namespace } // namespace credentio
diff --git a/utils/cbor_padding.cc b/utils/cbor_padding.cc new file mode 100644 index 0000000..383ccf3 --- /dev/null +++ b/utils/cbor_padding.cc
@@ -0,0 +1,202 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "utils/cbor_padding.h" + +#include <cstddef> +#include <cstdint> +#include <optional> +#include <string> + +#include "absl/status/status.h" +#include "absl/strings/str_cat.h" +#include "cose/sig_structure.h" +#include "cppbor/cppbor.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/multi_asset_hash_assertion.pb.h" + +namespace credentio { +namespace { +// See headerSize() function in cppbor.cpp +constexpr size_t kIntMaxEncodeByteSize = 9; +// The header byte size of a CBOR-encoded `TstContainer` that includes only +// one token of length 0. +constexpr size_t kTstContainerHeaderByteSize = 26; +// This is a value used to pad the empty COSE_Sign1 structure to the expected +// overall size. It is the initial size of pad2 to use when padding a COSE_Sign1 +// message and can be decremented to get us to the desired size. This is set to +// 8 because any increase in pad to the expected size can at most result in an +// overshoot of 8 bytes. This occurs when the expected size is 4,294,967,296 +// bytes greater than the initial marshalled sign1 size. headerSize(4294967296) +// = 9 and headerSize(0) = 1, so 9 - 1 = 8. +constexpr int kInitialPad2Size = 8; + +size_t EncodeByteSize(int64_t v) { + if (v < 0) { + return cppbor::Nint(v).encodedSize(); + } + return cppbor::Uint(v).encodedSize(); +} +} // namespace + +void PadDataHashAssertion(DataHashAssertion& data_hash) { + data_hash.set_pad(""); // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html + // requires its presence. + data_hash.clear_pad2(); + if (data_hash.exclusions_size() == 0) { + return; + } + size_t pad_size = 0; + for (const auto& exclusion : data_hash.exclusions()) { + pad_size += kIntMaxEncodeByteSize - EncodeByteSize(exclusion.start()); + pad_size += kIntMaxEncodeByteSize - EncodeByteSize(exclusion.length()); + } + data_hash.set_pad(std::string(pad_size, '\0')); + // `pad` is a Bstr, and its header encodes the length as an Uint, which itself + // is a variable of encoded sizes. We use an optional `pad2` to count for this + // variable. + size_t max_pad_size = + (kIntMaxEncodeByteSize - 1) * 2 * data_hash.exclusions_size(); + if (cppbor::Uint(max_pad_size).encodedSize() > 1) { + // The pad_size now becomes a variable of byte sizes, since it can be + // encoded as 1, 2, 3, 5 or 9 bytes, depending on the value. + size_t header_delta = cppbor::Uint(max_pad_size).encodedSize() - + cppbor::Uint(pad_size).encodedSize(); + data_hash.set_pad2(std::string(header_delta, '\0')); + } +} + +void PadBoxesHashAssertion(BoxesHashAssertion& boxes_hash) { + for (auto& box : *boxes_hash.mutable_boxes()) { + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html + // requires `pad` to be set. + box.set_pad(""); + // Optional, but set it in case some third-party validators require it. + box.set_pad2(""); + } +} + +void PadMultiAssetHashAssertion(MultiAssetHashAssertion& multi_asset_hash) { + multi_asset_hash.set_pad(""); // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html + // requires its presence. + multi_asset_hash.clear_pad2(); + if (multi_asset_hash.parts_size() == 0) { + return; + } + size_t pad_size = 0; + for (const auto& part : multi_asset_hash.parts()) { + pad_size += + kIntMaxEncodeByteSize - EncodeByteSize(part.location().byte_offset()); + pad_size += + kIntMaxEncodeByteSize - EncodeByteSize(part.location().length()); + } + multi_asset_hash.set_pad(std::string(pad_size, '\0')); + // `pad` is a Bstr, and its header encodes the length as an Uint, which itself + // is a variable of encoded sizes. We use an optional `pad2` to count for this + // variable. + size_t max_pad_size = + (kIntMaxEncodeByteSize - 1) * 2 * multi_asset_hash.parts_size(); + if (cppbor::Uint(max_pad_size).encodedSize() > 1) { + // The pad_size now becomes a variable of byte sizes, since it can be + // encoded as 1, 2, 3, 5 or 9 bytes, depending on the value. + size_t header_delta = cppbor::Uint(max_pad_size).encodedSize() - + cppbor::Uint(pad_size).encodedSize(); + multi_asset_hash.set_pad2(std::string(header_delta, '\0')); + } +} + +absl::Status PadCoseSign1TaggedStructure(CoseSign1TaggedStructure& cose_sign1, + int64_t max_signature_size, + int64_t max_timestamp_size) { + if (cose_sign1.unprotected_header.sig_tst2.has_value() && + cose_sign1.unprotected_header.sig_tst2->tst_tokens.size() > 1) { + return absl::InternalError("multiple timestamp tokens"); + } + if (cose_sign1.signature.size() > max_signature_size) { + return absl::InternalError( + "signature size is larger than the maximum allowed."); + } + if (cose_sign1.unprotected_header.sig_tst2.has_value() && + cose_sign1.unprotected_header.sig_tst2->tst_tokens[0].val.size() > + max_timestamp_size) { + return absl::InternalError( + "timestamp size is larger than the maximum allowed."); + } + + // Clear paddings. + cose_sign1.unprotected_header.pad = std::nullopt; + cose_sign1.unprotected_header.pad2 = std::nullopt; + + // Pad signature. + int64_t pad_size = max_signature_size - cose_sign1.signature.size(); + // Header size overhead when encoding the signature bstr. + int64_t header_size = EncodeByteSize(max_signature_size) - + EncodeByteSize(cose_sign1.signature.size()); + + // Pad timestamp. + if (cose_sign1.unprotected_header.sig_tst2.has_value()) { + int64_t timestamp_size = + cose_sign1.unprotected_header.sig_tst2->tst_tokens[0].val.size(); + pad_size += max_timestamp_size - timestamp_size; + // Header size overhead when encoding the timestamp bstr. + header_size += + EncodeByteSize(max_timestamp_size) - EncodeByteSize(timestamp_size); + } else { + pad_size += max_timestamp_size + kTstContainerHeaderByteSize; + // Header size overhead when encoding the timestamp bstr. + header_size += EncodeByteSize(max_timestamp_size) - EncodeByteSize(0); + } + + // Write padding in the `pad` field. + cose_sign1.unprotected_header.pad = std::string(pad_size, '\0'); + + // Header size overhead when encoding the `pad` bstr. + int64_t max_pad_size = + max_signature_size + max_timestamp_size + kTstContainerHeaderByteSize; + header_size += EncodeByteSize(max_pad_size) - EncodeByteSize(pad_size); + + // Write header overhead padding in the `pad2` field. + cose_sign1.unprotected_header.pad2 = std::string(header_size, '\0'); + if (EncodeByteSize(header_size) != 1) { + return absl::InternalError( + "the additional encoded length of the pad2 header does " + "not match the assumption of 1."); + } + return absl::OkStatus(); +} + +absl::Status PadCoseSign1TaggedStructure(CoseSign1TaggedStructure& cose_sign1, + size_t target_size) { + cose_sign1.unprotected_header.pad = ""; + cose_sign1.unprotected_header.pad2 = std::string(kInitialPad2Size, '\0'); + size_t current_size = EncodeCoseSign1TaggedStructure(cose_sign1).size(); + if (target_size < current_size) { + // This should never actually occur as long as target_size is + // set to be larger than the 25 bytes (Sign1 structure initial size). + return absl::InternalError(absl::StrCat("Expected size ", target_size, + " is smaller than base size ", + current_size)); + } + size_t pad_size = target_size - current_size; + cose_sign1.unprotected_header.pad = std::string(pad_size, '\0'); + int pad_prefix_length_delta = + cppbor::Uint(pad_size).encodedSize() - cppbor::Uint(0).encodedSize(); + int new_pad2_size = kInitialPad2Size - pad_prefix_length_delta; + cose_sign1.unprotected_header.pad2 = std::string(new_pad2_size, '\0'); + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/utils/cbor_padding.h b/utils/cbor_padding.h new file mode 100644 index 0000000..7ce218b --- /dev/null +++ b/utils/cbor_padding.h
@@ -0,0 +1,68 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_UTILS_CBOR_PADDING_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_CBOR_PADDING_H_ + +#include <cstddef> +#include <cstdint> + +#include "absl/status/status.h" +#include "cose/sig_structure.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/multi_asset_hash_assertion.pb.h" + +namespace credentio { +// Adds padding to DataHashAssertion to account for the variable encoding sizes +// of integers when serializing to CBOR. Given any two DataHashAssertion(s) that +// are identical except for the specific lengths/offsets in each exclusion, +// their CBOR-serialized sizes after applying this padding will be the same. +void PadDataHashAssertion(DataHashAssertion& data_hash); + +// Set an empty `pad` field for compatibility with other third-party validators. +void PadBoxesHashAssertion(BoxesHashAssertion& boxes_hash); + +// Adds padding to MultiAssetHashAssertion to account for the variable encoding +// sizes of integers when serializing to CBOR. Given any two +// MultiAssetHashAssertion(s) that are identical except for the specific +// lengths/offsets in each part location, their CBOR-serialized sizes after +// applying this padding will be the same. +void PadMultiAssetHashAssertion(MultiAssetHashAssertion& multi_asset_hash); + +// Adds padding to CoseSign1TaggedStructure to account for the variable +// encoding sizes of signature and timestamp bstr. It ensures that the +// encoded size is a function only of `max_signature_size`, +// `max_timestamp_size`, and the contents of `cose_sign1` other than the +// signature, timestamp, and padding. +// +// Returns an error if there is more than one timestamp or the signature or +// timestamp is too big. +absl::Status PadCoseSign1TaggedStructure(CoseSign1TaggedStructure& cose_sign1, + int64_t max_signature_size, + int64_t max_timestamp_size); + +// Pads the structure to an overall target size by adjusting the pad and pad2 +// fields. This handles cases where pad crosses the length prefix threshold +// (e.g. 23->24, 255->256, 65535->65536, etc.) +// +// Returns an error if the target size is smaller than the current size of the +// encoded structure. +absl::Status PadCoseSign1TaggedStructure(CoseSign1TaggedStructure& cose_sign1, + size_t target_size); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UTILS_CBOR_PADDING_H_
diff --git a/utils/cbor_padding_test.cc b/utils/cbor_padding_test.cc new file mode 100644 index 0000000..0411c88 --- /dev/null +++ b/utils/cbor_padding_test.cc
@@ -0,0 +1,485 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "utils/cbor_padding.h" + +#include <cstddef> +#include <cstdint> +#include <string> + +#include "absl/log/absl_check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "cose/sig_structure.h" +#include "crypto/algorithms.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/data_hash_assertion.cbor.h" +#include "proto/data_hash_assertion.pb.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::HasSubstr; +using ::testing::Le; +using ::testing::SizeIs; +using ::testing::TestWithParam; +using ::testing::Values; +using ::testing::ValuesIn; + +constexpr size_t kTstContainerHeaderByteSize = 26; + +struct Range { + int64_t start; + int64_t length; +}; + +size_t EncodeByteSize(const DataHashAssertion& data_hash) { + auto res = cbor::FromProto(data_hash, {}); + ABSL_CHECK_OK(res); + return (*res)->toString().size(); +} + +TEST(CborPaddingTest, PadDataHashAssertion) { + DataHashAssertion data_hash; + data_hash.set_alg("sha256"); + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + auto exclusion = data_hash.add_exclusions(); + exclusion->set_start(0); + exclusion->set_length(0); + data_hash.set_pad("to be replaced"); + data_hash.set_pad2("to be cleared"); + PadDataHashAssertion(data_hash); + DataHashAssertion expected = ParseTextProtoOrDie<DataHashAssertion>(R"pb( + exclusions { start: 0 length: 0 } + alg: "sha256" + hash: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + pad: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + )pb"); + EXPECT_THAT(data_hash, EqualsProto(expected)); +} + +TEST(CborPaddingTest, PadDataHashAssertionEmptyPadding) { + DataHashAssertion data_hash; + data_hash.set_alg("sha256"); + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + data_hash.set_pad("to be cleared"); + data_hash.set_pad2("to be cleared"); + PadDataHashAssertion(data_hash); + DataHashAssertion expected = ParseTextProtoOrDie<DataHashAssertion>(R"pb( + alg: "sha256" + hash: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + pad: "" + )pb"); + EXPECT_THAT(data_hash, EqualsProto(expected)); +} + +TEST(CborPaddingTest, PadDataHashAssertionWithPad2) { + DataHashAssertion data_hash; + data_hash.set_alg("sha256"); + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + for (int i = 0; i < 2; i++) { + auto exclusion = data_hash.add_exclusions(); + exclusion->set_start(0); + exclusion->set_length(0); + } + PadDataHashAssertion(data_hash); + DataHashAssertion expected = ParseTextProtoOrDie<DataHashAssertion>(R"pb( + exclusions { start: 0 length: 0 } + exclusions { start: 0 length: 0 } + alg: "sha256" + hash: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + pad: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + pad2: "" + )pb"); + EXPECT_THAT(data_hash, EqualsProto(expected)); +} + +TEST(CborPaddingTest, PadDataHashAssertionWithPad2Filled) { + DataHashAssertion data_hash; + data_hash.set_alg("sha256"); + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + for (int i = 0; i < 2; i++) { + auto exclusion = data_hash.add_exclusions(); + exclusion->set_start(0); + exclusion->set_length(4294967296); + } + PadDataHashAssertion(data_hash); + DataHashAssertion expected = ParseTextProtoOrDie<DataHashAssertion>(R"pb( + exclusions { start: 0 length: 4294967296 } + exclusions { start: 0 length: 4294967296 } + alg: "sha256" + hash: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + pad: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + pad2: "\000" + )pb"); + EXPECT_THAT(data_hash, EqualsProto(expected)); +} + +class PadDataHashTest : public TestWithParam<Range> {}; + +TEST_P(PadDataHashTest, PadDataHashAssertionTestWithParam) { + credentio::DataHashAssertion data_hash; + data_hash.set_alg("sha256"); + data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0')); + auto exclusion = data_hash.add_exclusions(); + exclusion->set_start(GetParam().start); + exclusion->set_length(GetParam().length); + PadDataHashAssertion(data_hash); + auto size_1 = EncodeByteSize(data_hash); + EXPECT_THAT(data_hash.pad().size() + data_hash.pad2().size(), Le(16)); + + data_hash.mutable_exclusions(0)->set_start(0); + data_hash.mutable_exclusions(0)->set_length(0); + PadDataHashAssertion(data_hash); + auto size_2 = EncodeByteSize(data_hash); + + EXPECT_EQ(size_1, size_2); +} + +constexpr Range kRanges[] = { + {.start = 0, .length = 0}, + {.start = 0, .length = 23}, + {.start = 0, .length = 24}, + {.start = 23, .length = 24}, + {.start = 24, .length = 255}, + {.start = 255, .length = 255}, + {.start = 255, .length = 256}, + {.start = 256, .length = 256}, + {.start = 256, .length = 65535}, + {.start = 256, .length = 65536}, + {.start = 65535, .length = 65536}, + {.start = 65536, .length = 65536}, + {.start = 65536, .length = 4294967295}, + {.start = 65536, .length = 4294967296}, + {.start = 4294967295, .length = 4294967296}, + {.start = 4294967296, .length = 4294967296}, + {.start = 9223372036854775807, .length = 9223372036854775807}, +}; + +INSTANTIATE_TEST_SUITE_P(PadDataHashAssertionTestWithParam, PadDataHashTest, + ValuesIn(kRanges)); + +TEST(CborPaddingTest, PadBoxesHashAssertion) { + BoxesHashAssertion boxes_hash = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { names: "name1" alg: "sha256" hash: "\000" } + boxes { names: "name2" alg: "sha256" hash: "\000" } + alg: "sha256" + )pb"); + PadBoxesHashAssertion(boxes_hash); + BoxesHashAssertion expected = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { names: "name1" alg: "sha256" hash: "\000" pad: "" pad2: "" } + boxes { names: "name2" alg: "sha256" hash: "\000" pad: "" pad2: "" } + alg: "sha256" + )pb"); + EXPECT_THAT(boxes_hash, EqualsProto(expected)); +} + +TEST(CborPaddingTest, PadCoseSign1TaggedStructureNoTimestamp) { + int64_t max_signature_size = 10; + int64_t max_timestamp_size = 10; + CoseSign1TaggedStructure cose_sign1; + cose_sign1.signature = std::string(max_signature_size, '\0'); + + CoseSign1TaggedStructure expected = cose_sign1; + expected.unprotected_header.pad = + std::string(max_timestamp_size + kTstContainerHeaderByteSize, '\0'); + expected.unprotected_header.pad2 = ""; + + ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size, + max_timestamp_size)); + EXPECT_EQ(cose_sign1, expected); +} + +TEST(CborPaddingTest, PadCoseSign1TaggedStructureEmptySignature) { + int64_t max_signature_size = 10; + int64_t max_timestamp_size = 10; + CoseSign1TaggedStructure cose_sign1; + cose_sign1.signature = ""; + cose_sign1.unprotected_header.sig_tst2 = TstContainer{ + .tst_tokens = {{.val = std::string(max_timestamp_size, '\0')}}}; + + CoseSign1TaggedStructure expected = cose_sign1; + expected.unprotected_header.pad = std::string(max_signature_size, '\0'); + expected.unprotected_header.pad2 = std::string(1, '\0'); + + ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size, + max_timestamp_size)); + EXPECT_EQ(cose_sign1, expected); +} + +TEST(CborPaddingTest, PadCoseSign1TaggedStructureEmptyTimestamp) { + int64_t max_signature_size = 10; + int64_t max_timestamp_size = 10; + CoseSign1TaggedStructure cose_sign1; + cose_sign1.signature = std::string(max_signature_size, '\0'); + cose_sign1.unprotected_header.sig_tst2 = + TstContainer{.tst_tokens = {{.val = ""}}}; + + CoseSign1TaggedStructure expected = cose_sign1; + expected.unprotected_header.pad = std::string(max_timestamp_size, '\0'); + expected.unprotected_header.pad2 = std::string(1, '\0'); + + ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size, + max_timestamp_size)); + EXPECT_EQ(cose_sign1, expected); +} + +TEST(CborPaddingTest, PadCoseSign1TaggedStructureEmptySignatureAndTimestamp) { + int64_t max_signature_size = 10; + int64_t max_timestamp_size = 10; + CoseSign1TaggedStructure cose_sign1; + cose_sign1.signature = ""; + cose_sign1.unprotected_header.sig_tst2 = + TstContainer{.tst_tokens = {{.val = ""}}}; + + CoseSign1TaggedStructure expected = cose_sign1; + expected.unprotected_header.pad = + std::string(max_signature_size + max_timestamp_size, '\0'); + expected.unprotected_header.pad2 = std::string(1, '\0'); + + ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size, + max_timestamp_size)); + EXPECT_EQ(cose_sign1, expected); +} + +TEST(CborPaddingTest, PadCoseSign1TaggedStructureFullSignatureAndTimestamp) { + int64_t max_signature_size = 10; + int64_t max_timestamp_size = 10; + CoseSign1TaggedStructure cose_sign1; + cose_sign1.signature = std::string(max_signature_size, '\0'); + cose_sign1.unprotected_header.sig_tst2 = TstContainer{ + .tst_tokens = {{.val = std::string(max_timestamp_size, '\0')}}}; + + CoseSign1TaggedStructure expected = cose_sign1; + expected.unprotected_header.pad = ""; + expected.unprotected_header.pad2 = std::string(1, '\0'); + + ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size, + max_timestamp_size)); + EXPECT_EQ(cose_sign1, expected); +} + +TEST(CborPaddingTest, + PadCoseSign1TaggedStructureWithSignatureAndTimestampDeltas) { + int64_t max_signature_size = 20; + int64_t max_timestamp_size = 200; + + CoseSign1TaggedStructure cose_sign1; + cose_sign1.signature = std::string(10, '\0'); + cose_sign1.unprotected_header.sig_tst2 = + TstContainer{.tst_tokens = {{.val = std::string(10, '\0')}}}; + + CoseSign1TaggedStructure expected = cose_sign1; + expected.unprotected_header.pad = + std::string(max_signature_size - 10 + max_timestamp_size - 10, '\0'); + expected.unprotected_header.pad2 = std::string(1, '\0'); + + ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size, + max_timestamp_size)); + EXPECT_EQ(cose_sign1, expected); +} + +TEST(CborPaddingTest, PadCoseSign1TaggedStructureSignatureTooBig) { + int64_t max_signature_size = 10; + int64_t max_timestamp_size = 10; + CoseSign1TaggedStructure cose_sign1; + cose_sign1.signature = std::string(max_signature_size + 1, '\0'); + cose_sign1.unprotected_header.sig_tst2 = TstContainer{ + .tst_tokens = {{.val = std::string(max_timestamp_size, '\0')}}}; + EXPECT_THAT(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size, + max_timestamp_size), + StatusIs(absl::StatusCode::kInternal, HasSubstr("signature"))); +} + +TEST(CborPaddingTest, PadCoseSign1TaggedStructureTimestampTooBig) { + int64_t max_signature_size = 10; + int64_t max_timestamp_size = 10; + CoseSign1TaggedStructure cose_sign1; + cose_sign1.signature = std::string(max_signature_size, '\0'); + cose_sign1.unprotected_header.sig_tst2 = TstContainer{ + .tst_tokens = {{.val = std::string(max_timestamp_size + 1, '\0')}}}; + EXPECT_THAT( + PadCoseSign1TaggedStructure(cose_sign1, max_signature_size, + max_timestamp_size), + StatusIs(absl::StatusCode::kInternal, HasSubstr("timestamp size"))); +} + +TEST(CborPaddingTest, PadCoseSign1TaggedStructureMultipleTimestampTokens) { + int64_t max_signature_size = 10; + int64_t max_timestamp_size = 10; + CoseSign1TaggedStructure cose_sign1; + cose_sign1.signature = std::string(max_signature_size, '\0'); + cose_sign1.unprotected_header.sig_tst2 = TstContainer{ + .tst_tokens = {{.val = std::string(max_timestamp_size, '\0')}, + {.val = std::string(max_timestamp_size, '\0')}}}; + EXPECT_THAT( + PadCoseSign1TaggedStructure(cose_sign1, max_signature_size, + max_timestamp_size), + StatusIs(absl::StatusCode::kInternal, HasSubstr("multiple timestamp"))); +} + +struct CoseSign1TaggedStructureInput { + int64_t signature_size; + int64_t timestamp_size; + int64_t signature_max_size; + int64_t timestamp_max_size; +}; + +class PadCoseSign1TaggedStructureTest + : public TestWithParam<CoseSign1TaggedStructureInput> {}; + +TEST_P(PadCoseSign1TaggedStructureTest, PadCoseSign1TaggedStructureWithParams) { + CoseSign1TaggedStructure cose_sign1; + cose_sign1.signature = std::string(GetParam().signature_size, '\0'); + if (GetParam().timestamp_size > 0) { + cose_sign1.unprotected_header.sig_tst2 = TstContainer{ + .tst_tokens = {{.val = std::string(GetParam().timestamp_size, '\0')}}}; + } + int64_t max_signature_size = GetParam().signature_max_size; + int64_t max_timestamp_size = GetParam().timestamp_max_size; + ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size, + max_timestamp_size)); + + CoseSign1TaggedStructure cose_sign2; + cose_sign2.signature = std::string(max_signature_size, '\0'); + ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign2, max_signature_size, + max_timestamp_size)); + + EXPECT_EQ(EncodeCoseSign1TaggedStructure(cose_sign1).size(), + EncodeCoseSign1TaggedStructure(cose_sign2).size()); +} +constexpr CoseSign1TaggedStructureInput kCoseSign1TaggedInputs[] = { + { + .signature_size = 10, + .timestamp_size = 0, + .signature_max_size = 10, + .timestamp_max_size = 10, + }, + { + .signature_size = 10, + .timestamp_size = 0, + .signature_max_size = 100, + .timestamp_max_size = 100, + }, + { + .signature_size = 10, + .timestamp_size = 0, + .signature_max_size = 1000, + .timestamp_max_size = 1000, + }, + { + .signature_size = 10, + .timestamp_size = 10, + .signature_max_size = 10, + .timestamp_max_size = 10, + }, + { + .signature_size = 10, + .timestamp_size = 10, + .signature_max_size = 100, + .timestamp_max_size = 100, + }, + { + .signature_size = 10, + .timestamp_size = 10, + .signature_max_size = 1000, + .timestamp_max_size = 1000, + }, + { + .signature_size = 10, + .timestamp_size = 10, + .signature_max_size = 10000, + .timestamp_max_size = 10000, + }, + { + .signature_size = 100, + .timestamp_size = 100, + .signature_max_size = 100, + .timestamp_max_size = 100, + }, + { + .signature_size = 1000, + .timestamp_size = 1000, + .signature_max_size = 1000, + .timestamp_max_size = 1000, + }, + { + .signature_size = 10000, + .timestamp_size = 10000, + .signature_max_size = 10000, + .timestamp_max_size = 10000, + }, +}; +INSTANTIATE_TEST_SUITE_P( + PadCoseSign1TaggedStructureWithParams, PadCoseSign1TaggedStructureTest, + ValuesIn(kCoseSign1TaggedInputs), + [](const testing::TestParamInfo<CoseSign1TaggedStructureInput>& info) { + return absl::StrCat("signature_", info.param.signature_size, + "_timestamp_", info.param.timestamp_size, + "_signature_max_", info.param.signature_max_size, + "_timestamp_max_", info.param.timestamp_max_size); + }); + +TEST(CborPaddingTest, + PadCoseSign1TaggedStructureSmallExpectedSizeReturnsError) { + CoseSign1TaggedStructure cose_sign1; + cose_sign1.unprotected_header.pad = ""; + cose_sign1.unprotected_header.pad2 = std::string(8, '\0'); + int initial_size = EncodeCoseSign1TaggedStructure(cose_sign1).size(); + EXPECT_THAT(PadCoseSign1TaggedStructure(cose_sign1, initial_size - 1), + StatusIs(absl::StatusCode::kInternal, + HasSubstr(absl::StrCat( + "Expected size ", initial_size - 1, + " is smaller than base size ", initial_size)))); +} + +// Returns the size of structure that PadCoseSign1TaggedStructure expects as +// initial size to pad from. +int GetInitialSizeForPadCoseSign1TaggedStructure() { + CoseSign1TaggedStructure cose_sign1; + cose_sign1.unprotected_header.pad = ""; + cose_sign1.unprotected_header.pad2 = std::string(8, '\0'); + return EncodeCoseSign1TaggedStructure(cose_sign1).size(); +} + +class PadOverallCoseSign1TaggedStructureTest : public TestWithParam<size_t> {}; + +TEST_P(PadOverallCoseSign1TaggedStructureTest, PadCoseSign1TaggedStructure) { + CoseSign1TaggedStructure cose_sign1; + size_t target_size = GetParam(); + + ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, target_size)); + + EXPECT_THAT(EncodeCoseSign1TaggedStructure(cose_sign1), SizeIs(target_size)); +} + +INSTANTIATE_TEST_SUITE_P( + PadOverallCoseSign1TaggedStructureTest, + PadOverallCoseSign1TaggedStructureTest, + Values(GetInitialSizeForPadCoseSign1TaggedStructure(), + GetInitialSizeForPadCoseSign1TaggedStructure() + 24, + GetInitialSizeForPadCoseSign1TaggedStructure() + 256, + GetInitialSizeForPadCoseSign1TaggedStructure() + 65536, + GetInitialSizeForPadCoseSign1TaggedStructure() + 1000000)); +} // namespace +} // namespace credentio
diff --git a/utils/crjson.cc b/utils/crjson.cc index 1b2c24c..22d87fa 100644 --- a/utils/crjson.cc +++ b/utils/crjson.cc
@@ -26,6 +26,7 @@ #include "absl/base/nullability.h" #include "absl/container/flat_hash_map.h" #include "absl/status/status.h" +#include "absl/status/status_macros.h" #include "absl/status/statusor.h" #include "absl/strings/match.h" #include "absl/strings/numbers.h" @@ -57,6 +58,7 @@ #include "proto/validation_status.pb.h" #include "tsp/timestamp_verifier.h" #include "utils/crjson_utils.h" +#include "validator/result.h" namespace credentio { @@ -90,24 +92,17 @@ }; absl::StatusOr<ParsedCbor<cbor::MapView>> ParseCborMap(absl::string_view cbor) { - auto parse_result = cbor::Parse(cbor); - if (!parse_result.ok()) { - return parse_result.status(); - } - auto cbor_map = (*parse_result)->AsMap(); - if (!cbor_map.ok()) { - return cbor_map.status(); - } - return ParsedCbor<cbor::MapView>{std::move(*parse_result), *cbor_map}; + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<cbor::ParseResult> parse_result, + cbor::Parse(cbor)); + ABSL_ASSIGN_OR_RETURN(cbor::MapView cbor_map, parse_result->AsMap()); + return ParsedCbor<cbor::MapView>{std::move(parse_result), cbor_map}; } absl::StatusOr<ParsedCbor<cbor::MapView>> GetCborMap( const jumbf::SuperBox& box) { - auto cbor_box = EnsureAndGetSingleBox<jumbf::CborBox>(box); - if (!cbor_box.ok()) { - return cbor_box.status(); - } - return ParseCborMap(cbor_box->payload); + ABSL_ASSIGN_OR_RETURN(const jumbf::CborBox& cbor_box, + EnsureAndGetSingleBox<jumbf::CborBox>(box)); + return ParseCborMap(cbor_box.payload); } enum class ManifestType { @@ -735,11 +730,9 @@ } } - auto manifest_store_box = - jumbf::ConsumeSuperBox(&raw_manifest_store, /*recursion_limit=*/-1); - if (!manifest_store_box.ok()) { - return manifest_store_box.status(); - } + ABSL_ASSIGN_OR_RETURN( + jumbf::SuperBox manifest_store_box, + jumbf::ConsumeSuperBox(&raw_manifest_store, /*recursion_limit=*/-1)); // Build the crJSON. Json crjson = Json::object(); @@ -751,8 +744,8 @@ // Add the manifests. crjson["manifests"] = Json::array(); bool is_active_manifest = true; - for (auto it = manifest_store_box->contents.rbegin(); - it != manifest_store_box->contents.rend(); ++it) { + for (auto it = manifest_store_box.contents.rbegin(); + it != manifest_store_box.contents.rend(); ++it) { const jumbf::ContentBox& content_box = *it; if (!content_box.Holds<jumbf::SuperBox>()) { crjson["_error"] = "Manifest store contains non-SuperBox content"; @@ -797,17 +790,17 @@ } absl::StatusOr<Json> ConvertToCrJson( - absl::string_view raw_manifest_store, - const ValidationResultProto& validation_result) { - return ConvertManifestStoreToCrJsonInternal(raw_manifest_store, - &validation_result, nullptr); + const ValidationResult& validation_result) { + return ConvertManifestStoreToCrJsonInternal( + validation_result.manifest_store_bytes(), &validation_result.proto(), + nullptr); } absl::StatusOr<Json> ConvertToCrJson( - absl::string_view raw_manifest_store, const IngredientValidationResultProto& ingredient_validation_result) { - return ConvertManifestStoreToCrJsonInternal(raw_manifest_store, nullptr, - &ingredient_validation_result); + return ConvertManifestStoreToCrJsonInternal( + ingredient_validation_result.manifest_store_bytes(), nullptr, + &ingredient_validation_result); } } // namespace credentio
diff --git a/utils/crjson.h b/utils/crjson.h index 7665cd0..31d8a78 100644 --- a/utils/crjson.h +++ b/utils/crjson.h
@@ -20,7 +20,7 @@ #include "absl/strings/string_view.h" #include "nlohmann/json.hpp" #include "proto/ingredient_validation_result.pb.h" -#include "proto/validation_result.pb.h" +#include "validator/result.h" namespace credentio { @@ -29,14 +29,13 @@ absl::string_view raw_manifest_store); // Convert a raw manifest store to crJSON and merge validation results. +// Requires that `validation_result` includes the manifest store bytes. absl::StatusOr<::nlohmann::json> ConvertToCrJson( - absl::string_view raw_manifest_store, - const ValidationResultProto& validation_result); + const ValidationResult& validation_result); // Convert a raw manifest store to crJSON and merge ingredient validation // results. absl::StatusOr<::nlohmann::json> ConvertToCrJson( - absl::string_view raw_manifest_store, const IngredientValidationResultProto& ingredient_validation_result); } // namespace credentio
diff --git a/utils/crjson_test.cc b/utils/crjson_test.cc index f21b17c..8265058 100644 --- a/utils/crjson_test.cc +++ b/utils/crjson_test.cc
@@ -41,8 +41,6 @@ namespace credentio { namespace { -using ::absl_testing::IsOk; - TEST(ConvertManifestStoreToCrJsonTest, ByteStringsAreBase64Encoded) { // 1. Construct a CBOR map containing a byte string. cppbor::Map map; @@ -75,7 +73,7 @@ // 7. Call ConvertManifestStoreToCrJsonWithoutValidationResults. absl::StatusOr<nlohmann::json> j = ConvertToCrJsonWithoutValidationResults(manifest_store_box); - ASSERT_THAT(j, IsOk()); + ABSL_ASSERT_OK(j); // 8. Verify that the output JSON contains the base64 encoded byte string. // The expected format is "b64'<base64_data>'". @@ -123,7 +121,7 @@ // 7. Call ConvertManifestStoreToCrJsonWithoutValidationResults. absl::StatusOr<nlohmann::json> j = ConvertToCrJsonWithoutValidationResults(manifest_store_box); - ASSERT_THAT(j, IsOk()); + ABSL_ASSERT_OK(j); // 8. Verify that the output JSON contains the unrecognized assertion with // empty object. @@ -156,7 +154,7 @@ // 5. Call ConvertManifestStoreToCrJsonWithoutValidationResults. absl::StatusOr<nlohmann::json> j = ConvertToCrJsonWithoutValidationResults(manifest_store_box); - ASSERT_THAT(j, IsOk()); + ABSL_ASSERT_OK(j); // 6. Verify that the output JSON contains the error message. ASSERT_TRUE(j->contains("manifests")); @@ -214,7 +212,7 @@ // 8. Call ConvertManifestStoreToCrJsonWithoutValidationResults. absl::StatusOr<nlohmann::json> j = ConvertToCrJsonWithoutValidationResults(manifest_store_box); - ASSERT_THAT(j, IsOk()); + ABSL_ASSERT_OK(j); // 9. Verify claim.v2 fields in output JSON. ASSERT_TRUE(j->contains("manifests")); @@ -317,7 +315,7 @@ // 9. Call ConvertManifestStoreToCrJsonWithoutValidationResults. absl::StatusOr<nlohmann::json> j = ConvertToCrJsonWithoutValidationResults(manifest_store_box); - ASSERT_THAT(j, IsOk()); + ABSL_ASSERT_OK(j); // 10. Verify metadata fields in output JSON. ASSERT_TRUE(j->contains("manifests"));
diff --git a/validator/BUILD b/validator/BUILD index fcdc5cc..59fcb77 100644 --- a/validator/BUILD +++ b/validator/BUILD
@@ -129,6 +129,16 @@ ) cc_library( + name = "manifest_consumer", + hdrs = ["manifest_consumer.h"], + deps = [ + "//proto:ingredient_validation_result_cc_proto", + "//proto:manifest_cc_proto", + "@abseil-cpp//absl/status", + ], +) + +cc_library( name = "validator_options", hdrs = ["validator_options.h"], deps = [ @@ -156,6 +166,59 @@ ) cc_library( + name = "ingredient_result_converter", + srcs = ["ingredient_result_converter.cc"], + hdrs = ["ingredient_result_converter.h"], + deps = [ + ":ingredient_result", + ":manifest_consumer", + ":result", + "//proto:assertion_cc_proto", + "//proto:generator_info_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:ingredient_validation_result_cc_proto", + "//proto:manifest_cc_proto", + "//proto:signature_info_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/hash", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@protobuf//:protobuf_lite", + ], +) + +cc_test( + name = "ingredient_result_converter_test", + srcs = ["ingredient_result_converter_test.cc"], + deps = [ + ":ingredient_result", + ":ingredient_result_converter", + ":manifest_consumer", + ":result", + "//proto:actions_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//proto:generator_info_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:ingredient_validation_result_cc_proto", + "//proto:manifest_cc_proto", + "//proto:signature_info_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:proto_test_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + ], +) + +cc_library( name = "graph", srcs = ["graph.cc"], hdrs = ["graph.h"], @@ -315,3 +378,9 @@ "@riegeli//riegeli/bytes:reader", ], ) + +cc_library( + name = "ingredient_result", + hdrs = ["ingredient_result.h"], + deps = ["//proto:ingredient_validation_result_cc_proto"], +)
diff --git a/validator/ingredient_result.h b/validator/ingredient_result.h new file mode 100644 index 0000000..835f9b1 --- /dev/null +++ b/validator/ingredient_result.h
@@ -0,0 +1,51 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_INGREDIENT_RESULT_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_INGREDIENT_RESULT_H_ + +#include <memory> +#include <utility> + +#include "proto/ingredient_validation_result.pb.h" + +namespace credentio { + +// Result of validating a C2PA ingredient. +// +// For now, this is just a wrapper for `IngredientValidationResultProto`. +class IngredientResult { + public: + explicit IngredientResult( + std::unique_ptr<IngredientValidationResultProto> proto) + : proto_(std::move(proto)) {} + + explicit IngredientResult(IngredientValidationResultProto&& proto) + : proto_(std::make_unique<IngredientValidationResultProto>( + std::move(proto))) {} + + const IngredientValidationResultProto& proto() const { return *proto_; } + + std::unique_ptr<IngredientValidationResultProto> release_proto() && { + return std::move(proto_); + } + + private: + std::unique_ptr<IngredientValidationResultProto> proto_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_INGREDIENT_RESULT_H_
diff --git a/validator/ingredient_result_converter.cc b/validator/ingredient_result_converter.cc new file mode 100644 index 0000000..83d042c --- /dev/null +++ b/validator/ingredient_result_converter.cc
@@ -0,0 +1,268 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "validator/ingredient_result_converter.h" + +#include <cstddef> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/container/flat_hash_map.h" +#include "absl/container/flat_hash_set.h" +#include "absl/hash/hash.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/strip.h" +#include "google/protobuf/repeated_ptr_field.h" +#include "proto/assertion.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/manifest.pb.h" +#include "proto/signature_info.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "validator/ingredient_result.h" +#include "validator/manifest_consumer.h" +#include "validator/result.h" + +namespace credentio { +namespace { + +bool IsLegacyManifestLabel(absl::string_view manifest_label) { + return !absl::StartsWith(manifest_label, "urn:c2pa"); +} + +std::string GetAssertionUri(absl::string_view manifest_label, + const Assertion& assertion) { + return absl::StrCat("self#jumbf=/c2pa/", manifest_label, "/c2pa.assertions/", + assertion.label()); +} + +std::optional<std::string> ExtractTargetManifestLabel( + const IngredientAssertionV3& ingredient_v3) { + if (ingredient_v3.has_active_manifest()) { + absl::string_view url = ingredient_v3.active_manifest().url(); + if (absl::ConsumePrefix(&url, "self#jumbf=/c2pa/")) { + return std::string(url); + } + } + return std::nullopt; +} + +struct ValidationStatusHash { + size_t operator()(const ValidationStatus& status) const { + return absl::HashOf(status.code(), status.url()); + } +}; +struct ValidationStatusEqual { + bool operator()(const ValidationStatus& lhs, + const ValidationStatus& rhs) const { + return lhs.code() == rhs.code() && lhs.url() == rhs.url(); + } +}; + +using ValidationStatusHashSet = + absl::flat_hash_set<ValidationStatus, ValidationStatusHash, + ValidationStatusEqual>; + +ValidationStatusHashSet GetIngredientAssertionValidationStatus( + const Assertion& assertion) { + ValidationStatusHashSet statuses; + if (assertion.has_ingredient_v3()) { + const ValidationStatusSet& assertion_results = + assertion.ingredient_v3().validation_results().active_manifest(); + statuses.insert(assertion_results.successes().begin(), + assertion_results.successes().end()); + statuses.insert(assertion_results.failures().begin(), + assertion_results.failures().end()); + statuses.insert(assertion_results.informationals().begin(), + assertion_results.informationals().end()); + } + return statuses; +} + +bool IsHardBindingSuccessCode(absl::string_view code) { + return code == "com.google.assertion.dataHash.match" || + code == "com.google.assertion.bmffHash.match" || + code == "com.google.assertion.boxesHash.match" || + code == "com.google.assertion.collectionHash.match" || + code == "assertion.dataHash.match" || + code == "assertion.bmffHash.match" || + code == "assertion.boxesHash.match" || + code == "assertion.collectionHash.match"; +} + +google::protobuf::RepeatedPtrField<ValidationStatus> ComputeStatusDeltas( + const google::protobuf::RepeatedPtrField<ValidationStatus>& current, + const ValidationStatusHashSet& assertion) { + google::protobuf::RepeatedPtrField<ValidationStatus> deltas; + for (const auto& status : current) { + if (!assertion.contains(status) || + IsHardBindingSuccessCode(status.code())) { + ValidationStatus* delta = deltas.Add(); + delta->set_code(status.code()); + delta->set_url(status.url()); + } + } + return deltas; +} + +ValidationStatusSet ComputeValidationDeltas( + const ValidationStatusSet& current_results, + const ValidationStatusHashSet& assertion_results) { + ValidationStatusSet status_set_deltas; + *status_set_deltas.mutable_successes() = + ComputeStatusDeltas(current_results.successes(), assertion_results); + *status_set_deltas.mutable_failures() = + ComputeStatusDeltas(current_results.failures(), assertion_results); + *status_set_deltas.mutable_informationals() = + ComputeStatusDeltas(current_results.informationals(), assertion_results); + return status_set_deltas; +} + +struct ReferencingIngredientAssertion { + const Assertion* assertion; + std::string assertion_uri; +}; + +} // namespace + +absl::StatusOr<std::unique_ptr<IngredientValidationResultProto>> +IngredientResultConverter::ConvertProto( + const ValidationResultProto& validation_result) const { + auto result = std::make_unique<IngredientValidationResultProto>(); + + if (!validation_result.media_type().empty()) { + result->set_media_type(validation_result.media_type()); + } + + if (validation_result.active_manifest().has_validation()) { + *result->mutable_ingredient_validation_results() + ->mutable_active_manifest() = + validation_result.active_manifest().validation(); + } + + if (!validation_result.spec_version().empty()) { + result->mutable_ingredient_validation_results()->set_spec_version( + validation_result.spec_version()); + } + if (!validation_result.trust_list_uri().empty()) { + result->mutable_ingredient_validation_results()->set_trust_list_uri( + validation_result.trust_list_uri()); + } + + // Populate logging_metadata. + auto* active_manifest_info = + result->mutable_logging_metadata()->mutable_active_manifest(); + if (validation_result.active_manifest().claim().has_claim_generator_info()) { + *active_manifest_info->mutable_claim_generator() = + validation_result.active_manifest().claim().claim_generator_info(); + } + if (validation_result.active_manifest().claim().has_signature_info()) { + *active_manifest_info->mutable_signature() = + validation_result.active_manifest().claim().signature_info(); + } + + // Populate legacy_ingredient_metadata. + if (IsLegacyManifestLabel(validation_result.active_manifest().label())) { + auto* legacy_metadata = result->mutable_legacy_ingredient_metadata(); + legacy_metadata->set_is_legacy_ingredient(true); + legacy_metadata->add_unsupported_manifests( + validation_result.active_manifest().label()); + } + + // Populate ingredient_deltas. + absl::flat_hash_map<std::string, std::vector<ReferencingIngredientAssertion>> + referencing_assertions; + + auto process_manifest = [&](const Manifest& m) { + for (const auto& a : m.assertions()) { + if (a.has_ingredient_v3()) { + auto target_label = ExtractTargetManifestLabel(a.ingredient_v3()); + if (target_label.has_value()) { + referencing_assertions[*target_label].push_back( + {.assertion = &a, + .assertion_uri = GetAssertionUri(m.label(), a)}); + } + } + } + }; + + process_manifest(validation_result.active_manifest()); + for (const auto& m : validation_result.ingredient_manifests()) { + process_manifest(m); + } + + for (const auto& m : validation_result.ingredient_manifests()) { + auto it = referencing_assertions.find(m.label()); + if (it != referencing_assertions.end()) { + for (const auto& ref : it->second) { + auto* delta = result->mutable_ingredient_validation_results() + ->add_ingredient_deltas(); + delta->set_ingredient_assertion_uri(ref.assertion_uri); + *delta->mutable_validation_deltas() = ComputeValidationDeltas( + m.validation(), + GetIngredientAssertionValidationStatus(*ref.assertion)); + if (IsLegacyManifestLabel(m.label())) { + auto* legacy_metadata = result->mutable_legacy_ingredient_metadata(); + legacy_metadata->add_unsupported_manifests(m.label()); + legacy_metadata->add_unsupported_ingredient_assertion_uris( + ref.assertion_uri); + } + } + } else { + if (IsLegacyManifestLabel(m.label())) { + auto* legacy_metadata = result->mutable_legacy_ingredient_metadata(); + legacy_metadata->add_unsupported_manifests(m.label()); + } + } + } + + // Let the consumer populate the fields the converter does not derive itself, + // e.g. digital_source_type. + if (consumer_ != nullptr) { + ABSL_RETURN_IF_ERROR(consumer_->OnManifestValidated( + validation_result.active_manifest(), /*is_active_manifest=*/true)); + for (const auto& m : validation_result.ingredient_manifests()) { + ABSL_RETURN_IF_ERROR( + consumer_->OnManifestValidated(m, /*is_active_manifest=*/false)); + } + ABSL_RETURN_IF_ERROR( + consumer_->OnIngredientValidationResultComplete(result.get())); + } + + return result; +} + +absl::StatusOr<std::unique_ptr<IngredientResult>> +IngredientResultConverter::Convert(ValidationResult validation_result) const { + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<IngredientValidationResultProto> proto, + ConvertProto(validation_result.proto())); + if (!validation_result.manifest_store_bytes().empty()) { + proto->set_manifest_store_bytes( + validation_result.release_manifest_store_bytes()); + } + return std::make_unique<IngredientResult>(std::move(proto)); +} + +} // namespace credentio
diff --git a/validator/ingredient_result_converter.h b/validator/ingredient_result_converter.h new file mode 100644 index 0000000..2bcff8f --- /dev/null +++ b/validator/ingredient_result_converter.h
@@ -0,0 +1,55 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_INGREDIENT_RESULT_CONVERTER_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_INGREDIENT_RESULT_CONVERTER_H_ + +#include <memory> +#include <utility> + +#include "absl/status/statusor.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/validation_result.pb.h" +#include "validator/ingredient_result.h" +#include "validator/manifest_consumer.h" +#include "validator/result.h" + +namespace credentio { + +// Converts a validation result into the ingredient validation result that +// describes it. +class IngredientResultConverter { + public: + IngredientResultConverter() = default; + + // The consumer is notified of every validated manifest and then of the + // completed result, which is how callers add information the converter does + // not derive itself, such as the digital source type. + explicit IngredientResultConverter(std::unique_ptr<ManifestConsumer> consumer) + : consumer_(std::move(consumer)) {} + + absl::StatusOr<std::unique_ptr<IngredientResult>> Convert( + ValidationResult validation_result) const; + + private: + absl::StatusOr<std::unique_ptr<IngredientValidationResultProto>> ConvertProto( + const ValidationResultProto& validation_result) const; + + std::unique_ptr<ManifestConsumer> consumer_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_INGREDIENT_RESULT_CONVERTER_H_
diff --git a/validator/ingredient_result_converter_test.cc b/validator/ingredient_result_converter_test.cc new file mode 100644 index 0000000..e779f92 --- /dev/null +++ b/validator/ingredient_result_converter_test.cc
@@ -0,0 +1,692 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "validator/ingredient_result_converter.h" + +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/actions_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/manifest.pb.h" +#include "proto/signature_info.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "testing/proto_test_utils.h" +#include "validator/ingredient_result.h" +#include "validator/manifest_consumer.h" +#include "validator/result.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::ElementsAre; + +TEST(IngredientResultTest, Accessors) { + auto proto = std::make_unique<IngredientValidationResultProto>(); + proto->set_media_type("image/png"); + IngredientResult result(std::move(proto)); + EXPECT_EQ(result.proto().media_type(), "image/png"); + + std::unique_ptr<IngredientValidationResultProto> released = + std::move(result).release_proto(); + ASSERT_NE(released, nullptr); + EXPECT_EQ(released->media_type(), "image/png"); +} + +TEST(IngredientResultTest, FromRvalueProto) { + IngredientValidationResultProto proto; + proto.set_media_type("image/png"); + IngredientResult result(std::move(proto)); + EXPECT_EQ(result.proto().media_type(), "image/png"); +} + +TEST(IngredientResultConverterTest, ConvertBasic) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + media_type: "image/jpeg" + active_manifest { + label: "urn:c2pa:active" + validation { successes { code: "success" } } + } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT((*result)->proto(), EqualsProto(R"pb( + media_type: "image/jpeg" + ingredient_validation_results { + active_manifest { successes { code: "success" } } + } + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, ConvertLoggingMetadata) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { + label: "urn:c2pa:test" + claim { + claim_generator_info { name: "test_generator" } + signature_info { issuer: "test_issuer" } + } + } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT((*result)->proto(), EqualsProto(R"pb( + logging_metadata { + active_manifest { + claim_generator { name: "test_generator" } + signature { issuer: "test_issuer" } + } + } + )pb")); +} + +TEST(IngredientResultConverterTest, ConvertLegacyActiveManifest) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { label: "legacy_label" } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT((*result)->proto(), EqualsProto(R"pb( + legacy_ingredient_metadata { + is_legacy_ingredient: true + unsupported_manifests: "legacy_label" + } + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, ConvertIngredientDeltas) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { + label: "urn:c2pa:active" + assertions { + label: "ingredient_assertion" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient1" } + validation_results { + active_manifest { successes { code: "ingredient_success" } } + } + } + } + } + ingredient_manifests { + label: "urn:c2pa:ingredient1" + validation { + successes { code: "ingredient_success" } + failures { code: "ingredient_failure" url: "test://failure" } + } + } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT( + (*result)->proto(), EqualsProto(R"pb( + ingredient_validation_results { + ingredient_deltas { + ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion" + validation_deltas { + failures { code: "ingredient_failure" url: "test://failure" } + } + } + } + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, ConvertIngredientDeltasHardBinding) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { + label: "urn:c2pa:active" + assertions { + label: "ingredient_assertion" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient1" } + validation_results { + active_manifest { + successes { code: "com.google.assertion.dataHash.match" } + } + } + } + } + } + ingredient_manifests { + label: "urn:c2pa:ingredient1" + validation { successes { code: "com.google.assertion.dataHash.match" } } + } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT( + (*result)->proto(), EqualsProto(R"pb( + ingredient_validation_results { + ingredient_deltas { + ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion" + validation_deltas { + successes { code: "com.google.assertion.dataHash.match" url: "" } + } + } + } + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, ConvertLegacyIngredientDeltas) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { + label: "urn:c2pa:active" + assertions { + label: "ingredient_assertion" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/legacy_ingredient" } + } + } + } + ingredient_manifests { label: "legacy_ingredient" } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT( + (*result)->proto(), EqualsProto(R"pb( + ingredient_validation_results { + ingredient_deltas { + ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion" + validation_deltas {} + } + } + legacy_ingredient_metadata { + unsupported_manifests: "legacy_ingredient" + unsupported_ingredient_assertion_uris: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion" + } + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, ConvertUnreferencedLegacyIngredient) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { label: "urn:c2pa:active" } + ingredient_manifests { label: "legacy_ingredient" } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT((*result)->proto(), EqualsProto(R"pb( + legacy_ingredient_metadata { + unsupported_manifests: "legacy_ingredient" + } + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, + ConvertActiveManifestFailuresAndInformationals) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { + label: "urn:c2pa:active" + validation { + failures { + code: "general.error" + url: "self#jumbf=/c2pa/urn:c2pa:active" + } + informationals { + code: "com.google.timeStamp.outOfRange" + url: "self#jumbf=/c2pa/urn:c2pa:active" + } + } + } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT((*result)->proto(), EqualsProto(R"pb( + ingredient_validation_results { + active_manifest { + failures { + code: "general.error" + url: "self#jumbf=/c2pa/urn:c2pa:active" + } + informationals { + code: "com.google.timeStamp.outOfRange" + url: "self#jumbf=/c2pa/urn:c2pa:active" + } + } + } + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, + ConvertMultipleAssertionsReferencingSameIngredient) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { + label: "urn:c2pa:active" + assertions { + label: "ingredient_assertion1" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient1" } + validation_results { + active_manifest { successes { code: "claimSignature.validated" } } + } + } + } + assertions { + label: "ingredient_assertion2" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient1" } + validation_results { + active_manifest { successes { code: "claimSignature.validated" } } + } + } + } + } + ingredient_manifests { + label: "urn:c2pa:ingredient1" + validation { + successes { code: "claimSignature.validated" } + failures { code: "ingredient_failure" url: "test://failure" } + } + } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT( + (*result)->proto(), EqualsProto(R"pb( + ingredient_validation_results { + ingredient_deltas { + ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion1" + validation_deltas { + failures { code: "ingredient_failure" url: "test://failure" } + } + } + ingredient_deltas { + ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion2" + validation_deltas { + failures { code: "ingredient_failure" url: "test://failure" } + } + } + } + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, ConvertChainedIngredientManifests) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { + label: "urn:c2pa:active" + assertions { + label: "assertion_for_ingredient1" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient1" } + validation_results { + active_manifest { successes { code: "claimSignature.validated" } } + } + } + } + } + ingredient_manifests { + label: "urn:c2pa:ingredient1" + assertions { + label: "assertion_for_ingredient2" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient2" } + validation_results { + active_manifest { successes { code: "claimSignature.validated" } } + } + } + } + validation { successes { code: "claimSignature.validated" } } + } + ingredient_manifests { + label: "urn:c2pa:ingredient2" + validation { + successes { code: "claimSignature.validated" } + failures { code: "chained_failure" url: "test://chained" } + } + } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT( + (*result)->proto(), EqualsProto(R"pb( + ingredient_validation_results { + ingredient_deltas { + ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/assertion_for_ingredient1" + validation_deltas {} + } + ingredient_deltas { + ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:ingredient1/c2pa.assertions/assertion_for_ingredient2" + validation_deltas { + failures { code: "chained_failure" url: "test://chained" } + } + } + } + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, ConvertIngredientDeltasStatusVarieties) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { + label: "urn:c2pa:active" + assertions { + label: "ingredient_assertion" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient1" } + validation_results { + active_manifest { successes { code: "known_success" } } + } + } + } + } + ingredient_manifests { + label: "urn:c2pa:ingredient1" + validation { + successes { code: "known_success" } + successes { code: "new_success" url: "test://success" } + informationals { code: "info_code" url: "test://info" } + } + } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT( + (*result)->proto(), EqualsProto(R"pb( + ingredient_validation_results { + ingredient_deltas { + ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion" + validation_deltas { + successes { code: "new_success" url: "test://success" } + informationals { code: "info_code" url: "test://info" } + } + } + } + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, ConvertUnreferencedStandardIngredient) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { label: "urn:c2pa:active" } + ingredient_manifests { label: "urn:c2pa:unreferenced" } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT((*result)->proto(), EqualsProto(R"pb( + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, + ConvertMissingReferencedIngredientManifest) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { + label: "urn:c2pa:active" + validation { + informationals { + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_v3" + } + } + assertions { + label: "ingredient_v3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:missing" } + } + } + } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT( + (*result)->proto(), EqualsProto(R"pb( + ingredient_validation_results { + active_manifest { + informationals { + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_v3" + } + } + } + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, ConvertMultipleLegacyIngredientManifests) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { + label: "urn:c2pa:active" + assertions { + label: "std_assertion" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:std" } + } + } + assertions { + label: "legacy_assertion" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/legacy_label" } + } + } + } + ingredient_manifests { label: "urn:c2pa:std" } + ingredient_manifests { label: "legacy_label" } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT( + (*result)->proto(), EqualsProto(R"pb( + ingredient_validation_results { + ingredient_deltas { + ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/std_assertion" + validation_deltas {} + } + ingredient_deltas { + ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/legacy_assertion" + validation_deltas {} + } + } + legacy_ingredient_metadata { + unsupported_manifests: "legacy_label" + unsupported_ingredient_assertion_uris: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/legacy_assertion" + } + logging_metadata { active_manifest {} } + )pb")); +} + +TEST(IngredientResultConverterTest, ConvertCopiesSpecVersion) { + ValidationResultProto validation_result; + validation_result.set_spec_version("2.2.0"); + validation_result.mutable_active_manifest() + ->mutable_validation() + ->add_successes() + ->set_code("code"); + + auto result = IngredientResultConverter().Convert( + ValidationResult(std::move(validation_result))); + ABSL_ASSERT_OK(result); + EXPECT_EQ((*result)->proto().ingredient_validation_results().spec_version(), + "2.2.0"); +} + +TEST(IngredientResultConverterTest, ConvertCopiesTrustListUri) { + ValidationResultProto validation_result; + validation_result.set_trust_list_uri("urn:test"); + validation_result.mutable_active_manifest() + ->mutable_validation() + ->add_successes() + ->set_code("code"); + + auto result = IngredientResultConverter().Convert( + ValidationResult(std::move(validation_result))); + ABSL_ASSERT_OK(result); + EXPECT_EQ((*result)->proto().ingredient_validation_results().trust_list_uri(), + "urn:test"); +} + +TEST(IngredientResultConverterTest, ConvertCopiesManifestStoreBytes) { + ValidationResult validation_result(ValidationResultProto(), + "manifest_store_bytes"); + + auto result = + IngredientResultConverter().Convert(std::move(validation_result)); + ABSL_ASSERT_OK(result); + EXPECT_EQ((*result)->proto().manifest_store_bytes(), "manifest_store_bytes"); +} + +// Records the manifests it is given and stamps a digital source type, standing +// in for enrichment that the converter deliberately does not implement. +class RecordingConsumer : public ManifestConsumer { + public: + absl::Status OnManifestValidated(const Manifest& manifest, + bool is_active_manifest) override { + labels_.push_back(manifest.label()); + active_manifest_labels_.push_back(is_active_manifest); + return absl::OkStatus(); + } + + absl::Status OnIngredientValidationResultComplete( + IngredientValidationResultProto* ingredient_result) override { + ingredient_result->set_digital_source_type("test://digital-source-type"); + return absl::OkStatus(); + } + + const std::vector<std::string>& labels() const { return labels_; } + const std::vector<bool>& active_manifest_labels() const { + return active_manifest_labels_; + } + + private: + std::vector<std::string> labels_; + std::vector<bool> active_manifest_labels_; +}; + +TEST(IngredientResultConverterTest, ConvertNotifiesTheConsumerOfEveryManifest) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { label: "urn:c2pa:active" } + ingredient_manifests { label: "urn:c2pa:first" } + ingredient_manifests { label: "urn:c2pa:second" } + )pb"); + auto consumer = std::make_unique<RecordingConsumer>(); + RecordingConsumer* consumer_ptr = consumer.get(); + // The converter owns the consumer, so it has to outlive the expectations. + IngredientResultConverter converter(std::move(consumer)); + + auto result = converter.Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_THAT( + consumer_ptr->labels(), + ElementsAre("urn:c2pa:active", "urn:c2pa:first", "urn:c2pa:second")); + EXPECT_THAT(consumer_ptr->active_manifest_labels(), + ElementsAre(true, false, false)); + EXPECT_EQ((*result)->proto().digital_source_type(), + "test://digital-source-type"); +} + +TEST(IngredientResultConverterTest, + ConvertWithoutAConsumerLeavesDigitalSourceTypeUnset) { + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { + label: "urn:c2pa:active" + assertions { + actions { + actions { + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + } + } + } + } + )pb"); + + auto result = + IngredientResultConverter().Convert(ValidationResult(std::move(input))); + ABSL_ASSERT_OK(result); + + EXPECT_FALSE((*result)->proto().has_digital_source_type()); +} + +TEST(IngredientResultConverterTest, ConvertPropagatesConsumerError) { + class FailingConsumer : public ManifestConsumer { + public: + absl::Status OnManifestValidated(const Manifest& manifest, + bool is_active_manifest) override { + return absl::InternalError("consumer failed"); + } + }; + ValidationResultProto input = ParseTextProtoOrDie(R"pb( + active_manifest { label: "urn:c2pa:active" } + )pb"); + + EXPECT_THAT(IngredientResultConverter(std::make_unique<FailingConsumer>()) + .Convert(ValidationResult(std::move(input))), + StatusIs(absl::StatusCode::kInternal)); +} + +} // namespace +} // namespace credentio
diff --git a/validator/manifest_consumer.h b/validator/manifest_consumer.h new file mode 100644 index 0000000..c8172c8 --- /dev/null +++ b/validator/manifest_consumer.h
@@ -0,0 +1,51 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_CONSUMER_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_CONSUMER_H_ + +#include "absl/status/status.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/manifest.pb.h" + +namespace credentio { + +// Interface for consumers that observe manifest validation events during +// manifest graph traversal. +// +// Consumers are the extension point for enrichment that the validator itself +// does not implement, e.g. deriving a digital source type from the actions +// recorded in the manifests. Validation results are identical with and without +// consumers; only the enriched fields differ. +class ManifestConsumer { + public: + virtual ~ManifestConsumer() = default; + + // Called when a manifest node has been successfully validated. + // `is_active_manifest` indicates if this is the root active manifest. + virtual absl::Status OnManifestValidated(const Manifest& manifest, + bool is_active_manifest) = 0; + + // Called after a complete ingredient validation result has been converted + // from a validation result. + virtual absl::Status OnIngredientValidationResultComplete( + IngredientValidationResultProto* ingredient_result) { + return absl::OkStatus(); + } +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_CONSUMER_H_
diff --git a/validator/result.h b/validator/result.h index 6af0a57..14dbb07 100644 --- a/validator/result.h +++ b/validator/result.h
@@ -37,6 +37,11 @@ : proto_(std::move(proto)), manifest_store_bytes_(std::move(manifest_store_bytes)) {} + explicit ValidationResult(ValidationResultProto&& proto, + std::string manifest_store_bytes = "") + : proto_(std::make_unique<ValidationResultProto>(std::move(proto))), + manifest_store_bytes_(std::move(manifest_store_bytes)) {} + const ValidationResultProto& proto() const { return *proto_; } ValidationResultProto& mutable_proto() { return *proto_; } @@ -44,10 +49,20 @@ return std::move(proto_); } + // Returns the raw manifest store bytes extracted from the asset. May be empty + // if the `ValidationResult` was constructed without them or after + // `release_manifest_store_bytes()` is called. absl::string_view manifest_store_bytes() const { return manifest_store_bytes_; } + // Releases ownership of the raw manifest store bytes. May be empty if the + // `ValidationResult` was constructed without them or if called more than + // once. + std::string release_manifest_store_bytes() { + return std::move(manifest_store_bytes_); + } + private: std::unique_ptr<ValidationResultProto> proto_; std::string manifest_store_bytes_;
diff --git a/validator/result_test.cc b/validator/result_test.cc index 37b49b1..2dd0c7c 100644 --- a/validator/result_test.cc +++ b/validator/result_test.cc
@@ -42,6 +42,22 @@ EXPECT_THAT(result.proto(), EqualsProto(expected)); } +TEST(ValidationResultTest, FromRvalueProto) { + ValidationResultProto proto; + proto.mutable_active_manifest()->set_label("test"); + ValidationResult result(std::move(proto), "manifest_store_bytes"); + ValidationResultProto expected; + expected.mutable_active_manifest()->set_label("test"); + EXPECT_THAT(result.proto(), EqualsProto(expected)); + EXPECT_EQ(result.manifest_store_bytes(), "manifest_store_bytes"); +} + +TEST(ValidationResultTest, ReleaseManifestStoreBytes) { + ValidationResult result(ValidationResultProto(), "manifest_store_bytes"); + EXPECT_EQ(result.manifest_store_bytes(), "manifest_store_bytes"); + EXPECT_EQ(result.release_manifest_store_bytes(), "manifest_store_bytes"); +} + TEST(GetAssertionTest, ReturnsAssertionFromActiveManifest) { PartialValidationResultProto result; result.mutable_active_manifest()->set_label("active_manifest");