blob: e879961b739cc0f3dc84b4ac43d89f84e3adbb2e [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 "formats/gif/extractor.h"
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "absl/status/status.h"
#include "absl/status/status_matchers.h"
#include "absl/status/statusor.h"
#include "absl/strings/string_view.h"
#include "formats/asset_box.h"
#include "formats/asset_byte_info.h"
#include "formats/byte_range.h"
#include "formats/extractor_result.h"
#include "formats/gif/constants.h"
#include "formats/gif/test_builder.h"
#include "gmock/gmock.h"
#include "gtest/gtest.h"
#include "riegeli/base/types.h"
#include "riegeli/bytes/reader.h"
#include "riegeli/bytes/string_reader.h"
#include "testing/jumbf_utils.h"
namespace credentio {
namespace {
using ::absl_testing::IsOkAndHolds;
using ::absl_testing::StatusIs;
using ::testing::HasSubstr;
class NoSizeStringReader : public riegeli::StringReader<> {
public:
using riegeli::StringReader<>::StringReader;
bool SupportsSize() override { return false; }
};
class SizeNotValuedStringReader : public riegeli::StringReader<> {
public:
using riegeli::StringReader<>::StringReader;
bool SupportsSize() override { return true; }
protected:
std::optional<riegeli::Position> SizeImpl() override { return std::nullopt; }
};
constexpr int64_t kMaxChunkBytes = 2 * 1048576; // 2MiB
struct TestCase {
std::string name;
std::string contents;
absl::StatusOr<ExtractorResult> result;
uint32_t max_chunk_bytes = kMaxChunkBytes;
};
class ExtractorTest : public testing::TestWithParam<TestCase> {};
INSTANTIATE_TEST_SUITE_P(
ExtractorTests, ExtractorTest,
testing::ValuesIn({
TestCase{
.name = "Valid",
.contents =
[]() {
TestGifBuilder builder;
builder.AddGifHeader();
builder.AddLogicalDescriptor(/*has_color_table=*/false);
builder.AddBlock(kGifExtensionIntroducer,
kGifExtensionApplication,
std::string(kGifC2paIdentifier) + "___",
"manifest_store");
return builder.GetAsset();
}(),
.result =
ExtractorResult{
.manifest_store = "manifest_store",
.asset_byte_info =
{
.manifest_store_location = ByteRange{.offset = 13,
.length = 30},
.boxes = {AssetBox{.identifier = "GIF89a",
.byte_range = {.offset = 0,
.length = 6}},
AssetBox{.identifier = "LSD",
.byte_range = {.offset = 6,
.length = 7}},
AssetBox{.identifier = "C2PA",
.byte_range = {.offset = 13,
.length = 30}}},
},
},
},
TestCase{
.name = "NoC2pa",
.contents =
[]() {
TestGifBuilder builder;
builder.AddGifHeader();
builder.AddLogicalDescriptor(/*has_color_table=*/false);
return builder.GetAsset();
}(),
.result = absl::NotFoundError("No manifest store found"),
},
TestCase{
.name = "MultipleC2pa",
.contents =
[]() {
TestGifBuilder builder;
builder.AddGifHeader();
builder.AddLogicalDescriptor(/*has_color_table=*/false);
builder.AddBlock(
kGifExtensionIntroducer, kGifExtensionApplication,
std::string(kGifC2paIdentifier) + "___", "first_store");
builder.AddBlock(
kGifExtensionIntroducer, kGifExtensionApplication,
std::string(kGifC2paIdentifier) + "___", "second_store");
return builder.GetAsset();
}(),
.result = absl::NotFoundError("Multiple manifest stores found"),
},
}),
[](const testing::TestParamInfo<ExtractorTest::ParamType>& info) {
return info.param.name;
});
TEST_P(ExtractorTest, Extract) {
const TestCase& param = GetParam();
const absl::StatusOr<ExtractorResult>& expected = param.result;
riegeli::StringReader<> input(param.contents);
absl::StatusOr<std::string> result =
GifExtractor().ExtractManifestStore(input);
if (expected.ok()) {
EXPECT_THAT(result, IsOkAndHolds(expected->manifest_store));
} else {
EXPECT_THAT(result, StatusIs(expected.status().code(),
HasSubstr(expected.status().message())));
}
}
TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) {
const TestCase& param = GetParam();
const absl::StatusOr<ExtractorResult>& expected = param.result;
std::string contents = "padding" + param.contents;
riegeli::StringReader<> input(contents);
ASSERT_TRUE(input.Seek(7));
absl::StatusOr<std::string> result =
GifExtractor().ExtractManifestStore(input);
if (expected.ok()) {
EXPECT_THAT(result, IsOkAndHolds(expected->manifest_store));
} else {
EXPECT_THAT(result, StatusIs(expected.status().code(),
HasSubstr(expected.status().message())));
}
}
TEST_P(ExtractorTest, ExtractManifestStoreLocationWithSuffixPadded) {
const TestCase& param = GetParam();
const absl::StatusOr<ExtractorResult>& expected = param.result;
std::string contents = param.contents + "padding";
riegeli::StringReader<> input(contents);
absl::StatusOr<std::optional<ByteRange>> result =
GifExtractor().ExtractManifestStoreLocation(
input, {.end_offset = static_cast<int64_t>(contents.size() - 7)});
if (expected.ok()) {
EXPECT_THAT(result, IsOkAndHolds(
expected->asset_byte_info.manifest_store_location));
} else {
EXPECT_THAT(result, StatusIs(expected.status().code(),
HasSubstr(expected.status().message())));
}
}
TEST_P(ExtractorTest, ExtractBoxesWithSuffixPadded) {
const TestCase& param = GetParam();
const absl::StatusOr<ExtractorResult>& expected = param.result;
std::string contents = param.contents + "padding";
riegeli::StringReader<> input(contents);
absl::StatusOr<std::vector<AssetBox>> result = GifExtractor().ExtractBoxes(
input, {.end_offset = static_cast<int64_t>(contents.size()) - 7});
if (expected.ok()) {
EXPECT_THAT(result, IsOkAndHolds(expected->asset_byte_info.boxes));
} else {
EXPECT_THAT(result, StatusIs(expected.status().code(),
HasSubstr(expected.status().message())));
}
}
TEST_P(ExtractorTest, ExtractManifestStoreLocationRequiresC2pa) {
const TestCase& param = GetParam();
const absl::StatusOr<ExtractorResult>& expected = param.result;
riegeli::StringReader<> input(param.contents);
absl::StatusOr<std::optional<ByteRange>> result =
GifExtractor().ExtractManifestStoreLocation(input, {});
if (expected.ok()) {
EXPECT_THAT(result, IsOkAndHolds(
expected->asset_byte_info.manifest_store_location));
} else {
EXPECT_THAT(result, StatusIs(expected.status().code(),
HasSubstr(expected.status().message())));
}
}
TEST_P(ExtractorTest, ExtractManifestStoreLocationDoesNotRequireC2pa) {
const TestCase& param = GetParam();
const absl::StatusOr<ExtractorResult>& expected = param.result;
riegeli::StringReader<> input(param.contents);
absl::StatusOr<std::optional<ByteRange>> result =
GifExtractor().ExtractManifestStoreLocation(input,
{.requires_c2pa = false});
if (expected.ok()) {
EXPECT_THAT(result, IsOkAndHolds(
expected->asset_byte_info.manifest_store_location));
} else if (expected.status().code() == absl::StatusCode::kNotFound &&
expected.status().message() != "Multiple manifest stores found") {
EXPECT_THAT(result, IsOkAndHolds(std::nullopt));
} else {
EXPECT_THAT(result, StatusIs(expected.status().code(),
HasSubstr(expected.status().message())));
}
}
TEST_P(ExtractorTest, ExtractBoxes) {
const TestCase& param = GetParam();
const absl::StatusOr<ExtractorResult>& expected = param.result;
riegeli::StringReader<> input(param.contents);
absl::StatusOr<std::vector<AssetBox>> result =
GifExtractor().ExtractBoxes(input, {});
if (expected.ok()) {
EXPECT_THAT(result, IsOkAndHolds(expected->asset_byte_info.boxes));
} else {
EXPECT_THAT(result, StatusIs(expected.status().code(),
HasSubstr(expected.status().message())));
}
}
TEST(GifExtractorTest, FailsWhenSizeNotSupported) {
std::string contents = "test";
NoSizeStringReader input(contents);
GifExtractor extractor;
EXPECT_THAT(extractor.ExtractBoxes(input, {}),
StatusIs(absl::StatusCode::kInvalidArgument,
HasSubstr("Input does not support size")));
}
TEST(GifExtractorTest, FailsWhenSizeHasNoValue) {
std::string contents = "test";
SizeNotValuedStringReader input(contents);
GifExtractor extractor;
EXPECT_THAT(extractor.ExtractBoxes(input, {}),
StatusIs(absl::StatusCode::kInvalidArgument,
HasSubstr("Input does not support size")));
}
TEST(GifExtractorTest,
ExtractManifestStoreLocationStopsWhenBlockExceedsEndOffset) {
TestGifBuilder builder;
builder.AddGifHeader();
builder.AddLogicalDescriptor(/*has_color_table=*/false);
builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication,
std::string(kGifC2paIdentifier) + "___", "manifest_store");
std::string contents = builder.GetAsset();
riegeli::StringReader<> input(contents);
// LSD is at [6, 13). Set end_offset to 10.
// It should stop at LSD and return NotFoundError because C2PA (at 13) is not
// reached.
absl::StatusOr<std::optional<ByteRange>> result =
GifExtractor().ExtractManifestStoreLocation(input, {.end_offset = 10});
EXPECT_THAT(result, StatusIs(absl::StatusCode::kNotFound,
HasSubstr("No manifest store found")));
}
TEST(GifExtractorTest, ExtractBoxesFailsWhenBlockExceedsEndOffset) {
TestGifBuilder builder;
builder.AddGifHeader();
builder.AddLogicalDescriptor(/*has_color_table=*/false);
std::string contents = builder.GetAsset();
riegeli::StringReader<> input(contents);
// LSD is at [6, 13). Set end_offset to 10.
// It should fail with InvalidArgumentError because LSD extends past 10.
absl::StatusOr<std::vector<AssetBox>> result =
GifExtractor().ExtractBoxes(input, {.end_offset = 10});
EXPECT_THAT(
result,
StatusIs(absl::StatusCode::kInvalidArgument,
HasSubstr("Block extends past the declared end of the input")));
}
TEST(GifExtractorTest,
ExtractManifestStoreLocationIgnoresC2paAfterImageDescriptor) {
TestGifBuilder builder;
builder.AddGifHeader();
builder.AddLogicalDescriptor(/*has_color_table=*/false);
builder.AddImageDescriptor(/*has_color_table=*/false);
builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication,
std::string(kGifC2paIdentifier) + "___", "manifest_store");
std::string contents = builder.GetAsset();
riegeli::StringReader<> input(contents);
// It should stop at the image descriptor and not find the C2PA block.
absl::StatusOr<std::optional<ByteRange>> result =
GifExtractor().ExtractManifestStoreLocation(input, {});
EXPECT_THAT(result, StatusIs(absl::StatusCode::kNotFound,
HasSubstr("No manifest store found")));
}
class FakeGifExtractor : public GifExtractor {
public:
absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
riegeli::Reader& input, ExtractOptions options) const override {
if (fake_location_.has_value()) {
return fake_location_;
}
return GifExtractor::ExtractManifestStoreLocation(input, options);
}
void SetFakeLocation(ByteRange loc) { fake_location_ = loc; }
private:
mutable std::optional<ByteRange> fake_location_;
};
TEST(GifExtractorTest, ExtractManifestStoreFailsOnMalformedC2paData) {
std::string contents;
contents.resize(14, 'A'); // 14 bytes of padding
contents += '\005'; // block size 5
contents += "abc"; // but only 3 bytes
riegeli::StringReader<> input(contents);
FakeGifExtractor extractor;
extractor.SetFakeLocation(ByteRange{.offset = 0, .length = 18});
absl::StatusOr<std::string> result = extractor.ExtractManifestStore(input);
EXPECT_THAT(result,
StatusIs(absl::StatusCode::kInvalidArgument,
HasSubstr("Invalid block size in GIF C2PA data")));
}
TEST(GifExtractorTest, ExtractManifestStoreFailsOnOversizedData) {
std::string contents(10, 'A');
riegeli::StringReader<> input(contents);
FakeGifExtractor extractor;
extractor.SetFakeLocation(
ByteRange{.offset = 0, .length = 11 * 1024 * 1024 + 14});
absl::StatusOr<std::string> result = extractor.ExtractManifestStore(input);
EXPECT_THAT(result, StatusIs(absl::StatusCode::kInvalidArgument,
HasSubstr("Manifest store is too large")));
}
TEST(GifExtractorTest, MightBeC2paManifestStore) {
EXPECT_TRUE(GifExtractor().MightBeC2paManifestStore(
CreateStartOfManifestStorePayload()));
EXPECT_FALSE(GifExtractor().MightBeC2paManifestStore("not a manifest store"));
}
} // namespace
} // namespace credentio