blob: 993575b7789ca5ecdefa9388f0004b6cbc79e414 [file]
// 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/merkle_validator.h"
#include <algorithm>
#include <cctype>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include "absl/log/log.h"
#include "absl/status/status.h"
#include "absl/status/status_matchers.h"
#include "absl/strings/str_cat.h"
#include "absl/strings/str_format.h"
#include "absl/strings/string_view.h"
#include "constants/status_codes.h"
#include "crypto/algorithms.h"
#include "crypto/hash.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/reader.h"
#include "riegeli/bytes/string_reader.h"
#include "testing/cbor_utils.h"
#include "testing/proto_test_utils.h"
#include "utils/status_tracker.h"
#include "validator/tracker.h"
namespace credentio {
namespace {
using ::absl_testing::IsOk;
using ::absl_testing::StatusIs;
using ::credentio_testing::ParseTextProtoOrDie;
using ::testing::ElementsAreArray;
using ::testing::IsEmpty;
class TrackingStatusTracker : public StatusTracker {
public:
TrackingStatusTracker() = default;
~TrackingStatusTracker() override = default;
struct SuccessRecord {
SuccessStatusCode code;
std::string url = "";
std::string explanation = "";
bool operator==(const SuccessRecord& other) const {
return code == other.code && url == other.url &&
explanation == other.explanation;
}
};
struct FailureRecord {
FailureStatusCode code;
std::string url = "";
std::string explanation = "";
bool operator==(const FailureRecord& other) const {
return code == other.code && url == other.url &&
explanation == other.explanation;
}
};
struct InformationalRecord {
InformationalStatusCode code;
std::string url = "";
std::string explanation = "";
bool operator==(const InformationalRecord& other) const {
return code == other.code && url == other.url &&
explanation == other.explanation;
}
};
void RecordSuccess(SuccessStatusCode code,
ValidationTracker::RecordOptions options) override {
successes_.push_back(SuccessRecord{
.code = code,
.url = std::string(options.url),
.explanation = std::string(options.explanation),
});
};
void RecordFailure(FailureStatusCode code,
ValidationTracker::RecordOptions options) override {
failures_.push_back(FailureRecord{
.code = code,
.url = std::string(options.url),
.explanation = std::string(options.explanation),
});
};
void RecordInformational(InformationalStatusCode code,
ValidationTracker::RecordOptions options) override {
informationals_.push_back(InformationalRecord{
.code = code,
.url = std::string(options.url),
.explanation = std::string(options.explanation),
});
};
std::vector<SuccessRecord> successes_;
std::vector<FailureRecord> failures_;
std::vector<InformationalRecord> informationals_;
};
class JoiningHasherFactoryProvider : public HasherFactoryProvider {
public:
class JoiningHasherFactory : public HasherFactory {
public:
class JoiningHasher : public Hasher {
public:
void Update(absl::string_view content) override {
digest_ = absl::StrCat(digest_, content);
std::replace_if(
digest_.begin(), digest_.end(),
[](char c) { return !std::isalnum(static_cast<unsigned char>(c)); },
'_');
}
std::string Digest() override { return digest_; }
protected:
std::string digest_ = "";
};
absl::StatusOr<std::unique_ptr<Hasher>> Create() const override {
return std::make_unique<JoiningHasher>();
};
HashAlgorithm algorithm() const override { return HashAlgorithm::kSha256; };
};
absl::StatusOr<std::unique_ptr<HasherFactory>> Create(
absl::string_view algo) const override {
if (algo == "error") {
return absl::InvalidArgumentError(
absl::StrFormat("unsupported hash algorithm: %s", algo));
}
return std::make_unique<JoiningHasherFactory>();
}
};
struct MerkleValidatorTestCase {
std::string name;
std::string file_contents;
BmffBasedHashAssertion assertion;
absl::Status expected_status;
std::vector<TrackingStatusTracker::FailureRecord> expected_failures;
};
class MerkleValidatorTest
: public ::testing::TestWithParam<MerkleValidatorTestCase> {
public:
void SetUp() override {
auto input_reader =
std::make_unique<riegeli::StringReader<>>(GetParam().file_contents);
ASSERT_THAT(input_reader->status(), IsOk());
input_ = std::move(input_reader);
factory_ = std::make_unique<JoiningHasherFactoryProvider>();
assertion_uri_ = "assertion_uri";
validator_ = std::make_unique<MerkleValidator>(
*input_, GetParam().assertion, assertion_uri_,
std::make_unique<JoiningHasherFactoryProvider>());
}
std::unique_ptr<riegeli::Reader> input_;
std::unique_ptr<HasherFactoryProvider> factory_;
std::string assertion_uri_;
std::unique_ptr<MerkleValidator> validator_;
};
TEST_P(MerkleValidatorTest, Validate) {
TrackingStatusTracker tracker;
EXPECT_THAT(validator_->Validate(tracker),
StatusIs(GetParam().expected_status.code(),
GetParam().expected_status.message()));
// Successes are not marked within the MerkleValidator.
EXPECT_THAT(tracker.successes_, IsEmpty());
EXPECT_THAT(tracker.failures_,
ElementsAreArray(GetParam().expected_failures));
EXPECT_THAT(tracker.informationals_, IsEmpty());
}
INSTANTIATE_TEST_SUITE_P(
MerkleValidatorTests, MerkleValidatorTest,
::testing::Values(
MerkleValidatorTestCase{
.name = "FailsWithInvalidInput",
.file_contents = "this_is_not_a_valid_bmff_file",
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles { count: 1 fixed_block_size: 10 }
)pb"),
.expected_status =
absl::InternalError("failed to extract leaf data"),
.expected_failures =
{{.code = FailureStatusCode::kGoogleInternalError,
.url = "assertion_uri",
.explanation = "truncated BMFF box; at byte 8"}},
},
MerkleValidatorTestCase{
.name = "FailsMdatCountNotMatching",
.file_contents =
absl::StrCat(credentio_testing::Box("mdat", "data_one"),
credentio_testing::Box("mdat", "data_two")),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles { count: 1 fixed_block_size: 10 }
)pb"),
.expected_status = absl::InternalError(
"number of mdat atoms does not match the "
"number of merkle maps"),
.expected_failures =
{{.code = FailureStatusCode::kAssertionBmffHashMalformed,
.url = "assertion_uri",
.explanation = "number of mdat atoms does not match "
"the number of merkle maps"}},
},
MerkleValidatorTestCase{
.name = "FailsFragmentedUnsupported",
.file_contents = credentio_testing::Box("mdat", "abcdef"),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles {
count: 2
init_hash: "only_for_fragmented"
fixed_block_size: 3
}
)pb"),
.expected_status = absl::UnimplementedError(
"fragmented merkle validations are not supported yet"),
.expected_failures =
{{.code = FailureStatusCode::kGoogleInternalError,
.url = "assertion_uri",
.explanation =
"fragmented merkle validations are not supported yet"}},
},
MerkleValidatorTestCase{
.name = "FailsHashesBelowLeafs",
.file_contents = credentio_testing::Box("mdat", "abcdef"),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles {
count: 3
fixed_block_size: 10
hashes: "a"
hashes: "b"
hashes: "c"
hashes: "d"
hashes: "e"
hashes: "f"
}
)pb"),
.expected_status = absl::InvalidArgumentError(
"hashes row size is larger than the leaf row size"),
.expected_failures =
{{.code = FailureStatusCode::kAssertionBmffHashMalformed,
.url = "assertion_uri",
.explanation =
"hashes row size is larger than the leaf row size"}},
},
MerkleValidatorTestCase{
.name = "FailsLeafSizesTooLarge",
.file_contents = credentio_testing::Box("mdat", "abcdef"),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles { count: 1 variable_block_sizes: 10 hashes: "abcdef" }
)pb"),
.expected_status = absl::InvalidArgumentError(
"merkle map's variable block sizes do "
"not sum to the mdat box size"),
.expected_failures =
{{.code = FailureStatusCode::kAssertionBmffHashMalformed,
.url = "assertion_uri",
.explanation = "merkle map's variable block sizes do not sum "
"to the mdat box size"}},
},
MerkleValidatorTestCase{
.name = "FailsLeafHashMismatch",
.file_contents = credentio_testing::Box("mdat", "abcdef"),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles {
count: 3
fixed_block_size: 2
hashes: "wrong_hash"
hashes: "cd"
hashes: "ef"
}
)pb"),
.expected_status = absl::InternalError("merkle map hash mismatch"),
.expected_failures =
{{.code = FailureStatusCode::kAssertionBmffHashMismatch,
.url = "assertion_uri",
.explanation = "merkle map hash mismatch at index 0: "
"Expected: wrong_hash, Actual: ab"}},
},
MerkleValidatorTestCase{
.name = "FailsAuxiliaryDataBeforeLastMdat",
.file_contents = []() -> std::string {
std::string aux_box = cbor::FromJson(R"json({
"hashes": [ "b64'Zm9v'" ],
"localId": 1,
"location": 0,
"uniqueId": 1
})json");
return absl::StrCat(credentio_testing::Box(
"uuid", credentio_testing::C2paBoxPayload(
"merkle", aux_box)),
credentio_testing::Box("mdat", "abcdef"));
}(),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles {
count: 7
fixed_block_size: 2
hashes: "wrong_hash"
hashes: "cd"
hashes: "ef"
}
)pb"),
.expected_status =
absl::InternalError("failed to extract leaf data"),
.expected_failures =
{{.code = FailureStatusCode::kGoogleInternalError,
.url = "assertion_uri",
.explanation =
"encountered auxiliary atom before the first mdat atom"}},
},
MerkleValidatorTestCase{
.name = "FailsIncorrectAuxiliaryCount",
.file_contents = credentio_testing::Box("mdat", "abcdef"),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles {
count: 3
fixed_block_size: 2
hashes: "wrong_hash"
hashes: "cd"
hashes: "ef"
}
)pb"),
.expected_status = absl::InternalError("merkle map hash mismatch"),
.expected_failures =
{{.code = FailureStatusCode::kAssertionBmffHashMismatch,
.url = "assertion_uri",
.explanation = "merkle map hash mismatch at index 0: "
"Expected: wrong_hash, Actual: ab"}},
},
MerkleValidatorTestCase{
.name = "FailsUnsupportedHashAlgorithm",
.file_contents = credentio_testing::Box("mdat", "abcdef"),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "error"
hash: "unused"
merkles {
count: 3
fixed_block_size: 2
hashes: "ab"
hashes: "cd"
hashes: "ef"
}
)pb"),
.expected_status =
absl::InvalidArgumentError("unsupported hash algorithm: error"),
.expected_failures =
{{.code = FailureStatusCode::kAssertionBmffHashMalformed,
.url = "assertion_uri",
.explanation = "unsupported hash algorithm: error"}},
},
MerkleValidatorTestCase{
.name = "FailsInvalidHashWithAuxiliaryData",
.file_contents = []() -> std::string {
std::string aux_box_1 = cbor::FromJson(R"json({
"hashes": [ "b64'ZGVm'" ],
"localId": 1,
"location": 0,
"uniqueId": 1
})json");
std::string aux_box_2 = cbor::FromJson(R"json({
"hashes": [ "b64'YWJj'" ],
"localId": 1,
"location": 1,
"uniqueId": 1
})json");
return absl::StrCat(credentio_testing::Box("mdat", "abcdef"),
credentio_testing::Box(
"uuid", credentio_testing::C2paBoxPayload(
"merkle", aux_box_1)),
credentio_testing::Box(
"uuid", credentio_testing::C2paBoxPayload(
"merkle", aux_box_2)));
}(),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles {
unique_id: 1
local_id: 1
count: 2
fixed_block_size: 3
hashes: "wrong_hash"
}
)pb"),
.expected_status = absl::InternalError("merkle map hash mismatch"),
.expected_failures =
{{.code = FailureStatusCode::kAssertionBmffHashMismatch,
.url = "assertion_uri",
.explanation = "merkle map hash mismatch at index 0: "
"Expected: wrong_hash, Actual: abcdef"}},
},
MerkleValidatorTestCase{
.name = "FailsBoxSizeTooSmallForMetadataHeaders",
.file_contents = []() -> std::string {
std::string aux_box_1 = cbor::FromJson(R"json({
"hashes": [ "b64'ZGVm'" ],
"localId": 1,
"location": 0,
"uniqueId": 1
})json");
return absl::StrCat(
credentio_testing::Box("mdat", "abcdef"),
credentio_testing::BadBox(
"uuid",
credentio_testing::C2paBoxPayload("merkle", aux_box_1),
28));
}(),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles {
unique_id: 1
local_id: 1
count: 2
fixed_block_size: 3
hashes: "wrong_hash"
}
)pb"),
.expected_status =
absl::InternalError("failed to extract leaf data"),
.expected_failures =
{{.code = FailureStatusCode::kGoogleInternalError,
.url = "assertion_uri",
.explanation = "box size too small for metadata headers"}},
},
MerkleValidatorTestCase{
.name = "FailsMdatBoxSizeSmallerThanHeaderSize",
.file_contents = std::string(
"\x00\x00\x00\x01mdat\x00\x00\x00\x00\x00\x00\x00\x08", 16),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles { count: 1 fixed_block_size: 2 hashes: "11" }
)pb"),
.expected_status =
absl::InternalError("failed to extract leaf data"),
.expected_failures =
{{.code = FailureStatusCode::kGoogleInternalError,
.url = "assertion_uri",
.explanation = "kInvalidData; box size too small for header; "
"at byte 16"}},
},
MerkleValidatorTestCase{
.name = "FailsTruncatedMerkleCrashPrevention",
.file_contents = credentio_testing::Box("mdat",
std::string(28, 'a')),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles {
count: 14
fixed_block_size: 2
hashes: "11"
hashes: "22"
hashes: "33"
}
)pb"),
.expected_status = absl::InvalidArgumentError(
"hashes count is not equal to the expected hashes count: "
"Hashes Count: 3, Leaf Count: 14, Expected Hashes Count: 4"),
.expected_failures =
{{.code = FailureStatusCode::kAssertionBmffHashMalformed,
.url = "assertion_uri",
.explanation =
"hashes count is not equal to the expected hashes count: "
"Hashes Count: 3, Leaf Count: 14, Expected Hashes Count: "
"4"}},
},
MerkleValidatorTestCase{
.name = "SuccessFixedBlockSizeWithHashesAsLeafRow",
.file_contents = credentio_testing::Box("mdat", "abcdef"),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles {
count: 3
fixed_block_size: 2
hashes: "ab"
hashes: "cd"
hashes: "ef"
}
)pb"),
.expected_status = absl::OkStatus(),
.expected_failures = {},
},
MerkleValidatorTestCase{
.name = "SuccessVariableBlockSizeWithHashesAsLeafRow",
.file_contents = credentio_testing::Box("mdat", "abcdef"),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles {
count: 2
variable_block_sizes: 4
variable_block_sizes: 2
hashes: "abcd"
hashes: "ef"
}
)pb"),
.expected_status = absl::OkStatus(),
.expected_failures = {},
},
MerkleValidatorTestCase{
.name = "SuccessHashWithAuxiliaryData",
.file_contents = []() -> std::string {
std::string aux_box_1 = cbor::FromJson(R"json({
"hashes": [ "b64'ZGVm'" ],
"localId": 1,
"location": 0,
"uniqueId": 1
})json");
std::string aux_box_2 = cbor::FromJson(R"json({
"hashes": [ "b64'YWJj'" ],
"localId": 1,
"location": 1,
"uniqueId": 1
})json");
return absl::StrCat(credentio_testing::Box("mdat", "abcdef"),
credentio_testing::Box(
"uuid", credentio_testing::C2paBoxPayload(
"merkle", aux_box_1)),
credentio_testing::Box(
"uuid", credentio_testing::C2paBoxPayload(
"merkle", aux_box_2)));
}(),
.assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
alg: "sha256"
hash: "unused"
merkles {
unique_id: 1
local_id: 1
count: 2
fixed_block_size: 3
hashes: "abcdef"
}
)pb"),
.expected_status = absl::OkStatus(),
.expected_failures = {}}),
[](const testing::TestParamInfo<MerkleValidatorTestCase>& info) {
return info.param.name;
});
using ::testing::NotNull;
TEST(DefaultHasherFactoryProviderTest, CreateSha256) {
DefaultHasherFactoryProvider provider;
auto factory = provider.Create("sha256");
ASSERT_THAT(factory, IsOk());
EXPECT_THAT(*factory, NotNull());
EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha256);
}
TEST(DefaultHasherFactoryProviderTest, CreateSha384) {
DefaultHasherFactoryProvider provider;
auto factory = provider.Create("sha384");
ASSERT_THAT(factory, IsOk());
EXPECT_THAT(*factory, NotNull());
EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha384);
}
TEST(DefaultHasherFactoryProviderTest, CreateSha512) {
DefaultHasherFactoryProvider provider;
auto factory = provider.Create("sha512");
ASSERT_THAT(factory, IsOk());
EXPECT_THAT(*factory, NotNull());
EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha512);
}
TEST(DefaultHasherFactoryProviderTest, CreateInvalid) {
DefaultHasherFactoryProvider provider;
EXPECT_THAT(provider.Create("invalid"),
StatusIs(absl::StatusCode::kInvalidArgument));
}
} // namespace
} // namespace credentio