| // 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 |