| // 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/boxes_hash_validator.h" | |
| #include <string> | |
| #include "absl/log/log.h" | |
| #include "absl/status/status.h" | |
| #include "absl/strings/str_format.h" | |
| #include "absl/strings/string_view.h" | |
| #include "constants/status_codes.h" | |
| #include "gmock/gmock.h" | |
| #include "google/protobuf/util/message_differencer.h" | |
| #include "gtest/gtest.h" | |
| #include "proto/boxes_hash_assertion.pb.h" | |
| #include "proto/validation_status.pb.h" | |
| #include "testing/proto_test_utils.h" | |
| #include "testing/test_status_tracker.h" | |
| namespace credentio { | |
| namespace { | |
| using ::credentio_testing::ParseTextProtoOrDie; | |
| struct ExpectedValues { | |
| absl::Status status; | |
| ValidationStatusSet validation; | |
| }; | |
| // Matcher for ValidationStatusSet that ignores the `url` field. | |
| MATCHER_P(EqualsValidationStatusSetIgnoringUrl, expected, "") { | |
| google::protobuf::util::MessageDifferencer differencer; | |
| differencer.IgnoreField( | |
| ValidationStatus::descriptor()->FindFieldByName("url")); | |
| return differencer.Compare(arg, expected); | |
| } | |
| struct BoxesHashValidatorTestParams { | |
| std::string name; | |
| BoxesHashAssertion assertion; | |
| ExpectedValues expected; | |
| }; | |
| ExpectedValues CreateStatusSet(bool has_additional_exclusions = false) { | |
| ExpectedValues expected; | |
| expected.status = absl::OkStatus(); | |
| if (has_additional_exclusions) { | |
| expected.validation.add_informationals()->set_code(absl::StrFormat( | |
| "%v", InformationalStatusCode:: | |
| kAssertionBoxesHashAdditionalExclusionsPresent)); | |
| } | |
| return expected; | |
| } | |
| ExpectedValues CreateStatusSet(FailureStatusCode failure_code, | |
| absl::string_view explanation, | |
| bool has_additional_exclusions = false) { | |
| ExpectedValues expected; | |
| ValidationStatus* failure = expected.validation.add_failures(); | |
| failure->set_code(absl::StrFormat("%v", failure_code)); | |
| failure->set_explanation(explanation); | |
| expected.status = absl::InvalidArgumentError(explanation); | |
| if (has_additional_exclusions) { | |
| expected.validation.add_informationals()->set_code(absl::StrFormat( | |
| "%v", InformationalStatusCode:: | |
| kAssertionBoxesHashAdditionalExclusionsPresent)); | |
| } | |
| return expected; | |
| } | |
| using BoxesHashValidatorTest = | |
| testing::TestWithParam<BoxesHashValidatorTestParams>; | |
| TEST_P(BoxesHashValidatorTest, Validate) { | |
| TestStatusTracker tracker; | |
| BoxesHashValidator validator(GetParam().assertion, "test_uri", "", tracker); | |
| EXPECT_EQ(validator.Validate(), GetParam().expected.status); | |
| EXPECT_THAT(tracker.GetStatusSet(), EqualsValidationStatusSetIgnoringUrl( | |
| GetParam().expected.validation)); | |
| } | |
| INSTANTIATE_TEST_SUITE_P( | |
| BoxesHashValidatorTest, BoxesHashValidatorTest, | |
| testing::ValuesIn<BoxesHashValidatorTestParams>({ | |
| { | |
| .name = "empty_assertion", | |
| .assertion = BoxesHashAssertion(), | |
| .expected = | |
| CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed, | |
| "box-map.boxes is empty"), | |
| }, | |
| { | |
| .name = "empty_names", | |
| .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { hash: "hash" } | |
| )pb"), | |
| .expected = | |
| CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed, | |
| "box-map.names is empty"), | |
| }, | |
| { | |
| .name = "empty_hash", | |
| .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { names: "box1" } | |
| )pb"), | |
| .expected = | |
| CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMismatch, | |
| "box-map.hash is empty"), | |
| }, | |
| { | |
| .name = "no_algo", | |
| .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { names: "box1" hash: "hash" } | |
| )pb"), | |
| .expected = | |
| CreateStatusSet(FailureStatusCode::kAlgorithmUnsupported, | |
| "Algorithm not supported: "), | |
| }, | |
| { | |
| .name = "bad_algo_box_map", | |
| .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { names: "box1" hash: "hash" alg: "bad_algo" } | |
| )pb"), | |
| .expected = | |
| CreateStatusSet(FailureStatusCode::kAlgorithmUnsupported, | |
| "Algorithm not supported: bad_algo"), | |
| }, | |
| { | |
| .name = "bad_algo_assertion", | |
| .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { names: "box1" hash: "hash" } | |
| alg: "bad_algo" | |
| )pb"), | |
| .expected = | |
| CreateStatusSet(FailureStatusCode::kAlgorithmUnsupported, | |
| "Algorithm not supported: bad_algo"), | |
| }, | |
| { | |
| .name = "c2pa_not_only_box", | |
| .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { names: "box1" names: "C2PA" hash: "hash" } | |
| alg: "sha256" | |
| )pb"), | |
| .expected = CreateStatusSet( | |
| FailureStatusCode::kAssertionBoxesHashMalformed, | |
| "C2PA box must be the only name in the names array"), | |
| }, | |
| { | |
| .name = "c2pa_not_zero_byte_hash", | |
| .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { names: "C2PA" hash: "hash" } | |
| alg: "sha256" | |
| )pb"), | |
| .expected = | |
| CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMismatch, | |
| "C2PA box hash is not a single 0 byte"), | |
| }, | |
| { | |
| .name = "only_c2pa_excluded", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { names: "box1" hash: "hash" } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = CreateStatusSet(), | |
| }, | |
| { | |
| .name = "box_1_excluded", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { names: "box1" hash: "hash", excluded: true } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = CreateStatusSet(/*has_additional_exclusions=*/true), | |
| }, | |
| { | |
| .name = "single_box_exclusion_ranges_out_of_order", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { | |
| names: "box1" | |
| hash: "hash", | |
| exclusions { start: 100 length: 100 } | |
| exclusions { start: 10 length: 50 } | |
| } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = CreateStatusSet( | |
| FailureStatusCode::kAssertionBoxesHashMalformed, | |
| "box-map.start is overlapping with the last box-map.start"), | |
| }, | |
| { | |
| .name = "single_box_exclusion_ranges_overlapping", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { | |
| names: "box1" | |
| hash: "hash", | |
| exclusions { start: 100 length: 100 } | |
| exclusions { start: 150 length: 100 } | |
| } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = CreateStatusSet( | |
| FailureStatusCode::kAssertionBoxesHashMalformed, | |
| "box-map.start is overlapping with the last box-map.start"), | |
| }, | |
| { | |
| .name = "two_boxes_exclusion_ranges_missing_box_index", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { | |
| names: "box1" | |
| names: "box2" | |
| hash: "hash", | |
| exclusions { start: 100 length: 100 } | |
| } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = CreateStatusSet( | |
| FailureStatusCode::kAssertionBoxesHashMalformed, | |
| "box-map.boxIndex is required when there is more than one box"), | |
| }, | |
| { | |
| .name = "two_boxes_exclusion_ranges_out_of_range", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { | |
| names: "box1" | |
| names: "box2" | |
| hash: "hash", | |
| exclusions { start: 100 length: 100 box_index: 2 } | |
| } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = | |
| CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed, | |
| "box-map.boxIndex is out of range"), | |
| }, | |
| { | |
| .name = "two_boxes_exclusion_ranges_negative_box_index", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { | |
| names: "box1" | |
| names: "box2" | |
| hash: "hash", | |
| exclusions { start: 100 length: 100 box_index: -1 } | |
| } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = | |
| CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed, | |
| "box-map.boxIndex is out of range"), | |
| }, | |
| { | |
| .name = "two_boxes_exclusion_ranges_box_index_not_in_order", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { | |
| names: "box1" | |
| names: "box2" | |
| hash: "hash", | |
| exclusions { start: 100 length: 100 box_index: 1 } | |
| exclusions { start: 100 length: 100 box_index: 0 } | |
| } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = | |
| CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed, | |
| "box-map.boxIndex is not in order"), | |
| }, | |
| { | |
| .name = "two_boxes_exclusion_ranges_box_index_in_order", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { | |
| names: "box1" | |
| names: "box2" | |
| hash: "hash" | |
| exclusions { start: 100 length: 100 box_index: 0 } | |
| exclusions { start: 100 length: 100 box_index: 1 } | |
| } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = CreateStatusSet(/*has_additional_exclusions=*/true), | |
| }, | |
| { | |
| .name = "one_box_exclusion_ranges_valid", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { | |
| names: "box1" | |
| hash: "hash", | |
| exclusions { start: 10 length: 10 } | |
| exclusions { start: 30 length: 10 } | |
| } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = CreateStatusSet(/*has_additional_exclusions=*/true), | |
| }, | |
| { | |
| .name = "negative_exclusion_start", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { | |
| names: "box1" | |
| hash: "hash", | |
| exclusions { start: -10 length: 10 } | |
| } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = CreateStatusSet( | |
| FailureStatusCode::kAssertionBoxesHashMalformed, | |
| "box-map.start and box-map.length must be non-negative"), | |
| }, | |
| { | |
| .name = "negative_exclusion_length", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { | |
| names: "box1" | |
| hash: "hash", | |
| exclusions { start: 10 length: -10 } | |
| } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = CreateStatusSet( | |
| FailureStatusCode::kAssertionBoxesHashMalformed, | |
| "box-map.start and box-map.length must be non-negative"), | |
| }, | |
| { | |
| .name = "exclusion_range_overflow", | |
| .assertion = []() -> BoxesHashAssertion { | |
| auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( | |
| boxes { | |
| names: "box1" | |
| hash: "hash", | |
| exclusions { start: 9223372036854775800 length: 100 } | |
| } | |
| boxes { names: "C2PA" hash: "" } | |
| alg: "sha256" | |
| )pb"); | |
| assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); | |
| return assertion; | |
| }(), | |
| .expected = | |
| CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed, | |
| "box-map.start + box-map.length overflows"), | |
| }, | |
| }), | |
| [](const testing::TestParamInfo<BoxesHashValidatorTestParams>& info) { | |
| return info.param.name; | |
| }); | |
| } // namespace | |
| } // namespace credentio |