| // Copyright 2026 Google LLC | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // https://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| // | |
| #include "formats/pdf/reader.h" | |
| #include <cstdint> | |
| #include <memory> | |
| #include <string> | |
| #include <utility> | |
| #include <vector> | |
| #include "absl/container/flat_hash_map.h" | |
| #include "absl/status/status.h" | |
| #include "absl/status/status_matchers.h" | |
| #include "absl/strings/str_replace.h" | |
| #include "absl/strings/string_view.h" | |
| #include "formats/pdf/objects.h" | |
| #include "gmock/gmock.h" | |
| #include "gtest/gtest.h" | |
| #include "riegeli/bytes/reader.h" | |
| #include "riegeli/bytes/string_reader.h" | |
| namespace credentio { | |
| namespace { | |
| using ::absl_testing::IsOkAndHolds; | |
| using ::absl_testing::StatusIs; | |
| using ::testing::ElementsAre; | |
| using ::testing::HasSubstr; | |
| using ::testing::Pair; | |
| using ::testing::UnorderedElementsAre; | |
| #ifndef ASSERT_OK_AND_ASSIGN | |
| #define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y | |
| #define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) | |
| #define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ | |
| ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) | |
| #define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ | |
| auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ | |
| ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ | |
| ::absl_testing::IsOk()); \ | |
| lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) | |
| #endif | |
| Object CreateName(absl::string_view value) { | |
| return Object{.value = Object::Name{.value = std::string(value)}}; | |
| } | |
| Object CreateInteger(int64_t value) { | |
| return Object{.value = Object::Integer{.value = value}}; | |
| } | |
| Object CreateRealNumber(absl::string_view value) { | |
| return Object{.value = Object::RealNumber{.raw_value = std::string(value)}}; | |
| } | |
| Object CreateIndirectRef(uint32_t object_number, uint32_t generation_number) { | |
| return Object{.value = Object::IndirectReference{ | |
| .object_number = object_number, | |
| .generation_number = generation_number}}; | |
| } | |
| Object CreateArray(std::vector<Object> values) { | |
| return Object{.value = Object::Array{.objects = std::move(values)}}; | |
| } | |
| Object CreateStream(absl::string_view raw_value) { | |
| return Object{.value = Object::Stream{.raw_value = std::string(raw_value)}}; | |
| } | |
| Object CreateDictionary(absl::flat_hash_map<std::string, Object> entries) { | |
| return Object{.value = Object::Dictionary{.entries = std::move(entries)}}; | |
| } | |
| std::string GetTestPdfContent() { | |
| std::string content = R"(%PDF-1.7 | |
| %%%%% | |
| 1 0 obj | |
| /test_name | |
| endobj | |
| 2 0 obj | |
| 123.456 | |
| endobj | |
| 3 0 obj | |
| <</Type /Catalog>> | |
| endobj | |
| xref | |
| 0 4 | |
| 0000000000 65535 f@ | |
| 0000000015 00000 n@ | |
| 0000000041 00000 n@ | |
| 0000000064 00000 n@ | |
| trailer | |
| <</Root 3 0 R /Size 3>> | |
| startxref | |
| 98 | |
| %%EOF)"; | |
| return absl::StrReplaceAll(content, {{"@", " "}}); | |
| } | |
| TEST(PdfReaderTest, ReadsAllObjects) { | |
| std::string content = GetTestPdfContent(); | |
| riegeli::StringReader<> input(content); | |
| ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); | |
| std::vector<IndirectObject> objects; | |
| while (reader->HasNext()) { | |
| ASSERT_OK_AND_ASSIGN(auto obj, reader->Next()); | |
| objects.push_back(std::move(obj)); | |
| } | |
| EXPECT_THAT( | |
| objects, | |
| ElementsAre(IndirectObject{.object_number = 1, | |
| .generation_number = 0, | |
| .object = CreateName("test_name")}, | |
| IndirectObject{.object_number = 2, | |
| .generation_number = 0, | |
| .object = CreateRealNumber("123.456")}, | |
| IndirectObject{.object_number = 3, | |
| .generation_number = 0, | |
| .object = CreateDictionary( | |
| {{"Type", CreateName("Catalog")}})})); | |
| } | |
| TEST(PdfReaderTest, FailsWhenReadingPastAllObjects) { | |
| std::string content = GetTestPdfContent(); | |
| riegeli::StringReader<> input(content); | |
| ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); | |
| int64_t count = 0; | |
| while (reader->HasNext()) { | |
| ASSERT_OK_AND_ASSIGN(auto obj, reader->Next()); | |
| ++count; | |
| } | |
| EXPECT_EQ(count, 3); | |
| EXPECT_THAT(reader->Next(), StatusIs(absl::StatusCode::kOutOfRange, | |
| HasSubstr("No more objects to read"))); | |
| } | |
| TEST(PdfReaderTest, GetFileTrailerDictionary) { | |
| std::string content = GetTestPdfContent(); | |
| riegeli::StringReader<> input(content); | |
| ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); | |
| auto file_trailer_dict = reader->file_trailer_dictionary(); | |
| EXPECT_THAT(file_trailer_dict.entries, | |
| UnorderedElementsAre(Pair("Root", CreateIndirectRef(3, 0)), | |
| Pair("Size", CreateInteger(3)))); | |
| } | |
| TEST(PdfReaderTest, FreeEntries) { | |
| std::string content = R"(%PDF-1.7 | |
| xref | |
| 0 8 | |
| 0000000003 65535 f@ | |
| 0000000015 00000 n@ | |
| 0000000081 00000 n@ | |
| 0000000005 00000 f@ | |
| 0000000331 00000 n@ | |
| 0000000000 00000 f@ | |
| 0000000409 00000 n@ | |
| 0000000555 00000 n@ | |
| trailer | |
| <<>> | |
| startxref | |
| 9 | |
| %%EOF)"; | |
| content = absl::StrReplaceAll(content, {{"@", " "}}); | |
| riegeli::StringReader<> input(content); | |
| ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); | |
| EXPECT_THAT(reader->free_entries(), | |
| ElementsAre(Object::IndirectReference{.object_number = 0, | |
| .generation_number = 65535}, | |
| Object::IndirectReference{.object_number = 3, | |
| .generation_number = 0}, | |
| Object::IndirectReference{.object_number = 5, | |
| .generation_number = 0})); | |
| } | |
| TEST(PdfReaderTest, GetObjectOffset) { | |
| std::string content = GetTestPdfContent(); | |
| riegeli::StringReader<> input(content); | |
| ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); | |
| EXPECT_THAT( | |
| reader->GetObjectOffset(/*object_number=*/2, /*generation_number=*/0), | |
| IsOkAndHolds(41)); | |
| } | |
| TEST(PdfReaderTest, GetNonExistentObjectOffsetFailure) { | |
| std::string content = GetTestPdfContent(); | |
| riegeli::StringReader<> input(content); | |
| ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); | |
| EXPECT_THAT( | |
| reader->GetObjectOffset(/*object_number=*/10, /*generation_number=*/0), | |
| StatusIs(absl::StatusCode::kNotFound, HasSubstr("No offset found"))); | |
| } | |
| TEST(PdfReaderTest, GetSpecificObject) { | |
| std::string content = GetTestPdfContent(); | |
| riegeli::StringReader<> input(content); | |
| ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); | |
| ASSERT_OK_AND_ASSIGN(auto obj, reader->GetObject(2, 0)); | |
| EXPECT_THAT(obj, (IndirectObject{.object_number = 2, | |
| .generation_number = 0, | |
| .object = CreateRealNumber("123.456")})); | |
| } | |
| TEST(PdfReaderTest, GetNonExistentObjectFailure) { | |
| std::string content = GetTestPdfContent(); | |
| riegeli::StringReader<> input(content); | |
| ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); | |
| EXPECT_THAT(reader->GetObject(10, 0), StatusIs(absl::StatusCode::kNotFound, | |
| HasSubstr("No offset found"))); | |
| } | |
| TEST(PdfReaderTest, ShortFileCausesInitializationFailure) { | |
| std::string content = "file is too short"; | |
| riegeli::StringReader<> input(content); | |
| EXPECT_THAT(PdfReader::Create(&input), | |
| StatusIs(absl::StatusCode::kDataLoss, | |
| HasSubstr("Failed to seek to offset"))); | |
| } | |
| TEST(PdfReaderTest, MissingXrefOffsetCausesInitializationFailure) { | |
| std::string content = R"(%PDF-1.7 | |
| xref | |
| 0 1 | |
| 0000000000 65536 f | |
| startxref | |
| %%EOF)"; | |
| riegeli::StringReader<> input(content); | |
| EXPECT_THAT(PdfReader::Create(&input), | |
| StatusIs(absl::StatusCode::kInvalidArgument, | |
| HasSubstr("Failed to find cross-reference table offset " | |
| "in the following payload"))); | |
| } | |
| TEST(PdfReaderTest, InvalidXrefOffsetCausesInitializationFailure) { | |
| std::string content = R"(%PDF-1.7 | |
| xref | |
| 0 1 | |
| 0000000000 65536 f | |
| startxref | |
| 2 | |
| %%EOF)"; | |
| riegeli::StringReader<> input(content); | |
| EXPECT_THAT( | |
| PdfReader::Create(&input), | |
| StatusIs( | |
| absl::StatusCode::kInvalidArgument, | |
| HasSubstr( | |
| "Malformed cross-reference table. Expected 'xref' but got"))); | |
| } | |
| TEST(PdfReaderTest, MalformedXrefTableCausesInitializationFailure) { | |
| std::string content = R"(%PDF-1.7 | |
| xref | |
| 0 2 | |
| 0000000000 65536 f | |
| 0000000 00001 n | |
| startxref | |
| 9 | |
| %%EOF)"; | |
| riegeli::StringReader<> input(content); | |
| EXPECT_THAT( | |
| PdfReader::Create(&input), | |
| StatusIs( | |
| absl::StatusCode::kInvalidArgument, | |
| HasSubstr( | |
| "Failed to parse cross-reference table entry. Invalid value"))); | |
| } | |
| TEST(PdfReaderTest, InvalidPrevXrefOffsetTypeCausesInitializationFailure) { | |
| std::string content = R"(%PDF-1.7 | |
| xref | |
| 0 1 | |
| 0000000000 65535 f@ | |
| trailer | |
| <</Prev /NotAnInteger /Size 1>> | |
| startxref | |
| 9 | |
| %%EOF)"; | |
| content = absl::StrReplaceAll(content, {{"@", " "}}); | |
| riegeli::StringReader<> input(content); | |
| EXPECT_THAT( | |
| PdfReader::Create(&input), | |
| StatusIs(absl::StatusCode::kInvalidArgument, | |
| HasSubstr("Object is not a credentio::Object::Integer"))); | |
| } | |
| TEST(PdfReaderTest, ContainingPreviousXrefTable) { | |
| std::string content = R"(%PDF-1.7 | |
| 1 0 obj | |
| << /Type /Catalog | |
| /Pages 2 0 R | |
| >> | |
| endobj | |
| 2 0 obj | |
| << /Type /Pages | |
| /Kids [3 0 R] | |
| /Count 1 | |
| >> | |
| endobj | |
| 3 0 obj | |
| << /Type /Page | |
| /Parent 2 0 R | |
| /MediaBox [0 0 100 100] | |
| /Contents 4 0 R | |
| >> | |
| endobj | |
| 4 0 obj | |
| << /Length 35 >> | |
| stream | |
| BT /F1 12 Tf 35 50 Td (Hello) Tj ET | |
| endstream | |
| endobj | |
| xref | |
| 0 5 | |
| 0000000000 65535 f@ | |
| 0000000009 00000 n@ | |
| 0000000058 00000 n@ | |
| 0000000115 00000 n@ | |
| 0000000202 00000 n@ | |
| trailer | |
| << /Size 5 | |
| /Root 1 0 R | |
| >> | |
| startxref | |
| 287 | |
| %%EOF | |
| 5 0 obj | |
| << /Type /Catalog | |
| /Pages 6 0 R | |
| >> | |
| endobj | |
| 6 0 obj | |
| << /Type /Pages | |
| /Kids [7 0 R 3 0 R] | |
| /Count 2 | |
| >> | |
| endobj | |
| 7 0 obj | |
| << /Type /Page | |
| /Parent 6 0 R | |
| /MediaBox [0 0 50 50] | |
| /Contents 8 0 R | |
| >> | |
| endobj | |
| 8 0 obj | |
| << /Length 35 >> | |
| stream | |
| BT /F1 12 Tf 10 20 Td (Hello) Tj ET | |
| endstream | |
| endobj | |
| xref | |
| 5 4 | |
| 0000000450 00000 n@ | |
| 0000000499 00000 n@ | |
| 0000000563 00000 n@ | |
| 0000000648 00000 n@ | |
| trailer | |
| << /Size 9 | |
| /Root 5 0 R | |
| /Prev 287 | |
| >> | |
| startxref | |
| 733 | |
| %%EOF)"; | |
| // Replace all @ with space to avoid presubmit failing trailing whitespace. | |
| content = absl::StrReplaceAll(content, {{"@", " "}}); | |
| riegeli::StringReader<> input(content); | |
| ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); | |
| std::vector<IndirectObject> objects; | |
| while (reader->HasNext()) { | |
| ASSERT_OK_AND_ASSIGN(auto obj, reader->Next()); | |
| objects.push_back(std::move(obj)); | |
| } | |
| ASSERT_THAT( | |
| objects, | |
| ElementsAre( | |
| IndirectObject{.object_number = 1, | |
| .generation_number = 0, | |
| .object = CreateDictionary({ | |
| {"Type", CreateName("Catalog")}, | |
| {"Pages", CreateIndirectRef(2, 0)}, | |
| })}, | |
| IndirectObject{.object_number = 2, | |
| .generation_number = 0, | |
| .object = CreateDictionary({ | |
| {"Kids", CreateArray({CreateIndirectRef(3, 0)})}, | |
| {"Type", CreateName("Pages")}, | |
| {"Count", CreateInteger(1)}, | |
| })}, | |
| IndirectObject{.object_number = 3, | |
| .generation_number = 0, | |
| .object = CreateDictionary({ | |
| {"Parent", CreateIndirectRef(2, 0)}, | |
| {"Type", CreateName("Page")}, | |
| {"MediaBox", CreateArray({ | |
| CreateInteger(0), | |
| CreateInteger(0), | |
| CreateInteger(100), | |
| CreateInteger(100), | |
| })}, | |
| {"Contents", CreateIndirectRef(4, 0)}, | |
| })}, | |
| IndirectObject{ | |
| .object_number = 4, | |
| .generation_number = 0, | |
| .stream_dictionary = | |
| Object::Dictionary{ | |
| .entries = {{"Length", CreateInteger(35)}}}, | |
| .object = CreateStream("BT /F1 12 Tf 35 50 Td (Hello) Tj ET")}, | |
| IndirectObject{.object_number = 5, | |
| .generation_number = 0, | |
| .object = CreateDictionary({ | |
| {"Type", CreateName("Catalog")}, | |
| {"Pages", CreateIndirectRef(6, 0)}, | |
| })}, | |
| IndirectObject{.object_number = 6, | |
| .generation_number = 0, | |
| .object = CreateDictionary({ | |
| {"Kids", CreateArray({CreateIndirectRef(7, 0), | |
| CreateIndirectRef(3, 0)})}, | |
| {"Type", CreateName("Pages")}, | |
| {"Count", CreateInteger(2)}, | |
| })}, | |
| IndirectObject{.object_number = 7, | |
| .generation_number = 0, | |
| .object = CreateDictionary({ | |
| {"Parent", CreateIndirectRef(6, 0)}, | |
| {"Type", CreateName("Page")}, | |
| {"MediaBox", CreateArray({ | |
| CreateInteger(0), | |
| CreateInteger(0), | |
| CreateInteger(50), | |
| CreateInteger(50), | |
| })}, | |
| {"Contents", CreateIndirectRef(8, 0)}, | |
| })}, | |
| IndirectObject{ | |
| .object_number = 8, | |
| .generation_number = 0, | |
| .stream_dictionary = | |
| Object::Dictionary{ | |
| .entries = {{"Length", CreateInteger(35)}}}, | |
| .object = CreateStream("BT /F1 12 Tf 10 20 Td (Hello) Tj ET")})); | |
| } | |
| riegeli::Reader* GetNullReader() { return nullptr; } | |
| TEST(PdfReaderTest, NullInputFailure) | |
| __attribute__((no_sanitize("nullability"))) { | |
| EXPECT_THAT(PdfReader::Create(GetNullReader()), | |
| StatusIs(absl::StatusCode::kInvalidArgument, | |
| HasSubstr("input cannot be null"))); | |
| } | |
| TEST(PdfReaderTest, CircularXrefTableFailure) { | |
| std::string content = R"(%PDF-1.7 | |
| 1 0 obj | |
| << /Type /Catalog | |
| /Pages 2 0 R | |
| >> | |
| endobj | |
| 2 0 obj | |
| << /Type /Pages | |
| /Kids [3 0 R] | |
| /Count 1 | |
| >> | |
| endobj | |
| 3 0 obj | |
| << /Type /Page | |
| /Parent 2 0 R | |
| /MediaBox [0 0 100 100] | |
| /Contents 4 0 R | |
| >> | |
| endobj | |
| 4 0 obj | |
| << /Length 35 >> | |
| stream | |
| BT /F1 12 Tf 35 50 Td (Hello) Tj ET | |
| endstream | |
| endobj | |
| xref | |
| 0 5 | |
| 0000000000 65535 f@ | |
| 0000000009 00000 n@ | |
| 0000000058 00000 n@ | |
| 0000000115 00000 n@ | |
| 0000000202 00000 n@ | |
| trailer | |
| << /Size 5 | |
| /Root 1 0 R | |
| /Prev 743 | |
| >> | |
| startxref | |
| 287 | |
| %%EOF | |
| 5 0 obj | |
| << /Type /Catalog | |
| /Pages 6 0 R | |
| >> | |
| endobj | |
| 6 0 obj | |
| << /Type /Pages | |
| /Kids [7 0 R 3 0 R] | |
| /Count 2 | |
| >> | |
| endobj | |
| 7 0 obj | |
| << /Type /Page | |
| /Parent 6 0 R | |
| /MediaBox [0 0 50 50] | |
| /Contents 8 0 R | |
| >> | |
| endobj | |
| 8 0 obj | |
| << /Length 35 >> | |
| stream | |
| BT /F1 12 Tf 10 20 Td (Hello) Tj ET | |
| endstream | |
| endobj | |
| xref | |
| 5 4 | |
| 0000000460 00000 n@ | |
| 0000000509 00000 n@ | |
| 0000000573 00000 n@ | |
| 0000000658 00000 n@ | |
| trailer | |
| << /Size 9 | |
| /Root 5 0 R | |
| /Prev 287 | |
| >> | |
| startxref | |
| 743 | |
| %%EOF)"; | |
| content = absl::StrReplaceAll(content, {{"@", " "}}); | |
| riegeli::StringReader<> input(content); | |
| EXPECT_THAT(PdfReader::Create(&input), | |
| StatusIs(absl::StatusCode::kInvalidArgument, | |
| HasSubstr("Circular cross-reference table detected"))); | |
| } | |
| TEST(PdfReaderTest, CollisionInUseEntriesFailure) { | |
| std::string content = R"(%PDF-1.7 | |
| 1 0 obj | |
| << /Type /Catalog | |
| /Pages 2 0 R | |
| >> | |
| endobj | |
| 2 0 obj | |
| << /Type /Pages | |
| /Kids [3 0 R] | |
| /Count 1 | |
| >> | |
| endobj | |
| 3 0 obj | |
| << /Type /Page | |
| /Parent 2 0 R | |
| /MediaBox [0 0 100 100] | |
| /Contents 4 0 R | |
| >> | |
| endobj | |
| 4 0 obj | |
| << /Length 35 >> | |
| stream | |
| BT /F1 12 Tf 35 50 Td (Hello) Tj ET | |
| endstream | |
| endobj | |
| xref | |
| 0 5 | |
| 0000000000 65535 f@ | |
| 0000000009 00000 n@ | |
| 0000000058 00000 n@ | |
| 0000000115 00000 n@ | |
| 0000000202 00000 n@ | |
| trailer | |
| << /Size 5 | |
| /Root 1 0 R | |
| >> | |
| startxref | |
| 287 | |
| %%EOF | |
| 5 0 obj | |
| << /Type /Catalog | |
| /Pages 6 0 R | |
| >> | |
| endobj | |
| 6 0 obj | |
| << /Type /Pages | |
| /Kids [7 0 R 3 0 R] | |
| /Count 2 | |
| >> | |
| endobj | |
| 7 0 obj | |
| << /Type /Page | |
| /Parent 6 0 R | |
| /MediaBox [0 0 50 50] | |
| /Contents 8 0 R | |
| >> | |
| endobj | |
| 8 0 obj | |
| << /Length 35 >> | |
| stream | |
| BT /F1 12 Tf 10 20 Td (Hello) Tj ET | |
| endstream | |
| endobj | |
| xref | |
| 1 1 | |
| 0000000450 00000 n@ | |
| 5 4 | |
| 0000000450 00000 n@ | |
| 0000000499 00000 n@ | |
| 0000000563 00000 n@ | |
| 0000000648 00000 n@ | |
| trailer | |
| << /Size 9 | |
| /Root 5 0 R | |
| /Prev 287 | |
| >> | |
| startxref | |
| 733 | |
| %%EOF)"; | |
| content = absl::StrReplaceAll(content, {{"@", " "}}); | |
| riegeli::StringReader<> input(content); | |
| EXPECT_THAT(PdfReader::Create(&input), | |
| StatusIs(absl::StatusCode::kInvalidArgument, | |
| HasSubstr("Collision detected in cross-reference table " | |
| "for in-use entries"))); | |
| } | |
| TEST(PdfReaderTest, CyclicStreamLengthFailure) { | |
| std::string content = R"(%PDF-1.7 | |
| %%%%% | |
| 1 0 obj | |
| << /Length 1 0 R >> | |
| stream | |
| abc | |
| endstream | |
| endobj | |
| xref | |
| 0 2 | |
| 0000000000 65535 f@ | |
| 0000000015 00000 n@ | |
| trailer | |
| << /Size 2 >> | |
| startxref | |
| 71 | |
| %%EOF)"; | |
| content = absl::StrReplaceAll(content, {{"@", " "}}); | |
| riegeli::StringReader<> input(content); | |
| ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); | |
| EXPECT_THAT(reader->GetObject(1, 0), | |
| StatusIs(absl::StatusCode::kInvalidArgument, | |
| HasSubstr("Circular reference detected"))); | |
| } | |
| } // namespace | |
| } // namespace credentio |