Public release GitOrigin-RevId: 17b5cbec4df844eb5a7a2bf9dbb9121d03446f8a Change-Id: Ieba593e8a374a62e1a4abdea4aa283d03515aa1b
diff --git a/README.md b/README.md index 7612f3c..3588b6b 100644 --- a/README.md +++ b/README.md
@@ -1,4 +1,4 @@ -# Project Credentio +# Credentio C++ libraries to support validation and generation of C2PA Content Credentials (https://c2pa.org/). @@ -11,12 +11,15 @@ The library supports C2PA provenance extraction and validation for files with the following extensions: -| Category | Extensions | -| :-------------- | :-------------------------------------------------- | -| **Image** | `.avif`, `.dng`, `.gif`, `.heic`, `.heif`, `.jpeg`, | -: : `.jpg`, `.png`, `.tif`, `.tiff`, `.webp` : -| **Video/Audio** | `.m4a`, `.mov`, `.mp3`, `.mp4`, `.wav`, `.flac` | -| **Document** | `.pdf`, `.docx`, `.pptx`, `.xlsx` | +<!-- 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` + +<!-- mdformat on --> ## Prerequisites @@ -37,8 +40,8 @@ ## Quickstart -Project Credentio includes a fast standalone command-line tool for inspecting -and verifying C2PA metadata embedded in media assets. +Credentio includes a fast standalone command-line tool for inspecting and +verifying C2PA metadata embedded in media assets. #### Building the CLI @@ -58,9 +61,9 @@ --tsa_trust=/path/to/tsa_trust_anchors.pem ``` -> **Note on Trust Lists:** Project Credentio does not distribute or provide -> trust anchor lists. You can obtain the latest official C2PA trust lists from -> the C2PA organization on GitHub +> **Note on Trust Lists:** Credentio does not distribute or provide trust anchor +> lists. You can obtain the latest official C2PA trust lists from the C2PA +> 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)). ## Building & Testing @@ -79,13 +82,13 @@ ## Support & Releases -Project Credentio recommends that users **live-at-head** (updating to the latest -commit on the main branch as often as possible). We are actively developing this +Credentio recommends that users **live-at-head** (updating to the latest commit +on the main branch as often as possible). We are actively developing this project and may introduce breaking changes without notice. ## License & Disclaimer -Project Credentio is licensed under the terms of the Apache 2.0 License. See +Credentio is licensed under the terms of the Apache 2.0 License. See [LICENSE](LICENSE) for more information. ### Disclaimer @@ -96,4 +99,5 @@ ## Contact -For questions, feedback, or inquiries, please contact: `c2pa-core@google.com`. +For questions, feedback, or inquiries, please contact: +`credentio-team@google.com`.
diff --git a/bindings/merkle_validator.cc b/bindings/merkle_validator.cc index 9ec8efe..dc04cbc 100644 --- a/bindings/merkle_validator.cc +++ b/bindings/merkle_validator.cc
@@ -15,6 +15,7 @@ #include "bindings/merkle_validator.h" +#include <algorithm> #include <cstdint> #include <memory> #include <string> @@ -128,17 +129,12 @@ ABSL_ASSIGN_OR_RETURN(auto purpose, ReadPurpose(contents)); if (purpose == "merkle") { - // Leverage the offset found within the manifest to find first aux - // 4 bytes are the version and flag data - // 1 byte is the null terminator of the purpose string - const uint64_t metadata_header_size = purpose.size() + 4 + 1; - if (header.box_size < header.header_size || - header.box_size - header.header_size < metadata_header_size) { + uint64_t end_of_box = header.start + header.box_size; + if (contents.pos() >= end_of_box) { return absl::InvalidArgumentError( "box size too small for metadata headers"); } - int64_t remaining_data = - header.box_size - header.header_size - metadata_header_size; + uint64_t remaining_data = end_of_box - contents.pos(); ABSL_ASSIGN_OR_RETURN(auto aux_box, ReadBmffMerkleMap(contents, remaining_data)); @@ -179,18 +175,33 @@ return derived_data.status(); } - if (mdat_atom.box_size < mdat_atom.header_size) { + uint64_t mdat_offset_adjustment = 0; + for (const auto& exclusion : assertion_.exclusions()) { + if (exclusion.xpath() == "/mdat" && !exclusion.subsets().empty()) { + mdat_offset_adjustment = exclusion.subsets(0).offset(); + break; + } + } + + uint64_t header_or_adjustment_size = + std::max<uint64_t>(mdat_atom.header_size, mdat_offset_adjustment); + + if (mdat_atom.box_size < header_or_adjustment_size) { tracker.RecordFailure( FailureStatusCode::kAssertionBmffHashMalformed, {.url = assertion_uri_, - .explanation = "mdat atom box_size is smaller than header_size"}); + .explanation = "mdat atom box_size is smaller than header_size or " + "exclusion adjustment"}); return absl::InvalidArgumentError( - "mdat atom box_size is smaller than header_size"); + "mdat atom box_size is smaller than header_size or exclusion " + "adjustment"); } - // Calculate and validate the lengths of each leaf. - absl::StatusOr<std::vector<int64_t>> leaf_sizes = DeriveMerkleBlockSizes( - merkle, mdat_atom.box_size - mdat_atom.header_size); + // Calculate and validate the lengths of each leaf using net payload size. + uint64_t net_mdat_payload_size = + mdat_atom.box_size - header_or_adjustment_size; + absl::StatusOr<std::vector<int64_t>> leaf_sizes = + DeriveMerkleBlockSizes(merkle, net_mdat_payload_size); if (!leaf_sizes.ok()) { tracker.RecordFailure( FailureStatusCode::kAssertionBmffHashMalformed, @@ -200,7 +211,7 @@ // Based on the above calculations, we ensure the assertion is not malformed, // now compute and compare the leaf hashes. - int64_t offset = mdat_atom.start + mdat_atom.header_size; + uint64_t offset = mdat_atom.start + header_or_adjustment_size; for (int64_t i = 0; i < merkle.count(); ++i) { absl::string_view algo = merkle.has_alg() ? merkle.alg() : fallback_algo; absl::StatusOr<std::unique_ptr<HasherFactory>> factory = @@ -226,11 +237,16 @@ std::string computed_hash = std::move(leaf_hash.value()); + uint64_t running_row_index = i; if (!auxiliary_merkle_maps.empty()) { - int64_t running_row_index = i; 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()) { - if (running_row_index % 2 == 1) { + 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 { @@ -238,21 +254,19 @@ JoinHashes(**factory, computed_hash, hash)); } running_row_index >>= 1; + ++lvl; } } - int64_t hashes_index = i >> derived_data->delta_rows; + uint64_t hashes_index = i >> derived_data->delta_rows; if (merkle.hashes(hashes_index) != computed_hash) { - tracker.RecordFailure( - FailureStatusCode::kAssertionBmffHashMismatch, - {.url = assertion_uri_, - .explanation = absl::StrFormat( - "merkle map hash mismatch at index %d: Expected: %s, Actual: %s", - hashes_index, merkle.hashes(hashes_index), computed_hash)}); + tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, + {.url = assertion_uri_, + .explanation = absl::StrFormat( + "merkle map hash mismatch on leaf: %d", i)}); return absl::InternalError("merkle map hash mismatch"); } } - return absl::OkStatus(); }
diff --git a/bindings/merkle_validator_test.cc b/bindings/merkle_validator_test.cc index 993575b..009133e 100644 --- a/bindings/merkle_validator_test.cc +++ b/bindings/merkle_validator_test.cc
@@ -318,8 +318,7 @@ .expected_failures = {{.code = FailureStatusCode::kAssertionBmffHashMismatch, .url = "assertion_uri", - .explanation = "merkle map hash mismatch at index 0: " - "Expected: wrong_hash, Actual: ab"}}, + .explanation = "merkle map hash mismatch on leaf: 0"}}, }, MerkleValidatorTestCase{ .name = "FailsAuxiliaryDataBeforeLastMdat", @@ -372,8 +371,7 @@ .expected_failures = {{.code = FailureStatusCode::kAssertionBmffHashMismatch, .url = "assertion_uri", - .explanation = "merkle map hash mismatch at index 0: " - "Expected: wrong_hash, Actual: ab"}}, + .explanation = "merkle map hash mismatch on leaf: 0"}}, }, MerkleValidatorTestCase{ .name = "FailsUnsupportedHashAlgorithm", @@ -434,8 +432,7 @@ .expected_failures = {{.code = FailureStatusCode::kAssertionBmffHashMismatch, .url = "assertion_uri", - .explanation = "merkle map hash mismatch at index 0: " - "Expected: wrong_hash, Actual: abcdef"}}, + .explanation = "merkle map hash mismatch on leaf: 0"}}, }, MerkleValidatorTestCase{ .name = "FailsBoxSizeTooSmallForMetadataHeaders",
diff --git a/cbor/parse.cc b/cbor/parse.cc index bb3de64..1893c87 100644 --- a/cbor/parse.cc +++ b/cbor/parse.cc
@@ -20,7 +20,6 @@ #include <utility> #include "absl/status/status.h" -#include "absl/status/statusor.h" #include "absl/strings/str_cat.h" #include "absl/strings/string_view.h" #include "cbor/cbor.h" @@ -36,10 +35,6 @@ return absl::InvalidArgumentError( absl::StrCat("CBOR parsing failed: ", error)); } - if (new_position != end) { - return absl::InvalidArgumentError( - "Trailing bytes after the parsed CBOR item"); - } return std::make_unique<ParseResult>(std::move(item)); }
diff --git a/cbor/parse_test.cc b/cbor/parse_test.cc index 78aafd5..f620b15 100644 --- a/cbor/parse_test.cc +++ b/cbor/parse_test.cc
@@ -44,6 +44,20 @@ 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", @@ -382,11 +396,12 @@ HasSubstr("CBOR parsing failed"))); } -TEST(ParseTest, ErrorTrailingBytes) { +TEST(ParseTest, TrailingBytesIgnored) { auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json"); - EXPECT_THAT(cbor::Parse(cbor + "trailing bytes"), - StatusIs(absl::StatusCode::kInvalidArgument, - HasSubstr("Trailing bytes"))); + 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)); } void CheckMap(const cbor::MapView& map, absl::string_view str_key,
diff --git a/formats/riff/format.cc b/formats/riff/format.cc index f2c620f..2c41364 100644 --- a/formats/riff/format.cc +++ b/formats/riff/format.cc
@@ -28,7 +28,7 @@ return Format::Create(FormatOptions{ .assessor = std::make_unique<RiffAssessor>(), .extractor = std::make_unique<RiffExtractor>(), - .mime_types = {"image/webp", "audio/wav"}, + .mime_types = {"image/webp", "audio/wav", "video/x-msvideo"}, }); }
diff --git a/tools/BUILD b/tools/BUILD index 7322fe9..5b96d35 100644 --- a/tools/BUILD +++ b/tools/BUILD
@@ -23,6 +23,8 @@ deps = [ "//crypto:crypto_read_handler", "//crypto/default:default_crypto_read_handler", + "//formats:core_registry", + "//utils:crjson", "//utils:media_type", "//validator:asset_validator_impl", "//validator:result", @@ -35,7 +37,9 @@ "@abseil-cpp//absl/status:statusor", "@abseil-cpp//absl/strings", "@abseil-cpp//absl/strings:string_view", + "@nlohmann_json//:json", "@protobuf", "@riegeli//riegeli/bytes:cfile_reader", + "@riegeli//riegeli/bytes:reader", ], )
diff --git a/tools/asset_validator_main.cc b/tools/asset_validator_main.cc index 700cd54..8c2aaa8 100644 --- a/tools/asset_validator_main.cc +++ b/tools/asset_validator_main.cc
@@ -31,8 +31,12 @@ #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" #include "validator/result.h" @@ -48,6 +52,9 @@ ABSL_FLAG(std::string, tsa_trust, "", "Path to PEM file containing TSA trust anchors. If not provided, " "then the validator will default to skip TSA trust checks."); +ABSL_FLAG(std::string, output_format, "crjson", + "The format to output the validation results in. [txtpb, crjson] " + "(Default: crjson)"); namespace { @@ -62,13 +69,54 @@ return buffer.str(); } +absl::Status PrintResultInTxtpb(const credentio::ValidationResult& result) { + std::string text_format; + if (google::protobuf::TextFormat::PrintToString(result.proto(), + &text_format)) { + std::cout << "Validation Result:\n" << text_format << "\n"; + } 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"; + return absl::OkStatus(); +} + } // namespace int main(int argc, char* argv[]) { absl::SetProgramUsageMessage( "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>]"); + "[--claim_signer_trust=<pem_path>] [--tsa_trust=<pem_path>] " + "[--output_format=<txtpb, crjson>]"); absl::InitializeLog(); absl::ParseCommandLine(argc, argv); @@ -78,6 +126,12 @@ return 1; } + const std::string output_format = absl::GetFlag(FLAGS_output_format); + if (output_format != "txtpb" && output_format != "crjson") { + std::cerr << "Error: --output_format must be txtpb or crjson.\n"; + return 1; + } + const std::string claim_signer_trust_path = absl::GetFlag(FLAGS_claim_signer_trust); const std::string tsa_trust_path = absl::GetFlag(FLAGS_tsa_trust); @@ -152,12 +206,19 @@ } std::cout << "Validation successful!\n"; - std::string text_format; - if (google::protobuf::TextFormat::PrintToString((*result)->proto(), - &text_format)) { - std::cout << "Validation Result:\n" << text_format << "\n"; - } else { - std::cerr << "Failed to convert result proto to text format.\n"; + + if (output_format == "crjson") { + absl::Status status = PrintResultInCrJson(reader, media_type_opt, **result); + if (status.ok()) { + return 0; + } + std::cerr << "Failed to print result in crjson, reverting to txtpb: " + << status << "\n"; + } + + absl::Status status = PrintResultInTxtpb(**result); + if (!status.ok()) { + std::cerr << "Failed to print result in txtpb: " << status << "\n"; return 1; }
diff --git a/utils/BUILD b/utils/BUILD index 0330b49..a8d25b0 100644 --- a/utils/BUILD +++ b/utils/BUILD
@@ -288,3 +288,86 @@ "@googletest//:gtest_main", ], ) + +cc_library( + name = "crjson_utils", + srcs = ["crjson_utils.cc"], + hdrs = ["crjson_utils.h"], + deps = [ + "//cbor", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@libcppbor", + "@nlohmann_json//:json", + ], +) + +cc_library( + name = "crjson", + srcs = ["crjson.cc"], + hdrs = ["crjson.h"], + deps = [ + ":crjson_utils", + "//cbor", + "//cbor:parse", + "//constants:labels", + "//cose:sig_structure", + "//cose:simple_cms_parser", + "//crypto:algorithms", + "//jumbf:box", + "//jumbf:convert_json", + "//jumbf:parse", + "//proto:ingredient_assertion_cc_proto", + "//proto:ingredient_validation_result_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "//tsp:timestamp_verifier", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@boringssl//:crypto", + "@nlohmann_json//:json", + "@protobuf//:protobuf_lite", + ], +) + +cc_test( + name = "crjson_utils_test", + srcs = ["crjson_utils_test.cc"], + deps = [ + ":crjson_utils", + "//cbor", + "@googletest//:gtest_main", + "@libcppbor", + "@nlohmann_json//:json", + ], +) + +cc_test( + name = "crjson_test", + srcs = ["crjson_test.cc"], + deps = [ + ":crjson", + "//constants:labels", + "//jumbf:constants", + "//jumbf:test_utils", + "//proto:ingredient_assertion_cc_proto", + "//proto:ingredient_validation_result_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@libcppbor", + "@nlohmann_json//:json", + ], +)
diff --git a/utils/crjson.cc b/utils/crjson.cc new file mode 100644 index 0000000..1b2c24c --- /dev/null +++ b/utils/crjson.cc
@@ -0,0 +1,813 @@ +// 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/crjson.h" + +#include <cstddef> +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/container/flat_hash_map.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/numbers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/time/clock.h" +#include "absl/time/time.h" +#include "cbor/cbor.h" +#include "cbor/parse.h" +#include "constants/labels.h" +#include "cose/sig_structure.h" +#include "cose/simple_cms_parser.h" +#include "crypto/algorithms.h" +#include "google/protobuf/repeated_ptr_field.h" +#include "jumbf/box.h" +#include "jumbf/convert_json.h" // IWYU pragma: keep +#include "jumbf/parse.h" +#include "nlohmann/json.hpp" +#include "nlohmann/json_fwd.hpp" +#include "openssl/asn1.h" +#include "openssl/bn.h" +#include "openssl/mem.h" +#include "openssl/obj.h" +#include "openssl/x509.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "tsp/timestamp_verifier.h" +#include "utils/crjson_utils.h" + +namespace credentio { + +namespace { + +using Json = ::nlohmann::json; + +// ============================================================================= +// JUMBF/CBOR Helpers +// ============================================================================= + +template <typename T> +absl::StatusOr<const T&> EnsureAndGetSingleBox(const jumbf::SuperBox& box) { + if (box.contents.size() != 1) { + return absl::InvalidArgumentError( + absl::StrCat(box.description.label.value_or("unspecified"), + " box must contain exactly one box")); + } + if (!box.contents[0].Holds<T>()) { + return absl::InvalidArgumentError(absl::StrCat( + box.description.label.value_or("unspecified"), + " must contained box of index: ", box.contents[0].payload.index())); + } + return box.contents[0].Get<T>(); +} + +template <typename T> +struct ParsedCbor { + std::unique_ptr<cbor::ParseResult> parse_result; + T cbor_view; +}; + +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::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); +} + +enum class ManifestType { + kUnknown, + kStandard, + kUpdate, + kCompressed, + kTimestamp, +}; + +ManifestType GetManifestType(const jumbf::SuperBox& superbox) { + const auto& uuid = superbox.description.type_uuid; + if (uuid == kStandardManifestUuid) { + return ManifestType::kStandard; + } + if (uuid == kUpdateManifestUuid) { + return ManifestType::kUpdate; + } + if (uuid == kCompressedManifestUuid) { + return ManifestType::kCompressed; + } + if (uuid == kTimestampManifestUuid) { + return ManifestType::kTimestamp; + } + return ManifestType::kUnknown; +} + +// ============================================================================= +// Generic Array Converters +// ============================================================================= + +Json ConvertArrayOfStrings(uint32_t _, const cbor::ItemView& item) { + auto val = item.GetString(); + if (!val.ok()) { + return Json({{"_error", val.status().message()}}); + } + return Json(*val); +} + +// ============================================================================= +// Assertion Converters +// ============================================================================= + +Json ConvertThumbnailAssertion(const jumbf::SuperBox& box) { + Json crjson = Json::object(); + absl::string_view label = box.description.label.value_or(""); + + if (absl::StartsWith(label, "c2pa.thumbnail.claim")) { + crjson["thumbnailType"] = 0; + } else if (absl::StartsWith(label, "c2pa.thumbnail.ingredient")) { + crjson["thumbnailType"] = 1; + } + + size_t prefix_len = absl::StartsWith(label, "c2pa.thumbnail.claim") ? 20 : 25; + size_t last_dot = label.rfind('.'); + if (last_dot != absl::string_view::npos && last_dot >= prefix_len) { + absl::string_view mime_type = label.substr(last_dot + 1); + size_t suffix_start = mime_type.find("__"); + if (suffix_start != absl::string_view::npos) { + mime_type = mime_type.substr(0, suffix_start); + } + crjson["mimeType"] = std::string(mime_type); + } + + return crjson; +} + +Json ConvertMetadataAssertion(const jumbf::SuperBox& box) { + Json crjson = Json::object(); + + auto json_box = EnsureAndGetSingleBox<jumbf::JsonBox>(box); + if (json_box.ok()) { + Json j; + jumbf::to_json(j, *json_box); + if (j.contains("json")) { + return j["json"]; + } else { + crjson["_error"] = "Failed to convert JSON box to JSON"; + return crjson; + } + } else { + crjson["_error"] = "Metadata assertion is not a JSON box"; + return crjson; + } +} + +bool LabelMatches(absl::string_view label, absl::string_view base_label) { + if (!absl::StartsWith(label, base_label)) { + return false; + } + if (label.size() == base_label.size()) { + return true; + } + if (label.size() > base_label.size() + 2 && + label.substr(base_label.size(), 2) == "__") { + absl::string_view number = label.substr(base_label.size() + 2); + uint64_t val; + return absl::SimpleAtoi(number, &val); + } + return false; +} + +// This is an allowlist of standard assertions that are supported by C2PA v2.4. +bool IsAllowlistedStandardAssertion(absl::string_view label) { + return LabelMatches(label, kDataHashAssertionLabel) || + LabelMatches(label, kDataHashAssertionPartLabel) || + LabelMatches(label, kBmffBasedHashAssertionV2Label) || + LabelMatches(label, kBmffBasedHashAssertionV3Label) || + LabelMatches(label, kBmffBasedHashAssertionV2PartLabel) || + LabelMatches(label, kBmffBasedHashAssertionV3PartLabel) || + LabelMatches(label, kBoxesHashAssertionLabel) || + LabelMatches(label, kBoxesHashAssertionPartLabel) || + LabelMatches(label, kMultiAssetHashAssertionLabel) || + LabelMatches(label, kCollectionDataHashAssertionLabel) || + LabelMatches(label, kSoftBindingAssertionLabel) || + LabelMatches(label, kActionsAssertionV1Label) || + LabelMatches(label, kActionsAssertionV2Label) || + LabelMatches(label, kIngredientAssertionV1Label) || + LabelMatches(label, kIngredientAssertionV2Label) || + LabelMatches(label, kIngredientAssertionV3Label) || + LabelMatches(label, "font.info") || + LabelMatches(label, "c2pa.font.info") || + LabelMatches(label, "c2pa.cloud-data") || + LabelMatches(label, "c2pa.session-keys") || + LabelMatches(label, "c2pa.ai-disclosure") || + LabelMatches(label, "c2pa.certificate-status") || + LabelMatches(label, "c2pa.asset-ref") || + LabelMatches(label, "c2pa.depthmap") || + LabelMatches(label, "c2pa.repository-receipt") || + LabelMatches(label, "c2pa.embedded_data") || + LabelMatches(label, "c2pa.environmental-sustainability") || + LabelMatches(label, "c2pa.time-stamp"); +} + +Json ConvertAssertionStore(const jumbf::SuperBox& box) { + Json crjson = Json::object(); + + for (const jumbf::ContentBox& content_box : box.contents) { + if (!content_box.Holds<jumbf::SuperBox>()) { + crjson["_error"] = "Assertion store contains non-SuperBox content"; + continue; + } + + const jumbf::SuperBox& child_box = content_box.Get<jumbf::SuperBox>(); + absl::string_view label = child_box.description.label.value_or(""); + if (absl::StartsWith(label, "c2pa.thumbnail.claim") || + absl::StartsWith(label, "c2pa.thumbnail.ingredient")) { + crjson[label] = ConvertThumbnailAssertion(child_box); + } else if (LabelMatches(label, "c2pa.metadata")) { + crjson[label] = ConvertMetadataAssertion(child_box); + } else if (IsAllowlistedStandardAssertion(label)) { + if (auto cbor_box = EnsureAndGetSingleBox<jumbf::CborBox>(child_box); + cbor_box.ok()) { + Json j; + jumbf::to_json(j, *cbor_box); + if (j.contains("cbor")) { + crjson[label] = j["cbor"]; + } else { + crjson[label] = Json::object(); + crjson[label]["_error"] = "Failed to convert CBOR to JSON"; + } + } else if (auto json_box = + EnsureAndGetSingleBox<jumbf::JsonBox>(child_box); + json_box.ok()) { + Json j; + jumbf::to_json(j, *json_box); + if (j.contains("json")) { + crjson[label] = j["json"]; + } else { + crjson[label] = Json::object(); + crjson[label]["_error"] = "Failed to convert JSON box to JSON"; + } + } + } else { + crjson[label] = Json::object(); + } + } + + return crjson; +} + +// ============================================================================= +// Claim Helpers +// ============================================================================= + +// Converts a hashed URI map to a crJSON object. +// - CBOR CDDL: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedded +Json ConvertHashedUri(const cbor::MapView& uri_map) { + Json crjson = Json::object(); + RecordString(uri_map, "url", true, crjson); + RecordString(uri_map, "alg", false, crjson); + RecordByteString(uri_map, "hash", true, crjson); + return crjson; +} + +Json ConvertClaimGeneratorInfo(const cbor::MapView& map) { + Json crjson = Json::object(); + RecordString(map, "name", true, crjson); + RecordString(map, "version", false, crjson); + RecordMap(map, "icon", false, ConvertHashedUri, crjson); + RecordString(map, "operating_system", false, crjson); + return crjson; +} + +Json ConvertRating(const cbor::MapView& map) { + Json rating = Json::object(); + RecordString(map, "value", true, rating); + RecordString(map, "code", false, rating); + RecordString(map, "explanation", false, rating); + return rating; +} + +Json ConvertSourceMap(const cbor::MapView& map) { + Json crjson = Json::object(); + RecordString(map, "type", true, crjson); + RecordString(map, "details", false, crjson); + return crjson; +} + +Json ConvertLocalizations(const cbor::MapView& map) { + Json crjson = Json::object(); + // Dynamic fields... + return crjson; +} + +Json ConvertRegionOfInterest(const cbor::MapView& map) { + Json crjson = Json::object(); + // No fields in the spec. + return crjson; +} + +Json ConvertMetadata(const cbor::MapView& map) { + Json crjson = Json::object(); + RecordString(map, "dateTime", false, crjson); + RecordArrayOfMaps(map, "rating", false, ConvertRating, crjson); + RecordMap(map, "reference", false, ConvertHashedUri, crjson); + RecordMap(map, "dataSource", false, ConvertSourceMap, crjson); + RecordArrayOfMaps(map, "localizations", false, ConvertLocalizations, crjson); + RecordMap(map, "regionOfInterest", false, ConvertRegionOfInterest, crjson); + return crjson; +} + +// ============================================================================= +// Claim Converters +// ============================================================================= + +Json ConvertClaimV2(const jumbf::SuperBox& box) { + Json crjson = Json::object(); + + auto parsed_cbor = GetCborMap(box); + if (!parsed_cbor.ok()) { + crjson["_error"] = parsed_cbor.status().message(); + return crjson; + } + + RecordString(parsed_cbor->cbor_view, "instanceID", true, crjson); + RecordMap(parsed_cbor->cbor_view, "claim_generator_info", true, + ConvertClaimGeneratorInfo, crjson); + RecordString(parsed_cbor->cbor_view, "signature", true, crjson); + RecordArrayOfMaps(parsed_cbor->cbor_view, "created_assertions", true, + ConvertHashedUri, crjson); + RecordArrayOfMaps(parsed_cbor->cbor_view, "gathered_assertions", true, + ConvertHashedUri, crjson); + RecordString(parsed_cbor->cbor_view, "dc:title", false, crjson); + RecordArray(parsed_cbor->cbor_view, "redacted_assertions", true, + ConvertArrayOfStrings, crjson); + RecordString(parsed_cbor->cbor_view, "alg", false, crjson); + RecordString(parsed_cbor->cbor_view, "alg_soft", false, crjson); + RecordString(parsed_cbor->cbor_view, "specVersion", false, crjson); + RecordMap(parsed_cbor->cbor_view, "metadata", false, ConvertMetadata, crjson); + + return crjson; +} + +// ============================================================================= +// Signature Helpers +// ============================================================================= + +std::string SigningAlgorithmToString(SigningAlgorithm alg) { + switch (alg) { + case SigningAlgorithm::kEs256: + return "ES256"; + case SigningAlgorithm::kEs384: + return "ES384"; + case SigningAlgorithm::kEs512: + return "ES512"; + case SigningAlgorithm::kPs256: + return "PS256"; + case SigningAlgorithm::kPs384: + return "PS384"; + case SigningAlgorithm::kPs512: + return "PS512"; + case SigningAlgorithm::kEdDsa: + return "Ed25519"; + } + return "Unknown"; +} + +Json ConvertDNParsed(X509_NAME* name) { + Json dn_map = Json::object(); + if (!name) return dn_map; + int count = X509_NAME_entry_count(name); + for (int i = 0; i < count; ++i) { + X509_NAME_ENTRY* entry = X509_NAME_get_entry(name, i); + ASN1_OBJECT* obj = X509_NAME_ENTRY_get_object(entry); + ASN1_STRING* str = X509_NAME_ENTRY_get_data(entry); + + int nid = OBJ_obj2nid(obj); + const char* short_name = OBJ_nid2sn(nid); + + unsigned char* utf8_str = nullptr; + int len = ASN1_STRING_to_UTF8(&utf8_str, str); + if (len >= 0) { + if (short_name) { + dn_map[short_name] = + std::string(reinterpret_cast<char*>(utf8_str), len); + } else { + char oid_buf[128]; + OBJ_obj2txt(oid_buf, sizeof(oid_buf), obj, 1); + dn_map[oid_buf] = std::string(reinterpret_cast<char*>(utf8_str), len); + } + OPENSSL_free(utf8_str); + } + } + return dn_map; +} + +std::string ConvertASN1TimeToISO8601(const ASN1_TIME* t) { + if (!t) return ""; + + int64_t posix_time; + if (ASN1_TIME_to_posix(t, &posix_time) == 0) { + return ""; + } + return absl::FormatTime(absl::RFC3339_full, absl::FromUnixSeconds(posix_time), + absl::UTCTimeZone()); +} + +Json ConvertCertificateInfo(absl::string_view certificate_der) { + Json cert_info = Json::object(); + + const uint8_t* p = reinterpret_cast<const uint8_t*>(certificate_der.data()); + X509* cert = d2i_X509(nullptr, &p, certificate_der.size()); + if (!cert) { + cert_info["_error"] = "Failed to parse certificate"; + return cert_info; + } + + ASN1_INTEGER* serial = X509_get_serialNumber(cert); + BIGNUM* bn = ASN1_INTEGER_to_BN(serial, nullptr); + if (bn) { + char* hex = BN_bn2hex(bn); + if (hex) { + cert_info["serialNumber"] = std::string(hex); + OPENSSL_free(hex); + } + BN_free(bn); + } + + cert_info["subject"] = ConvertDNParsed(X509_get_subject_name(cert)); + cert_info["issuer"] = ConvertDNParsed(X509_get_issuer_name(cert)); + + Json validity = Json::object(); + validity["notBefore"] = ConvertASN1TimeToISO8601(X509_get0_notBefore(cert)); + validity["notAfter"] = ConvertASN1TimeToISO8601(X509_get0_notAfter(cert)); + cert_info["validity"] = validity; + + X509_free(cert); + return cert_info; +} + +// ============================================================================= +// Signature Converter +// ============================================================================= + +Json ConvertClaimSignature(const jumbf::SuperBox& box) { + Json crjson = Json::object(); + + auto cbor_box = EnsureAndGetSingleBox<jumbf::CborBox>(box); + if (!cbor_box.ok()) { + return crjson; + } + + auto cose_sign1 = DecodeCoseSign1TaggedStructure(cbor_box->payload); + if (!cose_sign1.ok()) { + return crjson; + } + + auto protected_header = DecodeProtectedHeader(cose_sign1->protected_header); + if (protected_header.ok()) { + crjson["algorithm"] = SigningAlgorithmToString(protected_header->alg); + } + + std::vector<std::string> cert_chain; + if (!cose_sign1->unprotected_header.certificate_chain.empty()) { + cert_chain = cose_sign1->unprotected_header.certificate_chain; + } else if (protected_header.ok()) { + cert_chain = protected_header->certificate_chain; + } + + if (!cert_chain.empty()) { + crjson["certificateInfo"] = ConvertCertificateInfo(cert_chain[0]); + } + + // Handle Timestamp + auto timestamp_container = cose_sign1->unprotected_header.sig_tst2; + if (!timestamp_container.has_value()) { + timestamp_container = cose_sign1->unprotected_header.sig_tst; + } + + if (timestamp_container.has_value() && + !timestamp_container->tst_tokens.empty()) { + if (timestamp_container->tst_tokens.size() == 1) { + absl::string_view timestamp_token = + timestamp_container->tst_tokens[0].val; + TimestampVerifier verifier(nullptr); // Skip trust checks + auto parsed_token = SimpleParsedTimestampToken::Create(timestamp_token); + if (parsed_token.ok()) { + auto verified_timestamp = verifier.VerifyTimestampToken(**parsed_token); + if (verified_timestamp.ok()) { + Json ts_info = Json::object(); + ts_info["timestamp"] = absl::FormatTime( + absl::RFC3339_full, verified_timestamp->asserted_time(), + absl::UTCTimeZone()); + ts_info["certificateInfo"] = + ConvertCertificateInfo(verified_timestamp->tsa_certificate()); + crjson["timeStampInfo"] = ts_info; + } + } + } + } + + return crjson; +} + +Json ConvertValidationStatusSetToJson( + const ValidationStatusSet& validation, + std::optional<absl::string_view> spec_version = std::nullopt, + std::optional<absl::string_view> trust_list_uri = std::nullopt) { + Json v = Json::object(); + v["success"] = Json::array(); + v["informational"] = Json::array(); + v["failure"] = Json::array(); + if (spec_version.has_value()) { + v["specVersion"] = *spec_version; + } + if (trust_list_uri.has_value()) { + v["trustListURI"] = *trust_list_uri; + } + v["validationTime"] = + absl::FormatTime(absl::RFC3339_full, absl::Now(), absl::UTCTimeZone()); + + for (const auto& success : validation.successes()) { + Json s = Json::object(); + s["code"] = success.code(); + if (!success.url().empty()) { + s["url"] = success.url(); + } + if (!success.explanation().empty()) { + s["explanation"] = success.explanation(); + } + v["success"].push_back(s); + } + for (const auto& informational : validation.informationals()) { + Json i = Json::object(); + i["code"] = informational.code(); + if (!informational.url().empty()) { + i["url"] = informational.url(); + } + if (!informational.explanation().empty()) { + i["explanation"] = informational.explanation(); + } + v["informational"].push_back(i); + } + for (const auto& failure : validation.failures()) { + Json f = Json::object(); + f["code"] = failure.code(); + if (!failure.url().empty()) { + f["url"] = failure.url(); + } + if (!failure.explanation().empty()) { + f["explanation"] = failure.explanation(); + } + v["failure"].push_back(f); + } + return v; +} + +// ============================================================================= +// Top-Level Converters +// ============================================================================= + +Json ConvertManifestToCrJson( + const jumbf::SuperBox& manifest_box, + const ValidationStatusSet* validation_status = nullptr, + const google::protobuf::RepeatedPtrField<IngredientDeltaValidationResult>* + ingredient_deltas = nullptr, + std::optional<absl::string_view> spec_version = std::nullopt, + std::optional<absl::string_view> trust_list_uri = std::nullopt) { + Json crjson = Json::object(); + crjson["label"] = manifest_box.description.label.value_or(""); + switch (GetManifestType(manifest_box)) { + case ManifestType::kStandard: + crjson["isUpdateManifest"] = false; + crjson["isCompressedManifest"] = false; + break; + case ManifestType::kUpdate: + crjson["isUpdateManifest"] = true; + crjson["isCompressedManifest"] = false; + break; + case ManifestType::kCompressed: + crjson["isCompressedManifest"] = true; + crjson["_error"] = "Compressed manifests are not supported"; + // Compressed manifest are not supported, so mark an error and return. + return crjson; + default: + crjson["_error"] = "Unknown manifest type"; + // Unknown manifest type, so mark an error and return. + return crjson; + } + crjson["assertions"] = Json::object(); + crjson["signature"] = Json::object(); + + if (validation_status != nullptr) { + crjson["validationResults"] = ConvertValidationStatusSetToJson( + *validation_status, spec_version, trust_list_uri); + } else { + crjson["validationResults"] = Json::object(); + crjson["validationResults"]["success"] = Json::array(); + crjson["validationResults"]["informational"] = Json::array(); + crjson["validationResults"]["failure"] = Json::array(); + if (spec_version.has_value()) { + crjson["validationResults"]["specVersion"] = *spec_version; + } + if (trust_list_uri.has_value()) { + crjson["validationResults"]["trustListURI"] = *trust_list_uri; + } + crjson["validationResults"]["validationTime"] = + absl::FormatTime(absl::RFC3339_full, absl::Now(), absl::UTCTimeZone()); + } + + if (ingredient_deltas != nullptr && !ingredient_deltas->empty()) { + crjson["ingredientDeltas"] = Json::array(); + for (const auto& delta : *ingredient_deltas) { + Json d = Json::object(); + d["ingredientAssertionURI"] = delta.ingredient_assertion_uri(); + // Ingredient deltas should not have spec version or trust list URI. + d["validationDeltas"] = + ConvertValidationStatusSetToJson(delta.validation_deltas()); + crjson["ingredientDeltas"].push_back(d); + } + } + + for (const jumbf::ContentBox& content_box : manifest_box.contents) { + if (!content_box.Holds<jumbf::SuperBox>()) { + crjson["_error"] = "Manifest contains non-SuperBox content"; + continue; + } + + const jumbf::SuperBox& child_box = content_box.Get<jumbf::SuperBox>(); + if (child_box.description.label.value_or("") == kAssertionStoreLabel) { + crjson["assertions"] = ConvertAssertionStore(child_box); + } else if (child_box.description.label.value_or("") == kClaimV2Label) { + crjson["claim.v2"] = ConvertClaimV2(child_box); + } else if (child_box.description.label.value_or("") == + kClaimSignatureLabel) { + crjson["signature"] = ConvertClaimSignature(child_box); + } else if (child_box.description.label.value_or("") == "c2pa.databoxes") { + // Deprecated, ignore it. + } else { + // Ignore unhandled top-level boxes in manifest. + } + } + + return crjson; +} + +absl::flat_hash_map<std::string, ValidationStatusSet> IndexResults( + const ValidationResultProto* absl_nullable validation_result) { + absl::flat_hash_map<std::string, ValidationStatusSet> manifests; + if (validation_result != nullptr) { + if (validation_result->has_active_manifest()) { + manifests[validation_result->active_manifest().label()] = + validation_result->active_manifest().validation(); + } + for (const auto& ingredient : validation_result->ingredient_manifests()) { + manifests[ingredient.label()] = ingredient.validation(); + } + } + return manifests; +} + +absl::StatusOr<Json> ConvertManifestStoreToCrJsonInternal( + absl::string_view raw_manifest_store, + const ValidationResultProto* absl_nullable validation_result, + const IngredientValidationResultProto* absl_nullable + ingredient_validation_result) { + auto indexed_validation_results = IndexResults(validation_result); + + // Get the spec version and trust list URI from the validation results. + std::optional<absl::string_view> spec_version; + std::optional<absl::string_view> trust_list_uri; + if (validation_result != nullptr) { + if (validation_result->has_spec_version()) { + spec_version = validation_result->spec_version(); + } + if (validation_result->has_trust_list_uri()) { + trust_list_uri = validation_result->trust_list_uri(); + } + } else if (ingredient_validation_result != nullptr) { + if (ingredient_validation_result->has_ingredient_validation_results()) { + if (ingredient_validation_result->ingredient_validation_results() + .has_spec_version()) { + spec_version = + ingredient_validation_result->ingredient_validation_results() + .spec_version(); + } + if (ingredient_validation_result->ingredient_validation_results() + .has_trust_list_uri()) { + trust_list_uri = + ingredient_validation_result->ingredient_validation_results() + .trust_list_uri(); + } + } + } + + auto manifest_store_box = + jumbf::ConsumeSuperBox(&raw_manifest_store, /*recursion_limit=*/-1); + if (!manifest_store_box.ok()) { + return manifest_store_box.status(); + } + + // Build the crJSON. + Json crjson = Json::object(); + + // Add the context. + crjson["@context"] = + Json::array({"https://c2pa.org/crjson/crJSON.schema.json"}); + + // 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) { + const jumbf::ContentBox& content_box = *it; + if (!content_box.Holds<jumbf::SuperBox>()) { + crjson["_error"] = "Manifest store contains non-SuperBox content"; + continue; + } + + auto manifest_box = content_box.Get<jumbf::SuperBox>(); + auto results = indexed_validation_results.find( + manifest_box.description.label.value_or("")); + + const ValidationStatusSet* validation_status = nullptr; + if (results != indexed_validation_results.end()) { + validation_status = &results->second; + } else if (is_active_manifest && ingredient_validation_result != nullptr && + ingredient_validation_result + ->has_ingredient_validation_results()) { + validation_status = + &ingredient_validation_result->ingredient_validation_results() + .active_manifest(); + } + + crjson["manifests"].push_back(ConvertManifestToCrJson( + manifest_box, validation_status, + /*ingredient_deltas=*/nullptr, spec_version, trust_list_uri)); + + is_active_manifest = false; // Only first one is active + } + + // JSON generator info. + crjson["jsonGenerator"]["name"] = "Google C2PA Toolkit"; + crjson["jsonGenerator"]["version"] = "0.0.1"; + + return crjson; +} + +} // namespace + +absl::StatusOr<Json> ConvertToCrJsonWithoutValidationResults( + absl::string_view raw_manifest_store) { + return ConvertManifestStoreToCrJsonInternal(raw_manifest_store, nullptr, + nullptr); +} + +absl::StatusOr<Json> ConvertToCrJson( + absl::string_view raw_manifest_store, + const ValidationResultProto& validation_result) { + return ConvertManifestStoreToCrJsonInternal(raw_manifest_store, + &validation_result, 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); +} + +} // namespace credentio
diff --git a/utils/crjson.h b/utils/crjson.h new file mode 100644 index 0000000..7665cd0 --- /dev/null +++ b/utils/crjson.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_UTILS_CRJSON_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_CRJSON_H_ + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "nlohmann/json.hpp" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/validation_result.pb.h" + +namespace credentio { + +// Convert a raw manifest store to crJSON without validation results. +absl::StatusOr<::nlohmann::json> ConvertToCrJsonWithoutValidationResults( + absl::string_view raw_manifest_store); + +// Convert a raw manifest store to crJSON and merge validation results. +absl::StatusOr<::nlohmann::json> ConvertToCrJson( + absl::string_view raw_manifest_store, + const ValidationResultProto& 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 + +#endif // THIRD_PARTY_CREDENTIO_UTILS_CRJSON_H_
diff --git a/utils/crjson_test.cc b/utils/crjson_test.cc new file mode 100644 index 0000000..f21b17c --- /dev/null +++ b/utils/crjson_test.cc
@@ -0,0 +1,357 @@ +// 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/crjson.h" + +#include <cstdint> +#include <string> +#include <utility> +#include <vector> + +#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/string_view.h" +#include "constants/labels.h" +#include "cppbor/cppbor.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/constants.h" +#include "jumbf/test_utils.h" +#include "nlohmann/json.hpp" +#include "proto/ingredient_assertion.pb.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; + +TEST(ConvertManifestStoreToCrJsonTest, ByteStringsAreBase64Encoded) { + // 1. Construct a CBOR map containing a byte string. + cppbor::Map map; + map.add("hash_value", cppbor::Bstr("raw_bytes_here")); + std::vector<uint8_t> encoded_cbor = map.encode(); + absl::string_view cbor_view( + reinterpret_cast<const char*>(encoded_cbor.data()), encoded_cbor.size()); + + // 2. Wrap it in a CborBox. + std::string cbor_box = jumbf::EncodeCborBox(cbor_view); + + // 3. Wrap that in a SuperBox (representing an assertion). + // Use a standard allowlisted assertion label, e.g., kDataHashAssertionLabel. + std::string assertion_box = jumbf::EncodeSuperBox( + jumbf::kCborBoxTypeUuid, kDataHashAssertionLabel, {cbor_box}, + /*requestable=*/true); + + // 4. Wrap that in an assertion store SuperBox. + std::string assertion_store_box = jumbf::EncodeSuperBox( + kAssertionStoreUuid, kAssertionStoreLabel, {assertion_box}); + + // 5. Wrap that in a manifest SuperBox. + std::string manifest_box = jumbf::EncodeSuperBox( + kStandardManifestUuid, "test_manifest", {assertion_store_box}); + + // 6. Wrap that in a manifest store SuperBox. + std::string manifest_store_box = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest_box}); + + // 7. Call ConvertManifestStoreToCrJsonWithoutValidationResults. + absl::StatusOr<nlohmann::json> j = + ConvertToCrJsonWithoutValidationResults(manifest_store_box); + ASSERT_THAT(j, IsOk()); + + // 8. Verify that the output JSON contains the base64 encoded byte string. + // The expected format is "b64'<base64_data>'". + std::string expected_b64 = + absl::StrCat("b64'", absl::Base64Escape("raw_bytes_here"), "'"); + ASSERT_TRUE(j->contains("manifests")); + ASSERT_GT((*j)["manifests"].size(), 0); + const auto& manifest = (*j)["manifests"][0]; + ASSERT_TRUE(manifest.contains("assertions")); + const auto& assertions = manifest["assertions"]; + ASSERT_TRUE(assertions.contains(kDataHashAssertionLabel)); + const auto& assertion_json = assertions[kDataHashAssertionLabel]; + + EXPECT_EQ(assertion_json["hash_value"], expected_b64); +} + +TEST(ConvertManifestStoreToCrJsonTest, UnrecognizedAssertionIsIncluded) { + // 1. Construct a CBOR map for a dummy assertion. + cppbor::Map map; + map.add("foo", "bar"); + std::vector<uint8_t> encoded_cbor = map.encode(); + absl::string_view cbor_view( + reinterpret_cast<const char*>(encoded_cbor.data()), encoded_cbor.size()); + + // 2. Wrap it in a CborBox. + std::string cbor_box = jumbf::EncodeCborBox(cbor_view); + + // 3. Wrap that in a SuperBox with an unrecognized label. + std::string assertion_box = jumbf::EncodeSuperBox( + jumbf::kCborBoxTypeUuid, "custom.unrecognized.assertion", {cbor_box}, + /*requestable=*/true); + + // 4. Wrap that in an assertion store SuperBox. + std::string assertion_store_box = jumbf::EncodeSuperBox( + kAssertionStoreUuid, kAssertionStoreLabel, {assertion_box}); + + // 5. Wrap that in a manifest SuperBox. + std::string manifest_box = jumbf::EncodeSuperBox( + kStandardManifestUuid, "test_manifest", {assertion_store_box}); + + // 6. Wrap that in a manifest store SuperBox. + std::string manifest_store_box = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest_box}); + + // 7. Call ConvertManifestStoreToCrJsonWithoutValidationResults. + absl::StatusOr<nlohmann::json> j = + ConvertToCrJsonWithoutValidationResults(manifest_store_box); + ASSERT_THAT(j, IsOk()); + + // 8. Verify that the output JSON contains the unrecognized assertion with + // empty object. + ASSERT_TRUE(j->contains("manifests")); + ASSERT_GT((*j)["manifests"].size(), 0); + const auto& manifest = (*j)["manifests"][0]; + ASSERT_TRUE(manifest.contains("assertions")); + const auto& assertions = manifest["assertions"]; + ASSERT_TRUE(assertions.contains("custom.unrecognized.assertion")); + EXPECT_TRUE(assertions["custom.unrecognized.assertion"].is_object()); + EXPECT_TRUE(assertions["custom.unrecognized.assertion"].empty()); +} + +TEST(ConvertManifestStoreToCrJsonTest, HandlesNonSuperBoxInAssertionStore) { + // 1. Construct a CBOR box directly. This is not a SuperBox. + std::string cbor_box = jumbf::EncodeCborBox("test"); + + // 2. Wrap that in an assertion store SuperBox. This is the error condition. + std::string assertion_store_box = jumbf::EncodeSuperBox( + kAssertionStoreUuid, kAssertionStoreLabel, {cbor_box}); + + // 3. Wrap that in a manifest SuperBox. + std::string manifest_box = jumbf::EncodeSuperBox( + kStandardManifestUuid, "test_manifest", {assertion_store_box}); + + // 4. Wrap that in a manifest store SuperBox. + std::string manifest_store_box = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest_box}); + + // 5. Call ConvertManifestStoreToCrJsonWithoutValidationResults. + absl::StatusOr<nlohmann::json> j = + ConvertToCrJsonWithoutValidationResults(manifest_store_box); + ASSERT_THAT(j, IsOk()); + + // 6. Verify that the output JSON contains the error message. + ASSERT_TRUE(j->contains("manifests")); + ASSERT_GT((*j)["manifests"].size(), 0); + const auto& manifest = (*j)["manifests"][0]; + ASSERT_TRUE(manifest.contains("assertions")); + const auto& assertions = manifest["assertions"]; + // The error should be at the top level of the "assertions" object because + // the structure is invalid before even getting to a specific assertion label. + ASSERT_TRUE(assertions.contains("_error")); + EXPECT_EQ(assertions["_error"], + "Assertion store contains non-SuperBox content"); +} + +TEST(ConvertManifestStoreToCrJsonTest, ClaimV2IsConverted) { + // 1. Construct claim_generator_info map. + cppbor::Map generator_info; + generator_info.add("name", "Test Generator"); + generator_info.add("version", "1.0"); + + // 2. Construct created_assertions array. + cppbor::Array created_assertions; + cppbor::Map assertion_link; + assertion_link.add("url", "self#jumbf=/c2pa/assertions/custom.assertion"); + created_assertions.add(std::move(assertion_link)); + + // 3. Construct the claim map. + cppbor::Map claim_map; + claim_map.add("instanceID", "test_instance_id"); + claim_map.add("claim_generator_info", std::move(generator_info)); + claim_map.add("signature", "self#jumbf=/c2pa/signature"); + claim_map.add("created_assertions", std::move(created_assertions)); + claim_map.add("dc:title", "Test Title"); + + std::vector<uint8_t> encoded_claim = claim_map.encode(); + absl::string_view claim_view( + reinterpret_cast<const char*>(encoded_claim.data()), + encoded_claim.size()); + + // 4. Wrap in CborBox. + std::string cbor_box = jumbf::EncodeCborBox(claim_view); + + // 5. Wrap in Claim V2 SuperBox. + std::string claim_box = jumbf::EncodeSuperBox(jumbf::kCborBoxTypeUuid, + "c2pa.claim.v2", {cbor_box}); + + // 6. Wrap in manifest SuperBox. + std::string manifest_box = jumbf::EncodeSuperBox( + kStandardManifestUuid, "test_manifest", {claim_box}); + + // 7. Wrap in manifest store SuperBox. + std::string manifest_store_box = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest_box}); + + // 8. Call ConvertManifestStoreToCrJsonWithoutValidationResults. + absl::StatusOr<nlohmann::json> j = + ConvertToCrJsonWithoutValidationResults(manifest_store_box); + ASSERT_THAT(j, IsOk()); + + // 9. Verify claim.v2 fields in output JSON. + ASSERT_TRUE(j->contains("manifests")); + ASSERT_GT((*j)["manifests"].size(), 0); + const auto& manifest = (*j)["manifests"][0]; + ASSERT_TRUE(manifest.contains("claim.v2")); + const auto& claim_json = manifest["claim.v2"]; + + EXPECT_EQ(claim_json["instanceID"], "test_instance_id"); + EXPECT_EQ(claim_json["dc:title"], "Test Title"); + EXPECT_EQ(claim_json["signature"], "self#jumbf=/c2pa/signature"); + + ASSERT_TRUE(claim_json.contains("claim_generator_info")); + EXPECT_EQ(claim_json["claim_generator_info"]["name"], "Test Generator"); + EXPECT_EQ(claim_json["claim_generator_info"]["version"], "1.0"); + + ASSERT_TRUE(claim_json.contains("created_assertions")); + ASSERT_GT(claim_json["created_assertions"].size(), 0); + EXPECT_EQ(claim_json["created_assertions"][0]["url"], + "self#jumbf=/c2pa/assertions/custom.assertion"); +} + +TEST(ConvertManifestStoreToCrJsonTest, ClaimV2WithMetadataIsConverted) { + // 1. Construct claim_generator_info map. + cppbor::Map generator_info; + generator_info.add("name", "Test Generator"); + generator_info.add("version", "1.0"); + + // 2. Construct created_assertions array. + cppbor::Array created_assertions; + cppbor::Map assertion_link; + assertion_link.add("url", "self#jumbf=/c2pa/assertions/custom.assertion"); + created_assertions.add(std::move(assertion_link)); + + // 3. Construct metadata map. + cppbor::Map metadata_map; + metadata_map.add("dateTime", "2023-05-21T00:00:00Z"); + + // rating + cppbor::Array rating_array; + cppbor::Map rating_map; + rating_map.add("value", "suitable-for-all"); + rating_map.add("code", "G"); + rating_map.add("explanation", "General Audiences"); + rating_array.add(std::move(rating_map)); + metadata_map.add("rating", std::move(rating_array)); + + // dataSource + cppbor::Map datasource_map; + datasource_map.add("type", "camera"); + datasource_map.add("details", "Captured by hardware"); + metadata_map.add("dataSource", std::move(datasource_map)); + + // reference + cppbor::Map reference_map; + reference_map.add("url", "self#jumbf=/c2pa/assertions/custom.assertion"); + metadata_map.add("reference", std::move(reference_map)); + + // localizations + cppbor::Array localizations_array; + cppbor::Map localization_map; + localization_map.add("en", "English"); + localizations_array.add(std::move(localization_map)); + metadata_map.add("localizations", std::move(localizations_array)); + + // regionOfInterest + cppbor::Map region_map; + region_map.add("foo", "bar"); + metadata_map.add("regionOfInterest", std::move(region_map)); + + // 4. Construct the claim map. + cppbor::Map claim_map; + claim_map.add("instanceID", "test_instance_id"); + claim_map.add("claim_generator_info", std::move(generator_info)); + claim_map.add("signature", "self#jumbf=/c2pa/signature"); + claim_map.add("created_assertions", std::move(created_assertions)); + claim_map.add("dc:title", "Test Title"); + claim_map.add("metadata", std::move(metadata_map)); + + std::vector<uint8_t> encoded_claim = claim_map.encode(); + absl::string_view claim_view( + reinterpret_cast<const char*>(encoded_claim.data()), + encoded_claim.size()); + + // 5. Wrap in CborBox. + std::string cbor_box = jumbf::EncodeCborBox(claim_view); + + // 6. Wrap in Claim V2 SuperBox. + std::string claim_box = jumbf::EncodeSuperBox(jumbf::kCborBoxTypeUuid, + "c2pa.claim.v2", {cbor_box}); + + // 7. Wrap in manifest SuperBox. + std::string manifest_box = jumbf::EncodeSuperBox( + kStandardManifestUuid, "test_manifest", {claim_box}); + + // 8. Wrap in manifest store SuperBox. + std::string manifest_store_box = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest_box}); + + // 9. Call ConvertManifestStoreToCrJsonWithoutValidationResults. + absl::StatusOr<nlohmann::json> j = + ConvertToCrJsonWithoutValidationResults(manifest_store_box); + ASSERT_THAT(j, IsOk()); + + // 10. Verify metadata fields in output JSON. + ASSERT_TRUE(j->contains("manifests")); + ASSERT_GT((*j)["manifests"].size(), 0); + const auto& manifest = (*j)["manifests"][0]; + ASSERT_TRUE(manifest.contains("claim.v2")); + const auto& claim_json = manifest["claim.v2"]; + + ASSERT_TRUE(claim_json.contains("metadata")); + const auto& metadata_json = claim_json["metadata"]; + + EXPECT_EQ(metadata_json["dateTime"], "2023-05-21T00:00:00Z"); + + ASSERT_TRUE(metadata_json.contains("rating")); + ASSERT_GT(metadata_json["rating"].size(), 0); + EXPECT_EQ(metadata_json["rating"][0]["value"], "suitable-for-all"); + EXPECT_EQ(metadata_json["rating"][0]["code"], "G"); + EXPECT_EQ(metadata_json["rating"][0]["explanation"], "General Audiences"); + + ASSERT_TRUE(metadata_json.contains("dataSource")); + EXPECT_EQ(metadata_json["dataSource"]["type"], "camera"); + EXPECT_EQ(metadata_json["dataSource"]["details"], "Captured by hardware"); + + ASSERT_TRUE(metadata_json.contains("reference")); + EXPECT_EQ(metadata_json["reference"]["url"], + "self#jumbf=/c2pa/assertions/custom.assertion"); + + // Localizations and RegionOfInterest are currently converted to empty objects + // but we still verify they exist. + ASSERT_TRUE(metadata_json.contains("localizations")); + EXPECT_TRUE(metadata_json["localizations"].is_array()); + ASSERT_TRUE(metadata_json.contains("regionOfInterest")); + EXPECT_TRUE(metadata_json["regionOfInterest"].is_object()); +} + +} // namespace +} // namespace credentio
diff --git a/utils/crjson_utils.cc b/utils/crjson_utils.cc new file mode 100644 index 0000000..9ee3800 --- /dev/null +++ b/utils/crjson_utils.cc
@@ -0,0 +1,165 @@ +// 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/crjson_utils.h" + +#include <cstdint> +#include <string> + +#include "absl/functional/function_ref.h" +#include "absl/strings/escaping.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "cbor/cbor.h" +#include "cppbor/cppbor.h" +#include "nlohmann/json.hpp" + +namespace credentio { + +using Json = ::nlohmann::json; + +bool RecordString(const cbor::MapView& map, absl::string_view key, + bool required, Json& json) { + if (auto val = map.GetOptionalString(key); val.has_value()) { + json[key] = *val; + return true; + } + if (required) { + json["_missing"].push_back(key); + } + return false; +} + +bool RecordByteString(const cbor::MapView& map, absl::string_view key, + bool required, Json& json) { + if (auto val = map.GetOptionalByteString(key); val.has_value()) { + json[key] = absl::StrCat("b64'", absl::Base64Escape(*val), "'"); + return true; + } + if (required) { + json["_missing"].push_back(key); + } + return false; +} + +bool RecordByteString(const cbor::MapView& map, uint32_t key, bool required, + Json& json) { + if (auto val = map.GetByteString(key); val.ok()) { + json[std::to_string(key)] = + absl::StrCat("b64'", absl::Base64Escape(*val), "'"); + return true; + } + if (required) { + json["_missing"].push_back(std::to_string(key)); + } + return false; +} + +bool RecordUint64(const cbor::MapView& map, absl::string_view key, + bool required, Json& json) { + if (auto val = map.GetOptionalUint64(key); val.has_value()) { + json[key] = *val; + return true; + } + if (required) { + json["_missing"].push_back(key); + } + return false; +} + +bool RecordInt64(const cbor::MapView& map, absl::string_view key, bool required, + Json& json) { + if (auto val = map.GetOptionalInt64(key); val.has_value()) { + json[key] = *val; + return true; + } + if (required) { + json["_missing"].push_back(key); + } + return false; +} + +bool RecordBool(const cbor::MapView& map, absl::string_view key, bool required, + Json& json) { + if (auto val = map.GetOptionalBool(key); val.has_value()) { + json[key] = *val; + return true; + } + if (required) { + json["_missing"].push_back(key); + } + return false; +} + +bool RecordArray(const cbor::MapView& map, absl::string_view key, bool required, + ArrayProcessor processor, Json& json) { + if (auto val = map.GetOptionalArray(key); val.has_value()) { + json[key] = Json::array(); + for (int i = 0; i < val->size(); ++i) { + auto elem = val->Get(i); + if (!elem.ok()) { + json[key].push_back(Json({{"_error", elem.status().message()}})); + continue; + } + json[key].push_back(processor(i, *elem)); + } + return true; + } + if (required) { + json[key] = Json::array(); + } + return false; +} + +bool RecordArrayOfMaps(const cbor::MapView& map, absl::string_view key, + bool required, MapProcessor processor, Json& json) { + if (auto val = map.GetOptionalArray(key); val.has_value()) { + json[key] = Json::array(); + for (int i = 0; i < val->size(); ++i) { + auto elem = val->Get(i); + if (!elem.ok()) { + json[key].push_back(Json({{"_error", elem.status().message()}})); + continue; + } + auto map = elem->GetMap(); + if (!map.ok()) { + json[key].push_back(Json({{"_error", elem.status().message()}})); + continue; + } + json[key].push_back(processor(*map)); + } + return true; + } + if (required) { + json[key] = Json::array(); + } + return false; +} + +bool RecordMap(const cbor::MapView& map, absl::string_view key, bool required, + MapProcessor processor, Json& json) { + if (auto val = map.GetOptionalMap(key); val.has_value()) { + json[key] = processor(*val); + return true; + } + if (required) { + cppbor::Map map; + cbor::MapView map_view(&map); + json[key] = processor(map_view); + } + return false; +} + +} // namespace credentio
diff --git a/utils/crjson_utils.h b/utils/crjson_utils.h new file mode 100644 index 0000000..4ae8064 --- /dev/null +++ b/utils/crjson_utils.h
@@ -0,0 +1,127 @@ +// 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_CRJSON_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_CRJSON_UTILS_H_ + +#include <cstdint> + +#include "absl/functional/function_ref.h" +#include "absl/strings/string_view.h" +#include "cbor/cbor.h" +#include "nlohmann/json_fwd.hpp" + +namespace credentio { + +// Record a string value from the map to the JSON. +// +// If `required` is true, the key must be present in the map, otherwise the +// function will add the key to the `_missing` array in the JSON. +// +// Returns true if the key was present in the map, false otherwise. +bool RecordString(const cbor::MapView& map, absl::string_view key, + bool required, nlohmann::json& json); + +// Record a byte string value from the map to the JSON. +// +// If `required` is true, the key must be present in the map, otherwise the +// function will add the key to the `_missing` array in the JSON. +// +// Returns true if the key was present in the map, false otherwise. +bool RecordByteString(const cbor::MapView& map, uint32_t key, bool required, + nlohmann::json& json); + +// Record a byte string value from the map to the JSON. +// +// If `required` is true, the key must be present in the map, otherwise the +// function will add the key to the `_missing` array in the JSON. +// +// Returns true if the key was present in the map, false otherwise. +bool RecordByteString(const cbor::MapView& map, absl::string_view key, + bool required, nlohmann::json& json); + +// Record a byte string value from the map to the JSON. +// +// If `required` is true, the key must be present in the map, otherwise the +// function will add the key to the `_missing` array in the JSON. +// +// Returns true if the key was present in the map, false otherwise. +bool RecordUint64(const cbor::MapView& map, absl::string_view key, + bool required, nlohmann::json& json); + +// Record an int64 value from the map to the JSON. +// +// If `required` is true, the key must be present in the map, otherwise the +// function will add the key to the `_missing` array in the JSON. +// +// Returns true if the key was present in the map, false otherwise. +bool RecordInt64(const cbor::MapView& map, absl::string_view key, bool required, + nlohmann::json& json); + +// Record a bool value from the map to the JSON. +// +// If `required` is true, the key must be present in the map, otherwise the +// function will add the key to the `_missing` array in the JSON. +// +// Returns true if the key was present in the map, false otherwise. +bool RecordBool(const cbor::MapView& map, absl::string_view key, bool required, + nlohmann::json& json); + +// A function that processes an array element. +// +// The function is called for each element in the array and the index of the +// element in the array. The function should return a JSON object that +// represents the element. +using ArrayProcessor = absl::FunctionRef<nlohmann::json( + uint32_t index, const cbor::ItemView& item)>; + +// A function that processes a map. +// +// The function is called for each map in the array of maps and should return a +// JSON object that represents the map. +using MapProcessor = + absl::FunctionRef<nlohmann::json(const cbor::MapView& map)>; + +// Record an array from the map to the JSON. +// +// If `required` is true, the key must be present in the map, otherwise the +// function will add the key to the `_missing` array in the JSON. +// +// Returns true if the key was present in the map, false otherwise. +bool RecordArray(const cbor::MapView& map, absl::string_view key, bool required, + ArrayProcessor processor, nlohmann::json& json); + +// Record an array of maps from the map to the JSON. +// +// If `required` is true, the key must be present in the map, otherwise the +// function will add the key to the `_missing` array in the JSON. +// +// Returns true if the key was present in the map, false otherwise. +bool RecordArrayOfMaps(const cbor::MapView& map, absl::string_view key, + bool required, MapProcessor processor, + nlohmann::json& json); + +// Record a map from the map to the JSON. +// +// If `required` is true, the key must be present in the map, otherwise the +// function will add the key to the `_missing` array in the JSON. +// +// Returns true if the key was present in the map, false otherwise. +bool RecordMap(const cbor::MapView& map, absl::string_view key, bool required, + MapProcessor processor, nlohmann::json& json); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UTILS_CRJSON_UTILS_H_
diff --git a/utils/crjson_utils_test.cc b/utils/crjson_utils_test.cc new file mode 100644 index 0000000..23dd0ce --- /dev/null +++ b/utils/crjson_utils_test.cc
@@ -0,0 +1,218 @@ +// 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/crjson_utils.h" + +#include <cstdint> +#include <utility> + +#include "cbor/cbor.h" +#include "cppbor/cppbor.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "nlohmann/json.hpp" + +namespace credentio { +namespace { + +using Json = ::nlohmann::json; +using ::testing::ElementsAre; + +TEST(UtilsTest, RecordStringPresent) { + cppbor::Map map; + map.add("name", "John"); + cbor::MapView map_view(&map); + Json json = Json::object(); + + EXPECT_TRUE(RecordString(map_view, "name", true, json)); + EXPECT_EQ(json["name"], "John"); +} + +TEST(UtilsTest, RecordStringMissingRequired) { + cppbor::Map map; + cbor::MapView map_view(&map); + Json json = Json::object(); + + EXPECT_FALSE(RecordString(map_view, "name", true, json)); + EXPECT_THAT(json["_missing"], ElementsAre("name")); +} + +TEST(UtilsTest, RecordStringMissingNotRequired) { + cppbor::Map map; + cbor::MapView map_view(&map); + Json json = Json::object(); + + EXPECT_FALSE(RecordString(map_view, "name", false, json)); + EXPECT_FALSE(json.contains("name")); + EXPECT_FALSE(json.contains("_missing")); +} + +TEST(UtilsTest, RecordByteStringPresent) { + cppbor::Map map; + map.add("hash", cppbor::Bstr("data")); + cbor::MapView map_view(&map); + Json json = Json::object(); + + EXPECT_TRUE(RecordByteString(map_view, "hash", true, json)); + EXPECT_EQ(json["hash"], "b64'ZGF0YQ=='"); +} + +TEST(UtilsTest, RecordByteStringUint32KeyPresent) { + cppbor::Map map; + map.add(1, cppbor::Bstr("data")); + cbor::MapView map_view(&map); + Json json = Json::object(); + + EXPECT_TRUE(RecordByteString(map_view, 1, true, json)); + EXPECT_EQ(json["1"], "b64'ZGF0YQ=='"); +} + +TEST(UtilsTest, RecordUint64Present) { + cppbor::Map map; + map.add("size", 100); + cbor::MapView map_view(&map); + Json json = Json::object(); + + EXPECT_TRUE(RecordUint64(map_view, "size", true, json)); + EXPECT_EQ(json["size"], 100); +} + +TEST(UtilsTest, RecordUint64MissingRequired) { + cppbor::Map map; + cbor::MapView map_view(&map); + Json json = Json::object(); + + EXPECT_FALSE(RecordUint64(map_view, "size", true, json)); + EXPECT_THAT(json["_missing"], ElementsAre("size")); +} + +TEST(UtilsTest, RecordUint64MissingNotRequired) { + cppbor::Map map; + cbor::MapView map_view(&map); + Json json = Json::object(); + + EXPECT_FALSE(RecordUint64(map_view, "size", false, json)); + EXPECT_FALSE(json.contains("size")); + EXPECT_FALSE(json.contains("_missing")); +} + +TEST(UtilsTest, RecordInt64Present) { + cppbor::Map map; + map.add("val", -100); + cbor::MapView map_view(&map); + Json json = Json::object(); + + EXPECT_TRUE(RecordInt64(map_view, "val", true, json)); + EXPECT_EQ(json["val"], -100); +} + +TEST(UtilsTest, RecordBoolPresent) { + cppbor::Map map; + map.add("flag", true); + cbor::MapView map_view(&map); + Json json = Json::object(); + + EXPECT_TRUE(RecordBool(map_view, "flag", true, json)); + EXPECT_TRUE(json["flag"]); +} + +TEST(UtilsTest, RecordArrayPresent) { + cppbor::Map map; + cppbor::Array array; + array.add("a"); + array.add("b"); + map.add("list", std::move(array)); + cbor::MapView map_view(&map); + Json json = Json::object(); + + auto processor = [](uint32_t index, const cbor::ItemView& item) { + if (auto val = item.GetString(); val.ok()) { + return Json(*val); + } + return Json({{"_error", item.GetString().status().message()}}); + }; + + EXPECT_TRUE(RecordArray(map_view, "list", true, processor, json)); + EXPECT_THAT(json["list"], ElementsAre("a", "b")); +} + +TEST(UtilsTest, RecordArrayMissingRequired) { + cppbor::Map map; + cbor::MapView map_view(&map); + Json json = Json::object(); + + auto processor = [](uint32_t index, const cbor::ItemView& item) { + if (auto val = item.GetString(); val.ok()) { + return Json(*val); + } + return Json({{"_error", item.GetString().status().message()}}); + }; + + EXPECT_FALSE(RecordArray(map_view, "list", true, processor, json)); + EXPECT_TRUE(json["list"].is_array()); + EXPECT_TRUE(json["list"].empty()); +} + +TEST(UtilsTest, RecordArrayOfMapsPresent) { + cppbor::Map map; + cppbor::Array array; + cppbor::Map item1; + item1.add("k1", "v1"); + cppbor::Map item2; + item2.add("k2", "v2"); + array.add(std::move(item1)); + array.add(std::move(item2)); + map.add("list", std::move(array)); + cbor::MapView map_view(&map); + Json json = Json::object(); + + auto processor = [](const cbor::MapView& m) { + Json j = Json::object(); + if (auto val = m.GetOptionalString("k1"); val.has_value()) { + j["k1"] = *val; + } + if (auto val = m.GetOptionalString("k2"); val.has_value()) { + j["k2"] = *val; + } + return j; + }; + + EXPECT_TRUE(RecordArrayOfMaps(map_view, "list", true, processor, json)); + EXPECT_EQ(json["list"][0]["k1"], "v1"); + EXPECT_EQ(json["list"][1]["k2"], "v2"); +} + +TEST(UtilsTest, RecordMapPresent) { + cppbor::Map map; + cppbor::Map sub_map; + sub_map.add("sub_key", "sub_val"); + map.add("map_key", std::move(sub_map)); + cbor::MapView map_view(&map); + Json json = Json::object(); + + auto processor = [](const cbor::MapView& m) { + Json j = Json::object(); + if (auto val = m.GetString("sub_key"); val.ok()) { + j["sub_key"] = *val; + } + return j; + }; + + EXPECT_TRUE(RecordMap(map_view, "map_key", true, processor, json)); + EXPECT_EQ(json["map_key"]["sub_key"], "sub_val"); +} + +} // namespace +} // namespace credentio
diff --git a/utils/media_type.cc b/utils/media_type.cc index 5ecd6be..74a6e16 100644 --- a/utils/media_type.cc +++ b/utils/media_type.cc
@@ -56,6 +56,7 @@ {"tif", "image/tiff"}, {"tiff", "image/tiff"}, {"wav", "audio/wav"}, // Not formally registered. + {"avi", "video/x-msvideo"}, {"gif", "image/gif"}, {"mp3", "audio/mpeg"}, {"flac", "audio/flac"},
diff --git a/utils/media_type_test.cc b/utils/media_type_test.cc index 9b75dda..f78127f 100644 --- a/utils/media_type_test.cc +++ b/utils/media_type_test.cc
@@ -41,6 +41,7 @@ EXPECT_THAT(MediaType("foo.tif"), IsOkAndHolds("image/tiff")); EXPECT_THAT(MediaType("foo.tiff"), IsOkAndHolds("image/tiff")); EXPECT_THAT(MediaType("foo.wav"), IsOkAndHolds("audio/wav")); + EXPECT_THAT(MediaType("foo.avi"), IsOkAndHolds("video/x-msvideo")); EXPECT_THAT(MediaType("foo.gif"), IsOkAndHolds("image/gif")); EXPECT_THAT(MediaType("foo.m4a"), IsOkAndHolds("audio/mp4")); EXPECT_THAT(MediaType("foo.mp3"), IsOkAndHolds("audio/mpeg"));