From 8644cdb2c205b5195810a36d2ccb2b2820cc83c1 Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Tue, 20 Oct 2026 11:06:11 +0300 Subject: [PATCH 1/3] Harden captures and outline traversal Bind captures to a known base-file hash, reject invalid object types on import, and preserve inherited page attributes when restoring pages. Validate outline nodes and bound traversal depth to keep malformed outline links from being read or rewritten as bookmarks. --- core/fpdfapi/parser/cpdf_base_document.h | 5 + core/fpdfdoc/cpdf_bookmarktree.cpp | 44 ++++- core/fpdfdoc/cpdf_bookmarktree.h | 9 + fpdfsdk/epdf_capture.cpp | 166 +++++++++++++++--- fpdfsdk/epdf_capture_embeddertest.cpp | 205 ++++++++++++++++++++++- fpdfsdk/epdf_outline_embeddertest.cpp | 196 ++++++++++++++++++++-- fpdfsdk/epdf_outline_helpers.cpp | 66 +++++--- fpdfsdk/epdf_outline_helpers.h | 15 +- public/epdf_capture.h | 39 +++-- public/epdf_outline.h | 11 +- 10 files changed, 664 insertions(+), 92 deletions(-) diff --git a/core/fpdfapi/parser/cpdf_base_document.h b/core/fpdfapi/parser/cpdf_base_document.h index 6376a4bb7e..8f9d4a9b30 100644 --- a/core/fpdfapi/parser/cpdf_base_document.h +++ b/core/fpdfapi/parser/cpdf_base_document.h @@ -47,6 +47,11 @@ class CPDF_BaseDocument final : public CPDF_Document, public Retainable { // full pass over the file — unless the embedder supplied it through // SetKnownRawBaseSha256(). Reads all-zero when the file cannot be read. const std::array& GetRawBaseSha256() const; + // The same hash when it is known already (supplied, or worked out once), + // else null: never hashes the file. + const std::array* GetKnownRawBaseSha256() const { + return raw_base_sha256_ ? &*raw_base_sha256_ : nullptr; + } // A host that already hashed the exact bytes it handed us (a storage layer // verifying a download) need not pay for a second pass. Only an identity // claim: a wrong value makes this host's own artifacts fail to open on the diff --git a/core/fpdfdoc/cpdf_bookmarktree.cpp b/core/fpdfdoc/cpdf_bookmarktree.cpp index ae34e6396b..996b38e108 100644 --- a/core/fpdfdoc/cpdf_bookmarktree.cpp +++ b/core/fpdfdoc/cpdf_bookmarktree.cpp @@ -17,11 +17,44 @@ CPDF_BookmarkTree::CPDF_BookmarkTree(const CPDF_Document* doc) CPDF_BookmarkTree::~CPDF_BookmarkTree() = default; +// EmbedPDF(bug-fix): see the declaration. An item has no /Type, and the +// outline's dictionary may say /Outlines; the other keys belong to pages and +// the page tree (/Kids /Contents /MediaBox /Resources), annotations and +// fields (/Subtype /FT) and actions (/S, whose /Next chains actions). +// static +bool CPDF_BookmarkTree::IsOutlineNode(const CPDF_Dictionary* dict) { + if (!dict) { + return false; + } + if (dict->KeyExist("Type") && dict->GetNameFor("Type") != "Outlines") { + return false; + } + for (const char* key : + {"Subtype", "FT", "S", "Kids", "Contents", "MediaBox", "Resources"}) { + if (dict->KeyExist(key)) { + return false; + } + } + return true; +} + +namespace { + +// EmbedPDF(bug-fix): |dict| as a bookmark when IsOutlineNode() takes it. +CPDF_Bookmark OutlineItem(RetainPtr dict) { + return CPDF_BookmarkTree::IsOutlineNode(dict.Get()) + ? CPDF_Bookmark(std::move(dict)) + : CPDF_Bookmark(); +} + +} // namespace + CPDF_Bookmark CPDF_BookmarkTree::GetFirstChild( const CPDF_Bookmark& parent) const { const CPDF_Dictionary* parent_dict = parent.GetDict(); if (parent_dict) { - return CPDF_Bookmark(parent_dict->GetDictFor("First")); + // EmbedPDF(bug-fix): only an item IsOutlineNode() takes. + return OutlineItem(parent_dict->GetDictFor("First")); } const CPDF_Dictionary* root = document_->GetRoot(); @@ -31,8 +64,10 @@ CPDF_Bookmark CPDF_BookmarkTree::GetFirstChild( RetainPtr outlines = root->GetDictFor(pdfium::catalog::kOutlines); - return outlines ? CPDF_Bookmark(outlines->GetDictFor("First")) - : CPDF_Bookmark(); + // EmbedPDF(bug-fix): only an outline and item IsOutlineNode() takes. + return IsOutlineNode(outlines.Get()) + ? OutlineItem(outlines->GetDictFor("First")) + : CPDF_Bookmark(); } CPDF_Bookmark CPDF_BookmarkTree::GetNextSibling( @@ -43,5 +78,6 @@ CPDF_Bookmark CPDF_BookmarkTree::GetNextSibling( } RetainPtr next = dict->GetDictFor("Next"); - return next != dict ? CPDF_Bookmark(std::move(next)) : CPDF_Bookmark(); + // EmbedPDF(bug-fix): only an item IsOutlineNode() takes. + return next != dict ? OutlineItem(std::move(next)) : CPDF_Bookmark(); } diff --git a/core/fpdfdoc/cpdf_bookmarktree.h b/core/fpdfdoc/cpdf_bookmarktree.h index 144879bdf1..866a6ac0e2 100644 --- a/core/fpdfdoc/cpdf_bookmarktree.h +++ b/core/fpdfdoc/cpdf_bookmarktree.h @@ -10,6 +10,8 @@ #include "core/fpdfdoc/cpdf_bookmark.h" #include "core/fxcrt/unowned_ptr.h" +// EmbedPDF(bug-fix): IsOutlineNode() below reads one. +class CPDF_Dictionary; class CPDF_Document; class CPDF_BookmarkTree { @@ -17,6 +19,13 @@ class CPDF_BookmarkTree { explicit CPDF_BookmarkTree(const CPDF_Document* doc); ~CPDF_BookmarkTree(); + // EmbedPDF(bug-fix): upstream took any dictionary that /Outlines, /First or + // /Next reaches as part of the outline, so a broken file that links a page, + // a field or an action there showed it as a bookmark, and an outline + // writer rewrote its links. Whether |dict| can be the outline's dictionary + // or one of its items: it has no key that gives a dictionary another role. + static bool IsOutlineNode(const CPDF_Dictionary* dict); + CPDF_Bookmark GetFirstChild(const CPDF_Bookmark& parent) const; CPDF_Bookmark GetNextSibling(const CPDF_Bookmark& bookmark) const; diff --git a/fpdfsdk/epdf_capture.cpp b/fpdfsdk/epdf_capture.cpp index 467098186c..d38acf8516 100644 --- a/fpdfsdk/epdf_capture.cpp +++ b/fpdfsdk/epdf_capture.cpp @@ -14,6 +14,7 @@ #include "core/fpdfapi/edit/cpdf_stringarchivestream.h" #include "core/fpdfapi/parser/cpdf_array.h" +#include "core/fpdfapi/parser/cpdf_base_document.h" #include "core/fpdfapi/parser/cpdf_dictionary.h" #include "core/fpdfapi/parser/cpdf_document_view_scope.h" #include "core/fpdfapi/parser/cpdf_layer_document.h" @@ -23,6 +24,7 @@ #include "core/fpdfapi/parser/cpdf_stream.h" #include "core/fpdfapi/parser/cpdf_string.h" #include "core/fpdfapi/parser/cpdf_syntax_parser.h" +#include "core/fpdfdoc/cpdf_bookmarktree.h" #include "core/fxcrt/bytestring.h" #include "core/fxcrt/cfx_read_only_span_stream.h" #include "core/fxcrt/compiler_specific.h" @@ -60,6 +62,44 @@ bool IsOutlineLinkKey(ByteStringView key) { key == "SE"; } +// The keys a page inherits from the page tree above it (ISO 32000-2 +// 7.7.3.4). +constexpr const char* kInheritableKeys[] = {"Resources", "MediaBox", "CropBox", + "Rotate"}; + +bool IsInheritableKey(ByteStringView key) { + return std::ranges::any_of(kInheritableKeys, [key](const char* inheritable) { + return key == inheritable; + }); +} + +// The value the nearest /Pages node above |page| gives the inheritable |key|, +// as that node holds it (a reference stays one), or null when none does. +RetainPtr InheritedValueOf(const CPDF_Dictionary* page, + const char* key) { + std::set seen; + for (RetainPtr node = page->GetDictFor("Parent"); + node && seen.insert(node.Get()).second; + node = node->GetDictFor("Parent")) { + if (RetainPtr value = node->GetObjectFor(key)) { + return value; + } + } + return nullptr; +} + +// |value| as PDF syntax, which tells two values apart: a reference reads as +// its object number, anything else as what it holds. +DataVector SyntaxOf(const CPDF_Object* value) { + fxcrt::ostringstream text; + CPDF_StringArchiveStream archive(&text); + if (!value || !value->WriteTo(&archive, nullptr)) { + return {}; + } + const auto bytes = text.str(); + return DataVector(bytes.begin(), bytes.end()); +} + // Objects a capture never holds: the catalog, the page tree and its pages, // save the page a page capture is of (CaptureWriter::AddPage()). bool IsBoundaryObject(const CPDF_Object* object) { @@ -77,6 +117,39 @@ CPDF_LayerDocument* LayerOf(FPDF_DOCUMENT document) { CPDFDocumentFromFPDFDocument(document)); } +ByteString HexOf(const std::array& sha256) { + static constexpr char kHexDigits[] = "0123456789abcdef"; + ByteString hex; + for (uint8_t byte : sha256) { + hex += kHexDigits[byte >> 4]; + hex += kHexDigits[byte & 0xf]; + } + return hex; +} + +// A capture's manifest: its |kind|, and the file it was taken on (Base: the +// SHA-256 of the layer's base, in hex) when the layer knows it already, so +// taking a capture never costs hashing the file. A host that keeps captures +// beyond a session supplies the hash (EPDF_SetBaseDocumentSha256()). +RetainPtr NewManifest(CPDF_LayerDocument* layer, + const char* kind) { + auto manifest = pdfium::MakeRetain(); + manifest->SetNewFor("Kind", kind); + if (const std::array* sha256 = + layer->GetBaseDocument()->GetKnownRawBaseSha256()) { + manifest->SetNewFor("Base", HexOf(*sha256)); + } + return manifest; +} + +// Whether |manifest| comes from |layer|'s file, as far as it says: one that +// names a file comes back only into a layer of that file. +bool IsOfFile(const CPDF_Dictionary* manifest, CPDF_LayerDocument* layer) { + const ByteString base = manifest->GetByteStringFor("Base"); + return base.IsEmpty() || + base == HexOf(layer->GetBaseDocument()->GetRawBaseSha256()); +} + // The object numbers |array| refers to, in order (0 for a direct entry). std::vector RefsIn(const CPDF_Array* array) { std::vector refs; @@ -276,8 +349,10 @@ struct Capture { }; // Reads |data| into objects |layer| holds, or nullopt when it is not a -// whole, well-formed capture of kind |kind|, or names a number above the -// layer's last one. Nothing is written. +// whole, well-formed capture of kind |kind|, names a file other than +// |layer|'s or a number above the layer's last one, or holds an object no +// capture holds (the catalog, the page tree, a page other than the one a +// page capture is of). Nothing is written. std::optional ReadCapture(CPDF_LayerDocument* layer, pdfium::span data, ByteStringView kind) { @@ -290,9 +365,12 @@ std::optional ReadCapture(CPDF_LayerDocument* layer, parser.SetPos(header.GetLength()); Capture capture; capture.manifest = ToDictionary(parser.GetObjectBody(layer)); - if (!capture.manifest || capture.manifest->GetNameFor("Kind") != kind) { + if (!capture.manifest || capture.manifest->GetNameFor("Kind") != kind || + !IsOfFile(capture.manifest.Get(), layer)) { return std::nullopt; } + const int own_page = + kind == "Page" ? capture.manifest->GetIntegerFor("Page") : 0; while (true) { parser.ToNextWord(); if (parser.GetPos() >= parser.GetDocumentSize()) { @@ -305,7 +383,9 @@ std::optional ReadCapture(CPDF_LayerDocument* layer, } const uint32_t objnum = object->GetObjNum(); if (!objnum || objnum > layer->GetLastObjNum() || - capture.objects.contains(objnum)) { + capture.objects.contains(objnum) || + (IsBoundaryObject(object.Get()) && + objnum != static_cast(own_page))) { return std::nullopt; } // A stream read from the capture keeps its bytes in memory, as stored. @@ -412,8 +492,7 @@ EPDFPage_ExportAnnotsRawToOwnedBuffer(FPDF_DOCUMENT document, } const std::vector refs = RefsIn(annots.Get()); - auto manifest = pdfium::MakeRetain(); - manifest->SetNewFor("Kind", "Annots"); + RetainPtr manifest = NewManifest(layer, "Annots"); manifest->SetNewFor( "Page", pdfium::checked_cast(page->GetObjNum())); auto members = manifest->SetNewFor("Members"); @@ -506,8 +585,7 @@ EPDFDoc_ExportDictRawToOwnedBuffer(FPDF_DOCUMENT document, if (!dict) { return nullptr; } - auto manifest = pdfium::MakeRetain(); - manifest->SetNewFor("Kind", "Dict"); + RetainPtr manifest = NewManifest(layer, "Dict"); manifest->SetNewFor("Obj", pdfium::checked_cast(objnum)); manifest->SetNewFor( "Gen", pdfium::checked_cast(dict->GetGenNum())); @@ -614,8 +692,7 @@ EPDFForm_ExportFieldRawToOwnedBuffer(FPDF_DOCUMENT document, } } - auto manifest = pdfium::MakeRetain(); - manifest->SetNewFor("Kind", "Field"); + RetainPtr manifest = NewManifest(layer, "Field"); manifest->SetNewFor("Field", pdfium::checked_cast(field_objnum)); CaptureWriter writer(layer); @@ -795,8 +872,7 @@ EPDFOutline_ExportItemRawToOwnedBuffer(FPDF_DOCUMENT document, // The item, where it was (In: its parent, 0 for the top level; After: the // sibling it followed, 0 when it was the first), and the items under it. - auto manifest = pdfium::MakeRetain(); - manifest->SetNewFor("Kind", "OutlineItem"); + RetainPtr manifest = NewManifest(layer, "OutlineItem"); manifest->SetNewFor("Item", pdfium::checked_cast(objnum)); manifest->SetNewFor( "In", parent == *root ? 0 : pdfium::checked_cast(parent)); @@ -842,10 +918,15 @@ EPDFOutline_ImportItemRaw(FPDF_DOCUMENT document, if (epdf::IsOutlineItem(layer, *root, item)) { return false; } - // It comes back from the capture, or as the version the layer reads now - // (the file's original, when the layer never edited it). - if (!read->objects.contains(item) && - !ToDictionary(layer->GetOrParseIndirectObject(item))) { + // What the number holds once the capture is in, which must be an outline + // item: the layer's own version stays (InstallObjects()), else the + // capture's comes back, else the file's original. + auto captured = read->objects.find(static_cast(item)); + RetainPtr comes_back = + layer->FindLayerVersion(item) || captured == read->objects.end() + ? layer->GetOrParseIndirectObject(item) + : captured->second; + if (!CPDF_BookmarkTree::IsOutlineNode(ToDictionary(comes_back).Get())) { return false; } std::vector children; @@ -902,8 +983,7 @@ EPDFPage_ExportRawToOwnedBuffer(FPDF_DOCUMENT document, // The page, where it was (After: the page it followed, 0 when it was the // first), and the keys that name it. - auto manifest = pdfium::MakeRetain(); - manifest->SetNewFor("Kind", "Page"); + RetainPtr manifest = NewManifest(layer, "Page"); manifest->SetNewFor("Page", pdfium::checked_cast(page_objnum)); manifest->SetNewFor( @@ -913,6 +993,19 @@ EPDFPage_ExportRawToOwnedBuffer(FPDF_DOCUMENT document, names->AppendNew(key.AsStringView()); } CaptureWriter writer(layer); + // What it takes from the page tree above it (Inherited): an import may put + // it under another /Pages node, so it brings these back as its own. + RetainPtr page = layer->GetPageDictionary(page_index); + auto inherited = manifest->SetNewFor("Inherited"); + for (const char* key : kInheritableKeys) { + RetainPtr value = page && !page->KeyExist(key) + ? InheritedValueOf(page.Get(), key) + : nullptr; + if (value) { + inherited->SetFor(key, value->Clone()); + writer.AddReachOf(value.Get()); + } + } writer.AddPage(page_objnum); return ToOwnedBuffer(writer.Write(manifest.Get()), out_size); } @@ -949,10 +1042,17 @@ EPDFPage_ImportRaw(FPDF_DOCUMENT document, // file's original, when the layer never edited it). auto captured = read->objects.find(page); const bool in_capture = captured != read->objects.end(); + // What the number holds once the capture is in: the layer's own version + // stays (InstallObjects()), save the null a deleted layer page left, which + // the capture's page replaces; without a version of the layer's, the + // capture's, or the file's original. + RetainPtr held = layer->FindLayerVersion(page); + const bool over_null = held && held->IsNull(); RetainPtr reads_now = ToDictionary(layer->GetOrParseIndirectObject(page)); - const CPDF_Dictionary* comes_back = - in_capture ? captured->second->AsDictionary() : reads_now.Get(); + const CPDF_Dictionary* comes_back = in_capture && (!held || over_null) + ? captured->second->AsDictionary() + : reads_now.Get(); if (!comes_back || comes_back->GetNameFor("Type") != "Page") { return false; } @@ -965,20 +1065,34 @@ EPDFPage_ImportRaw(FPDF_DOCUMENT document, // A page the layer made reads as null once deleted (SetPageToNullObject(), // so nothing that still points at it saves it): it comes back over that // null. - if (in_capture) { - RetainPtr held = layer->FindLayerVersion(page); - if (held && held->IsNull()) { - layer->DeleteIndirectObject(page); - } + if (in_capture && over_null) { + layer->DeleteIndirectObject(page); } if (!InstallObjects(layer, *read)) { return false; } RetainPtr page_dict = ToDictionary(layer->GetMutableIndirectObject(page)); - if (!page_dict || !layer->InsertExistingPage(slot, std::move(page_dict))) { + if (!page_dict || !layer->InsertExistingPage(slot, page_dict)) { return false; } + // What it took from the page tree where it was, as its own key where its + // place now gives otherwise; back under the same node, nothing changes. + if (RetainPtr inherited = + read->manifest->GetDictFor("Inherited")) { + CPDF_DictionaryLocker locker(inherited); + for (const auto& [key, value] : locker) { + if (!IsInheritableKey(key.AsStringView()) || + page_dict->KeyExist(key.AsStringView())) { + continue; + } + RetainPtr now = + InheritedValueOf(page_dict.Get(), key.c_str()); + if (SyntaxOf(now.Get()) != SyntaxOf(value.Get())) { + page_dict->SetFor(key, value->Clone()); + } + } + } // A key another page holds now stays with it. RetainPtr names = read->manifest->GetArrayFor("Names"); for (size_t i = 0; names && i < names->size(); ++i) { diff --git a/fpdfsdk/epdf_capture_embeddertest.cpp b/fpdfsdk/epdf_capture_embeddertest.cpp index 2d25974638..667d2fcf34 100644 --- a/fpdfsdk/epdf_capture_embeddertest.cpp +++ b/fpdfsdk/epdf_capture_embeddertest.cpp @@ -35,11 +35,13 @@ #include "public/epdf_form.h" #include "public/epdf_layer.h" #include "public/epdf_named_pages.h" +#include "public/epdf_outline.h" #include "public/epdf_save.h" #include "public/epdf_view.h" #include "public/fpdf_annot.h" #include "public/fpdf_edit.h" #include "public/fpdf_save.h" +#include "public/fpdf_transformpage.h" #include "public/fpdfview.h" #include "testing/embedder_test.h" #include "testing/fx_string_testhelpers.h" @@ -608,6 +610,32 @@ TEST_F(EPDFCaptureEmbedderTest, ImportRefusesWhatDoesNotFit) { ASSERT_TRUE(EPDFLayer_AbortTransaction(fresh.get())); } +// A capture never holds the catalog, the page tree or a page it isn't of: +// one that does (here an annotation capture with a catalog put in) is +// refused whole, and nothing of it is put in. +TEST_F(EPDFCaptureEmbedderTest, ImportRefusesACaptureHoldingTheCatalog) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + ASSERT_TRUE(CreateSquare(layer.get(), 1, 20)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + std::vector capture = ExportAnnots(layer.get(), 1, {0}); + ASSERT_FALSE(capture.empty()); + ASSERT_TRUE(EPDFPage_RemoveAnnotRaw(layer.get(), 1, 0)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(layer.get()); + const std::string catalog = ObjectGraph(pdf).at(1); + const std::string forged = "1 0 obj\n<< /Type /Catalog >>\nendobj\n"; + capture.insert(capture.end(), forged.begin(), forged.end()); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + EXPECT_FALSE(ImportAnnots(layer.get(), 1, capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_EQ(catalog, ObjectGraph(pdf).at(1)); + EXPECT_TRUE(Annots(layer.get(), 1).empty()); +} + // A dictionary captured before an update comes back exactly: every key as it // was, none added, and the appearance under a deep key with its bytes, also // after a save and reopen dropped the old appearance. @@ -1054,13 +1082,186 @@ TEST_F(EPDFPageCaptureEmbedderTest, ImportLeavesAKeyAnotherPageTook) { EXPECT_THAT(NamedPages(layer.get()), ElementsAre(L"Cover=3", L"Intro=5")); } -// A capture holds its page, never another: the cover links to the body, -// which the layer turned, and the page tree is its parent. +// A page comes back with what it took from the page tree, as its own. The +// page tree has two branches of different size, turn and resources; the +// last page of the first branch comes back at its place, which is the start +// of the second branch now, and still reads as the first branch made it. +TEST_F(EPDFPageCaptureEmbedderTest, PageKeepsWhatItInheritedFromItsBranch) { + EPDF_ReleaseBaseDocument(base_); + input_ = MakePdf({ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 4 >>", + "<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 " + "/MediaBox [0 0 200 100] /Rotate 90 /Resources 9 0 R >>", + "<< /Type /Pages /Parent 2 0 R /Kids [7 0 R 8 0 R] /Count 2 " + "/MediaBox [0 0 500 500] /Resources << >> >>", + "<< /Type /Page /Parent 3 0 R >>", + "<< /Type /Page /Parent 3 0 R >>", + "<< /Type /Page /Parent 4 0 R >>", + "<< /Type /Page /Parent 4 0 R >>", + "<< /ProcSet [/PDF] >>", + }); + base_ = EPDF_LoadMemBaseDocument(input_.data(), input_.size(), nullptr); + ASSERT_TRUE(base_); + for (bool reopen : {false, true}) { + SCOPED_TRACE(reopen ? "reopened between delete and import" + : "same session"); + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const std::vector capture = ExportPage(layer.get(), 6); + ASSERT_FALSE(capture.empty()); + ASSERT_TRUE(EPDFDoc_DeletePageByObjectNumber(layer.get(), 6)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_THAT(PageOrder(layer.get()), ElementsAre(5, 7, 8)); + + ScopedFPDFDocument target = reopen ? Reopen(layer.get()) : std::move(layer); + ASSERT_TRUE(target); + ASSERT_TRUE(EPDFLayer_BeginTransaction(target.get())); + ASSERT_TRUE(ImportPage(target.get(), capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(target.get())); + EXPECT_THAT(PageOrder(target.get()), ElementsAre(5, 6, 7, 8)); + EXPECT_EQ(4u, RefOf(target.get(), 6, "Parent")); // the other branch + EXPECT_EQ(9u, RefOf(target.get(), 6, "Resources")); + ScopedFPDFPage page(FPDF_LoadPage(target.get(), 1)); + ASSERT_TRUE(page); + EXPECT_EQ(1, FPDFPage_GetRotation(page.get())); + float left = 0; + float bottom = 0; + float right = 0; + float top = 0; + ASSERT_TRUE(FPDFPage_GetMediaBox(page.get(), &left, &bottom, &right, &top)); + EXPECT_EQ(200, right); + EXPECT_EQ(100, top); + // Turned a quarter, as the first branch turns its pages. + EXPECT_EQ(100, FPDF_GetPageWidthF(page.get())); + EXPECT_EQ(200, FPDF_GetPageHeightF(page.get())); + } +} + +// A capture names the file it was taken on when the layer knows the file's +// hash, and never makes the layer work it out. +TEST_F(EPDFPageCaptureEmbedderTest, CaptureNamesItsFileWhenTheHashIsKnown) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + const std::vector unnamed = ExportPage(layer.get(), kIntro); + ASSERT_FALSE(unnamed.empty()); + EXPECT_EQ(std::string::npos, + std::string(unnamed.begin(), unnamed.end()).find("/Base")); + unsigned char sha256[32] = {}; + ASSERT_TRUE(EPDFLayer_GetBaseSha256(layer.get(), sha256)); + const std::vector named = ExportPage(layer.get(), kIntro); + EXPECT_NE(std::string::npos, + std::string(named.begin(), named.end()).find("/Base")); + // Both come back into a layer of this file. + for (const std::vector* capture : {&unnamed, &named}) { + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + ASSERT_TRUE(EPDFDoc_DeletePageByObjectNumber(layer.get(), kIntro)); + EXPECT_TRUE(ImportPage(layer.get(), *capture)); + ASSERT_TRUE(EPDFLayer_AbortTransaction(layer.get())); + } +} + +// A capture that names its file comes back only into a layer of that file: +// in another file the same numbers are other objects. +TEST_F(EPDFPageCaptureEmbedderTest, ImportRefusesACaptureOfAnotherFile) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + // The layer knows its file's hash, as a host that keeps captures makes it. + unsigned char sha256[32] = {}; + ASSERT_TRUE(EPDFLayer_GetBaseSha256(layer.get(), sha256)); + const std::vector capture = ExportPage(layer.get(), kIntro); + ASSERT_FALSE(capture.empty()); + + // The same objects, with other bytes after the end: another file. + const std::string other = input_ + "% another file\n"; + EPDF_BASE_DOCUMENT other_base = + EPDF_LoadMemBaseDocument(other.data(), other.size(), nullptr); + ASSERT_TRUE(other_base); + { + ScopedFPDFDocument other_layer( + EPDFLayer_OpenLayer(other_base, nullptr, nullptr, nullptr)); + ASSERT_TRUE(other_layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(other_layer.get())); + ASSERT_TRUE(EPDFDoc_DeletePageByObjectNumber(other_layer.get(), kIntro)); + EXPECT_FALSE(ImportPage(other_layer.get(), capture)); + ASSERT_TRUE(EPDFLayer_AbortTransaction(other_layer.get())); + } + EPDF_ReleaseBaseDocument(other_base); +} + +// What goes back into the page tree is what the page's number holds once +// the capture is in. A save drops the null a layer page's delete left, so +// its number is free again; an outline item that took it never becomes a +// page. +TEST_F(EPDFPageCaptureEmbedderTest, + ImportRefusesWhenItsNumberHoldsSomethingElse) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + ASSERT_TRUE(EPDFPage_InsertBlankRaw(layer.get(), 3, 300, 300, 20)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const std::vector capture = ExportPage(layer.get(), 20); + ASSERT_FALSE(capture.empty()); + ASSERT_TRUE(EPDFDoc_DeletePageByObjectNumber(layer.get(), 20)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + + ScopedFPDFDocument reopened = Reopen(layer.get()); + ASSERT_TRUE(reopened); + ASSERT_TRUE(EPDFLayer_RaiseLastObjectNumber(reopened.get(), kRaised)); + ASSERT_TRUE(EPDFLayer_BeginTransaction(reopened.get())); + ScopedFPDFWideString title = GetFPDFWideString(L"Taken"); + ASSERT_EQ(20u, EPDFOutline_CreateItem(reopened.get(), 20, 0, 0, title.get())); + EXPECT_FALSE(ImportPage(reopened.get(), capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(reopened.get())); + EXPECT_THAT(PageOrder(reopened.get()), ElementsAre(kCover, kIntro, kBody)); +} + +// A page that comes back under the node it was under takes nothing as its +// own: its dictionary reads as it did, so what was digested of it still +// matches. +TEST_F(EPDFPageCaptureEmbedderTest, PageBackUnderItsNodeGainsNoKeys) { + EPDF_ReleaseBaseDocument(base_); + input_ = MakePdf({ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 100] " + "/Resources << >> >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Page /Parent 2 0 R >>", + }); + base_ = EPDF_LoadMemBaseDocument(input_.data(), input_.size(), nullptr); + ASSERT_TRUE(base_); + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(layer.get()); + const std::string before = ObjectGraph(pdf).at(4); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const std::vector capture = ExportPage(layer.get(), 4); + ASSERT_FALSE(capture.empty()); + ASSERT_TRUE(EPDFDoc_DeletePageByObjectNumber(layer.get(), 4)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + ASSERT_TRUE(ImportPage(layer.get(), capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_THAT(PageOrder(layer.get()), ElementsAre(3, 4)); + EXPECT_EQ(before, ObjectGraph(pdf).at(4)); +} + +// A capture holds its page, never another page, the page tree or the +// catalog: the cover links to the body, which the layer turned, and reaches +// the page tree and the catalog, which the delete changed, through a key a +// capture follows. TEST_F(EPDFPageCaptureEmbedderTest, CaptureHoldsNoOtherPage) { ScopedFPDFDocument layer = OpenLayer(); ASSERT_TRUE(layer); ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); ASSERT_TRUE(EPDFDoc_SetPageRotationByObjectNumber(layer.get(), kCover, 1)); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(layer.get()); + RetainPtr reach = + WritableDict(layer.get(), kCover)->SetNewFor("Reach"); + reach->AppendNew(pdf, 1); + reach->AppendNew(pdf, kPageTree); ASSERT_TRUE(EPDFDoc_SetPageRotationByObjectNumber(layer.get(), kBody, 2)); ASSERT_TRUE(EPDFDoc_DeletePageByObjectNumber(layer.get(), kIntro)); ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); diff --git a/fpdfsdk/epdf_outline_embeddertest.cpp b/fpdfsdk/epdf_outline_embeddertest.cpp index 58ed2ad4cb..9ca4b3cc24 100644 --- a/fpdfsdk/epdf_outline_embeddertest.cpp +++ b/fpdfsdk/epdf_outline_embeddertest.cpp @@ -25,6 +25,7 @@ #include "public/cpp/fpdf_scopers.h" #include "public/epdf_capture.h" #include "public/epdf_doc.h" +#include "public/epdf_edit.h" #include "public/epdf_layer.h" #include "public/epdf_outline.h" #include "public/epdf_save.h" @@ -331,10 +332,15 @@ class EPDFOutlineEmbedderTest : public EmbedderTest { return reopened; } + void RandomWrites(FPDF_DOCUMENT doc, bool layer); + std::string plain_; std::string input_; std::string artifact_; EPDF_BASE_DOCUMENT base_ = nullptr; + // The next number a layer write creates an item at (OpenLayer() hands out + // numbers up to 40). + uint32_t next_number = 41; }; TEST_F(EPDFOutlineEmbedderTest, ItemsAreNamedByNumber) { @@ -549,11 +555,15 @@ TEST_F(EPDFOutlineEmbedderTest, SetActionReplacesTheDestination) { EXPECT_TRUE(RefOf(a.Get(), "A")); EXPECT_TRUE(FPDFBookmark_GetAction(EPDFOutline_GetItem(doc.get(), kA))); - // Another document's action is refused; none removes the target. - ScopedFPDFDocument other = OpenLayer(); + // Another document's action is refused, also when its number names an + // object here (the action set above); none removes the target. + ScopedFPDFDocument other( + FPDF_LoadMemDocument(plain_.data(), plain_.size(), nullptr)); ASSERT_TRUE(other); FPDF_ACTION foreign = GoTo(other.get(), kFirstPage); ASSERT_TRUE(foreign); + ASSERT_TRUE( + DictOf(doc.get(), CPDFDictionaryFromFPDFAction(foreign)->GetObjNum())); EXPECT_FALSE(EPDFOutline_SetAction(doc.get(), kB, foreign)); ASSERT_TRUE(EPDFOutline_SetAction(doc.get(), kA, nullptr)); a = DictOf(doc.get(), kA); @@ -664,11 +674,119 @@ TEST_F(EPDFOutlineEmbedderTest, ADirectOutlineDictionaryIsNeverWritten) { EXPECT_FALSE(Create(doc.get(), 0, 0, L"x")); } -// Random writes, checked after each one: the links and the counts are what -// the tree says. -TEST_F(EPDFOutlineEmbedderTest, RandomWritesKeepTheOutlineRight) { - ScopedFPDFDocument doc = OpenPlain(); +// A dictionary that isn't an item (a page, an action, a field, an +// annotation, a page tree) ends the list a broken file links it into: the +// readers don't show it, and no write takes it for an item or changes it. +TEST_F(EPDFOutlineEmbedderTest, ADictionaryThatIsntAnItemEndsTheList) { + for (const char* other : { + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + "<< /MediaBox [0 0 300 300] /Resources << >> >>", + "<< /S /URI /URI (https://example.com) /Next 7 0 R >>", + "<< /FT /Tx /T (name) /Parent 3 0 R >>", + "<< /Subtype /Text /Rect [0 0 10 10] >>", + "<< /Type /Pages /Kids [4 0 R] /Count 1 >>", + }) { + SCOPED_TRACE(other); + ScopedFPDFDocument doc = OpenPlain(MakeDocumentWith({ + "<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + "<< /Title (One) /Parent 3 0 R /Next 6 0 R >>", + other, + })); + ASSERT_TRUE(doc); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc.get()); + const std::string before = ObjectGraph(pdf)[6]; + EXPECT_EQ("1:One", Tree(doc.get())); + EXPECT_FALSE(EPDFOutline_GetItem(doc.get(), 6)); + EXPECT_FALSE(EPDFOutline_DeleteItem(doc.get(), 6)); + EXPECT_FALSE(EPDFOutline_MoveItem(doc.get(), 6, 0, 0)); + EXPECT_FALSE(Create(doc.get(), 6, 0, L"under it")); + ASSERT_TRUE(Create(doc.get(), 0, 5, L"Two")); + EXPECT_EQ(before, ObjectGraph(pdf)[6]); + EXPECT_EQ("2:One Two", Tree(doc.get())); + EXPECT_EQ("", Problems(doc.get())); + EXPECT_EQ(1, FPDF_GetPageCount(doc.get())); + } +} + +// An /Outlines that isn't an outline's dictionary (here the page tree) is +// no outline: the readers show none, and a create makes a new outline in +// its place without touching what was linked there. +TEST_F(EPDFOutlineEmbedderTest, AnOutlinesThatIsntAnOutlineIsNone) { + ScopedFPDFDocument doc = OpenPlain(MakePdf({ + "<< /Type /Catalog /Pages 2 0 R /Outlines 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + })); ASSERT_TRUE(doc); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc.get()); + const std::string pages = ObjectGraph(pdf)[2]; + EXPECT_FALSE(FPDFBookmark_GetFirstChild(doc.get(), nullptr)); + ASSERT_TRUE(Create(doc.get(), 0, 0, L"One")); + EXPECT_EQ(pages, ObjectGraph(pdf)[2]); + EXPECT_NE(2u, OutlinesOf(doc.get())); + EXPECT_EQ("1:One", Tree(doc.get())); + EXPECT_EQ(1, FPDF_GetPageCount(doc.get())); +} + +// Lists a broken file shares are counted once: in an outline whose every +// item lists the items before it as its children, each item counts once, +// and counting costs a look at each (not one per list it is in). +TEST_F(EPDFOutlineEmbedderTest, SharedListsAreCountedOnce) { + constexpr uint32_t kItems = 3000; + const uint32_t last = 4 + kItems; + std::vector outline = { + "<< /Type /Outlines /First 5 0 R /Last " + std::to_string(last) + + " 0 R >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + }; + for (uint32_t objnum = 5; objnum <= last; ++objnum) { + std::string item = "<< /Title (x) /Parent 3 0 R /First 5 0 R /Count 1"; + if (objnum < last) { + item += " /Next " + std::to_string(objnum + 1) + " 0 R"; + } + outline.push_back(item + " >>"); + } + ScopedFPDFDocument doc = OpenPlain(MakeDocumentWith(outline)); + ASSERT_TRUE(doc); + ASSERT_TRUE(Create(doc.get(), 0, last, L"After")); + EXPECT_EQ(static_cast(kItems) + 1, + DictOf(doc.get(), 3)->GetIntegerFor("Count")); +} + +// Reads of the outline go 128 levels deep; an item deeper than that is +// neither found nor written by its number, and the one at the last level +// still is. +TEST_F(EPDFOutlineEmbedderTest, ItemsDeeperThanReadsGoAreLeftAlone) { + constexpr uint32_t kLevels = 130; + std::vector outline = { + "<< /Type /Outlines /First 5 0 R /Last 5 0 R >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + }; + for (uint32_t level = 0; level < kLevels; ++level) { + const uint32_t objnum = 5 + level; + std::string item = "<< /Title (x) /Parent " + + std::to_string(level ? objnum - 1 : 3) + " 0 R"; + if (level + 1 < kLevels) { + const std::string child = std::to_string(objnum + 1); + item += " /First " + child + " 0 R /Last " + child + " 0 R"; + } + outline.push_back(item + " >>"); + } + ScopedFPDFDocument doc = OpenPlain(MakeDocumentWith(outline)); + ASSERT_TRUE(doc); + ScopedFPDFWideString title = GetFPDFWideString(L"y"); + EXPECT_TRUE(EPDFOutline_GetItem(doc.get(), 5 + 127)); + EXPECT_TRUE(EPDFOutline_SetTitle(doc.get(), 5 + 127, title.get())); + EXPECT_FALSE(EPDFOutline_GetItem(doc.get(), 5 + 128)); + EXPECT_FALSE(EPDFOutline_SetTitle(doc.get(), 5 + 128, title.get())); + EXPECT_FALSE(EPDFOutline_DeleteItem(doc.get(), 5 + 128)); +} + +// Random writes, checked after each one: the links and the counts are what +// the tree says. On a layer each write is a transaction, and one in five is +// aborted, which must leave the outline as it was. +void EPDFOutlineEmbedderTest::RandomWrites(FPDF_DOCUMENT doc, bool layer) { std::mt19937 random(20261018); auto pick = [&](const std::vector& from) -> uint32_t { if (from.empty()) { @@ -679,44 +797,67 @@ TEST_F(EPDFOutlineEmbedderTest, RandomWritesKeepTheOutlineRight) { }; for (int step = 0; step < 300; ++step) { SCOPED_TRACE(step); - const std::vector items = Items(doc.get()); + const std::vector items = Items(doc); const uint32_t item = pick(items); // A parent and a neighbour that may or may not fit: refusals must also // leave the outline right. const uint32_t parent = random() % 4 ? pick(items) : 0; std::vector siblings; for (FPDF_BOOKMARK child = FPDFBookmark_GetFirstChild( - doc.get(), - parent ? EPDFOutline_GetItem(doc.get(), parent) : nullptr); - child; child = FPDFBookmark_GetNextSibling(doc.get(), child)) { + doc, parent ? EPDFOutline_GetItem(doc, parent) : nullptr); + child; child = FPDFBookmark_GetNextSibling(doc, child)) { siblings.push_back(EPDFOutline_GetObjectNumber(child)); } const uint32_t after = random() % 3 ? pick(siblings) : 0; + const std::string before = Tree(doc); + if (layer) { + ASSERT_TRUE(EPDFLayer_BeginTransaction(doc)); + } switch (random() % 5) { case 0: case 1: - Create(doc.get(), parent, after, L"new"); + // A layer creates at numbers it holds; a plain document picks one. + Create(doc, parent, after, L"new", layer ? next_number++ : 0); break; case 2: if (item) { - EPDFOutline_MoveItem(doc.get(), item, parent, after); + EPDFOutline_MoveItem(doc, item, parent, after); } break; case 3: if (item && items.size() > 3) { - EPDFOutline_DeleteItem(doc.get(), item); + EPDFOutline_DeleteItem(doc, item); } break; case 4: if (item) { - EPDFOutline_SetOpen(doc.get(), item, random() % 2); + EPDFOutline_SetOpen(doc, item, random() % 2); } break; } - ASSERT_EQ("", Problems(doc.get())); + if (layer && random() % 5 == 0) { + ASSERT_TRUE(EPDFLayer_AbortTransaction(doc)); + ASSERT_EQ(before, Tree(doc)); + } else if (layer) { + ASSERT_TRUE(EPDFLayer_CommitTransaction(doc)); + } + ASSERT_EQ("", Problems(doc)); } } +TEST_F(EPDFOutlineEmbedderTest, RandomWritesKeepTheOutlineRight) { + ScopedFPDFDocument doc = OpenPlain(); + ASSERT_TRUE(doc); + RandomWrites(doc.get(), /*layer=*/false); +} + +TEST_F(EPDFOutlineEmbedderTest, RandomWritesKeepALayersOutlineRight) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_RaiseLastObjectNumber(layer.get(), 400)); + RandomWrites(layer.get(), /*layer=*/true); +} + // A write changes only what it must: appending an item touches the item // before it and the outline, never the items further up the list. TEST_F(EPDFOutlineEmbedderTest, WritesCopyOnlyWhatChanges) { @@ -839,6 +980,31 @@ TEST_F(EPDFOutlineEmbedderTest, ImportFindsItsPlace) { EXPECT_EQ("", Problems(layer.get())); } +// What goes back into the outline is what the item's number holds once the +// capture is in: a page that took the number after a save dropped the +// deleted item never becomes an item. +TEST_F(EPDFOutlineEmbedderTest, ImportRefusesWhenItsNumberHoldsSomethingElse) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + ASSERT_EQ(20u, Create(layer.get(), 0, kC, L"New", 20)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const std::vector capture = ExportItem(layer.get(), 20); + ASSERT_FALSE(capture.empty()); + ASSERT_TRUE(EPDFOutline_DeleteItem(layer.get(), 20)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + + ScopedFPDFDocument reopened = Reopen(layer.get()); + ASSERT_TRUE(reopened); + ASSERT_TRUE(EPDFLayer_RaiseLastObjectNumber(reopened.get(), 40)); + ASSERT_TRUE(EPDFLayer_BeginTransaction(reopened.get())); + ASSERT_TRUE(EPDFPage_InsertBlankRaw(reopened.get(), 0, 100, 100, 20)); + EXPECT_FALSE(ImportItem(reopened.get(), capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(reopened.get())); + EXPECT_EQ("4:A+1[A1] B C-1[C1]", Tree(reopened.get())); +} + TEST_F(EPDFOutlineEmbedderTest, CapturesNeedAnItemAndALayer) { ScopedFPDFDocument layer = OpenLayer(); ASSERT_TRUE(layer); diff --git a/fpdfsdk/epdf_outline_helpers.cpp b/fpdfsdk/epdf_outline_helpers.cpp index 5c65d51a62..0af703577d 100644 --- a/fpdfsdk/epdf_outline_helpers.cpp +++ b/fpdfsdk/epdf_outline_helpers.cpp @@ -13,11 +13,18 @@ #include "core/fpdfapi/parser/cpdf_name.h" #include "core/fpdfapi/parser/cpdf_number.h" #include "core/fpdfapi/parser/cpdf_reference.h" +#include "core/fpdfdoc/cpdf_bookmarktree.h" namespace epdf { namespace { +// How deep an item may sit: the steps up its /Parent chain to the outline's +// dictionary, 1 for a top-level item. Reads of the outline go no deeper (the +// engine's OUTLINE_READ_LIMITS.depth), so no write reaches an item a read +// can't show, and no walk up a chain goes on for long. +constexpr size_t kMaxOutlineDepth = 128; + RetainPtr DictOf(CPDF_Document* doc, uint32_t objnum) { return objnum ? ToDictionary(doc->GetOrParseIndirectObject(objnum)) : nullptr; } @@ -34,9 +41,14 @@ bool IsOpen(const CPDF_Dictionary* item) { } // The children of |dict|, whose own number is |self| (0 when it has none). +// With |taken|, the list also ends at an item another list gave, and its +// own items join |taken|: in a well-formed outline every item is in one +// list, so a walk that shares |taken| looks at each item once, however a +// broken file links its lists. std::vector ChildrenOfDict(CPDF_Document* doc, const CPDF_Dictionary* dict, - uint32_t self) { + uint32_t self, + std::set* taken = nullptr) { std::vector children; if (!dict) { return children; @@ -45,20 +57,28 @@ std::vector ChildrenOfDict(CPDF_Document* doc, if (self) { seen.insert(self); } + // The outline's own dictionary is never an item. + if (const uint32_t root = OutlineRootOf(doc).value_or(0)) { + seen.insert(root); + } RetainPtr link = dict->GetObjectFor("First"); while (link) { OutlineChild child; if (const CPDF_Reference* ref = link->AsReference()) { child.objnum = ref->GetRefObjNum(); - if (!seen.insert(child.objnum).second) { - break; // a loop + if (!seen.insert(child.objnum).second || + (taken && taken->contains(child.objnum))) { + break; // a loop, or an item another list has } child.dict = DictOf(doc, child.objnum); } else { child.dict = ToDictionary(link); } - if (!child.dict) { - break; // a reference to nothing, or not a dictionary + if (!CPDF_BookmarkTree::IsOutlineNode(child.dict.Get())) { + break; // a reference to nothing, or not an item: a page, an action + } + if (taken && child.objnum) { + taken->insert(child.objnum); } link = child.dict->GetObjectFor("Next"); children.push_back(std::move(child)); @@ -69,13 +89,15 @@ std::vector ChildrenOfDict(CPDF_Document* doc, // How many items show under |node| when it is open: each child, and what // shows under each open child, read from the tree. |known| holds what was // worked out already, by object number. +// Each item is counted once, so the count costs one look at each item under +// |node|, whatever a broken file shares between lists. int VisibleUnder(CPDF_Document* doc, uint32_t node, const std::map& known) { int total = 0; - std::set expanded = {node}; + std::set taken = {node}; std::vector pending = - ChildrenOfDict(doc, DictOf(doc, node).Get(), node); + ChildrenOfDict(doc, DictOf(doc, node).Get(), node, &taken); while (!pending.empty()) { OutlineChild child = std::move(pending.back()); pending.pop_back(); @@ -88,12 +110,9 @@ int VisibleUnder(CPDF_Document* doc, total += it->second; continue; } - if (!expanded.insert(child.objnum).second) { - continue; // a loop - } } for (OutlineChild& grandchild : - ChildrenOfDict(doc, child.dict.Get(), child.objnum)) { + ChildrenOfDict(doc, child.dict.Get(), child.objnum, &taken)) { pending.push_back(std::move(grandchild)); } } @@ -158,14 +177,14 @@ std::optional OutlineRootOf(CPDF_Document* doc) { const CPDF_Dictionary* catalog = doc->GetRoot(); RetainPtr value = catalog ? catalog->GetObjectFor("Outlines") : nullptr; - if (!value) { + // What isn't an outline's dictionary (a page tree a broken file links + // there) is no outline: a create makes a new one in its place. + if (!value || !CPDF_BookmarkTree::IsOutlineNode( + ToDictionary(value->GetDirect()).Get())) { return 0; } const CPDF_Reference* ref = value->AsReference(); - if (!ref) { - return value->IsDictionary() ? std::nullopt : std::optional(0); - } - return DictOf(doc, ref->GetRefObjNum()) ? ref->GetRefObjNum() : 0; + return ref ? std::optional(ref->GetRefObjNum()) : std::nullopt; } uint32_t CreateOutlineRoot(CPDF_Document* doc) { @@ -201,8 +220,8 @@ bool IsOutlineItem(CPDF_Document* doc, uint32_t root, uint32_t objnum) { } std::set seen; for (uint32_t node = objnum; node != root;) { - if (!seen.insert(node).second) { - return false; // a loop in the /Parent chain + if (seen.size() >= kMaxOutlineDepth || !seen.insert(node).second) { + return false; // deeper than reads go, or a loop in the /Parent chain } const uint32_t parent = OutlineParentOf(doc, node); if (!parent) { @@ -225,12 +244,12 @@ uint32_t OutlineParentOf(CPDF_Document* doc, uint32_t objnum) { std::vector OutlineSubtreeOf(CPDF_Document* doc, uint32_t objnum) { std::vector items = {objnum}; - std::set seen = {objnum}; + std::set taken = {objnum}; for (size_t i = 0; i < items.size(); ++i) { const uint32_t parent = items[i]; - for (const OutlineChild& child : OutlineChildrenOf(doc, parent)) { - if (child.objnum && LinkOf(child.dict.Get(), "Parent") == parent && - seen.insert(child.objnum).second) { + for (const OutlineChild& child : + ChildrenOfDict(doc, DictOf(doc, parent).Get(), parent, &taken)) { + if (child.objnum && LinkOf(child.dict.Get(), "Parent") == parent) { items.push_back(child.objnum); } } @@ -258,6 +277,9 @@ bool RecountOutline(CPDF_Document* doc, uint32_t root, uint32_t start) { std::map known; std::set seen; for (uint32_t node = start; node && seen.insert(node).second;) { + if (seen.size() > kMaxOutlineDepth + 1) { + return false; // deeper than an item may sit + } RetainPtr dict = DictOf(doc, node); if (!dict) { return false; diff --git a/fpdfsdk/epdf_outline_helpers.h b/fpdfsdk/epdf_outline_helpers.h index cc0c12c3b3..3142ec2819 100644 --- a/fpdfsdk/epdf_outline_helpers.h +++ b/fpdfsdk/epdf_outline_helpers.h @@ -29,16 +29,18 @@ struct OutlineChild { }; // The outline's own dictionary, /Root /Outlines: its object number, 0 when -// the document has none, or nullopt when it is a direct dictionary, which no -// item's /Parent can refer to. +// the document has none (or links something that isn't an outline there, see +// CPDF_BookmarkTree::IsOutlineNode()), or nullopt when it is a direct +// dictionary, which no item's /Parent can refer to. std::optional OutlineRootOf(CPDF_Document* doc); // Makes a new /Root /Outlines and returns its object number, or 0. uint32_t CreateOutlineRoot(CPDF_Document* doc); // The children of |node| (the outline's dictionary or an item): its /First -// and the /Next that follow. A repeat, or a reference to nothing, ends the -// list. +// and the /Next that follow. A repeat, a reference to nothing, the outline's +// own dictionary, or a dictionary that isn't an item (a page, an action: +// CPDF_BookmarkTree::IsOutlineNode()) ends the list. std::vector OutlineChildrenOf(CPDF_Document* doc, uint32_t node); // The object numbers of |node|'s children, or nullopt when one of them isn't @@ -48,7 +50,7 @@ std::optional> OutlineChildNumbers(CPDF_Document* doc, // Whether |objnum| is an item of the outline whose dictionary is |root|: // each step up its /Parent chain is among the parent's children, up to -// |root|. +// |root|, in at most 128 steps (the depth reads of the outline go to). bool IsOutlineItem(CPDF_Document* doc, uint32_t root, uint32_t objnum); // The object number the item's /Parent refers to, or 0. @@ -56,7 +58,8 @@ uint32_t OutlineParentOf(CPDF_Document* doc, uint32_t objnum); // The item |objnum| and the items under it, the item first. A child goes // with its parent only when its /Parent says so, so a list that wandered -// into another part of the tree never takes that part along. +// into another part of the tree never takes that part along, and each item +// is looked at once. std::vector OutlineSubtreeOf(CPDF_Document* doc, uint32_t objnum); // Makes |children| the children of |node|, in order: their /Parent, /Prev diff --git a/public/epdf_capture.h b/public/epdf_capture.h index c144a1fbbf..a3ca7581dc 100644 --- a/public/epdf_capture.h +++ b/public/epdf_capture.h @@ -19,18 +19,25 @@ extern "C" { // A capture holds the layer's own versions of the objects involved and of // what they reach: what the layer made, and what it edited. It never holds // the uploaded file's originals, which always stay. It is PDF syntax: the -// line "%EPDF-CAPTURE 1", a manifest dictionary naming what was captured and -// where it was, then each object as "N G obj ... endobj", a stream with its -// bytes as stored. Following references stops at the catalog, the page tree -// and its pages, and at the keys /P, /Parent, /IRT and /OC. +// line "%EPDF-CAPTURE 1", a manifest dictionary naming what was captured, +// where it was and, when the layer knows its base's SHA-256, the file it was +// taken on (/Base: supplied with EPDF_SetBaseDocumentSha256() or worked out +// once, so taking a capture never hashes the file), then each object as +// "N G obj ... endobj", a stream with its bytes as stored. Following references +// stops at the catalog, the page tree and its pages, and at the keys /P, +// /Parent, /IRT and /OC. // // An import runs inside a layer transaction (EPDFLayer_BeginTransaction()). // It puts an object back only where the layer holds no version of its number // now: a version the layer holds may be newer (a shared font grows its -// subset), and numbers are never reused, so it is the same object. A capture -// that names a number above the layer's last one is refused before anything -// is written. Which objects to capture, and when to put them back, is the -// caller's decision. +// subset). What it puts back in its place (an annotation into /Annots, a page +// into the page tree) is what the number holds once the objects are in, and +// an import that would place something of another kind is refused: a save can +// free a deleted object's number for another object. A capture that names +// another file, one that names a number above the layer's last one, and one +// that holds the catalog, the page tree or a page it isn't of are refused +// before anything is written. Which objects to capture, and when to put them +// back, is the caller's decision. // // Exports return a buffer the caller releases with EPDF_FreeBuffer(), its // size in |out_size|, or NULL. All of these work on layer documents only, and @@ -125,7 +132,8 @@ EPDFOutline_ExportItemRawToOwnedBuffer(FPDF_DOCUMENT document, // children, right after the sibling it followed while that is still one of // them, else first. Counts are kept, as every outline write keeps them. // False outside a transaction, for a malformed capture, when its parent is no -// longer an item in the outline, or when the item is in the outline already. +// longer an item in the outline, when the item is in the outline already, or +// when what its number holds isn't an outline item. FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFOutline_ImportItemRaw(FPDF_DOCUMENT document, const void* capture, @@ -136,8 +144,9 @@ EPDFOutline_ImportItemRaw(FPDF_DOCUMENT document, // the page and every object the layer holds a version of that it reaches // (its contents, resources and annotations, with their appearances; never // another page or the form's fields), the /Names /Pages keys that name it, -// and its place: the page it followed. NULL when |page_objnum| is not a page -// in the page tree. +// its place (the page it followed), and what it takes from the page tree +// above it: the /Resources, /MediaBox, /CropBox and /Rotate it doesn't hold +// itself. NULL when |page_objnum| is not a page in the page tree. FPDF_EXPORT void* FPDF_CALLCONV EPDFPage_ExportRawToOwnedBuffer(FPDF_DOCUMENT document, uint32_t page_objnum, @@ -147,9 +156,11 @@ EPDFPage_ExportRawToOwnedBuffer(FPDF_DOCUMENT document, // Puts the page of |capture| back: its objects where the layer holds no // version, the page itself also over the null a delete leaves for a page the // layer made; then the page into the page tree right after the page it -// followed while that is still in it, else first; then each of its keys that -// no registration holds now. False outside a transaction, for a malformed -// capture, or when the page is in the page tree already. +// followed while that is still in it, else first, holding what it took from +// the page tree as its own keys where its place now gives otherwise; then each +// of its keys that no registration holds now. False outside a transaction, for +// a malformed capture, when the page is in the page tree already, or when what +// its number holds is no page. FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFPage_ImportRaw(FPDF_DOCUMENT document, const void* capture, diff --git a/public/epdf_outline.h b/public/epdf_outline.h index c727ca619e..7c9cfc0374 100644 --- a/public/epdf_outline.h +++ b/public/epdf_outline.h @@ -22,11 +22,16 @@ extern "C" { // number to anything else. A |parent| is an item's number, or 0 for the // outline itself, whose children are the top-level items. An item is in the // outline when each step up its /Parent chain is among the parent's children, -// up to /Root /Outlines: a deleted item is not, even where its dictionary can -// still be read. +// up to /Root /Outlines, in at most 128 steps (the depth reads of the outline +// go to): a deleted item is not, even where its dictionary can still be read. // // A parent's children are its /First and the /Next that follow, a loop ending -// the list. Every write keeps the outline well formed: +// the list. A dictionary that isn't an item also ends it, for the readers of +// fpdf_doc.h as for these functions: one with a /Type other than /Outlines, +// or a key of a page, the page tree, an annotation, a field or an action +// (/Kids, /Contents, /MediaBox, /Resources, /Subtype, /FT, /S). An /Outlines +// that isn't an outline dictionary is no outline: a create makes a new one in +// its place. Every write keeps the outline well formed: // * It rewrites /Parent, /Prev, /Next, /First and /Last of every list it // changes, from that walk, so a wrong /Last or /Prev never misleads it. It // writes only the keys whose value changes. From beb3c49692eab36289071847cf367505e59ad52e Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Tue, 20 Oct 2026 12:26:43 +0300 Subject: [PATCH 2/3] Add canonical dictionary capture export Export a dictionary and selected reachable objects in a canonical form for change detection. Decoded stream bytes and caller-selected skipped keys keep comparisons stable across saves that only re-encode streams or update ignored keys. --- fpdfsdk/epdf_c_api_test.c | 1 + fpdfsdk/epdf_capture.cpp | 75 ++++++++++++++++++++++++--- fpdfsdk/epdf_capture_embeddertest.cpp | 45 ++++++++++++++++ public/epdf_capture.h | 16 ++++++ 4 files changed, 131 insertions(+), 6 deletions(-) diff --git a/fpdfsdk/epdf_c_api_test.c b/fpdfsdk/epdf_c_api_test.c index aecddb9de2..aa322265e0 100644 --- a/fpdfsdk/epdf_c_api_test.c +++ b/fpdfsdk/epdf_c_api_test.c @@ -217,6 +217,7 @@ int CheckEmbedPDFCApi(void) { CHK(EPDFDoc_GetAttachmentKey); // epdf_capture.h + CHK(EPDFDoc_ExportDictCanonicalToOwnedBuffer); CHK(EPDFDoc_ExportDictRawToOwnedBuffer); CHK(EPDFDoc_ImportDictRaw); CHK(EPDFForm_ExportFieldRawToOwnedBuffer); diff --git a/fpdfsdk/epdf_capture.cpp b/fpdfsdk/epdf_capture.cpp index d38acf8516..08e51c2caf 100644 --- a/fpdfsdk/epdf_capture.cpp +++ b/fpdfsdk/epdf_capture.cpp @@ -22,6 +22,7 @@ #include "core/fpdfapi/parser/cpdf_number.h" #include "core/fpdfapi/parser/cpdf_reference.h" #include "core/fpdfapi/parser/cpdf_stream.h" +#include "core/fpdfapi/parser/cpdf_stream_acc.h" #include "core/fpdfapi/parser/cpdf_string.h" #include "core/fpdfapi/parser/cpdf_syntax_parser.h" #include "core/fpdfdoc/cpdf_bookmarktree.h" @@ -184,11 +185,18 @@ void InsertReference(CPDF_Document* doc, objnum); } +// How a capture writes a stream: its bytes as stored, so it comes back byte +// for byte; or as it reads, so a save that only encodes it again changes +// nothing (the canonical bytes a guard compares). +enum class StreamBytes { kAsStored, kAsRead }; + // Collects the layer's versions of what a capture's roots reach, and writes // the capture. class CaptureWriter { public: - explicit CaptureWriter(CPDF_LayerDocument* layer) : layer_(layer) {} + explicit CaptureWriter(CPDF_LayerDocument* layer, + StreamBytes stream_bytes = StreamBytes::kAsStored) + : layer_(layer), stream_bytes_(stream_bytes) {} // Captures the layer's version of |objnum|, if it holds one, and what the // object reaches. @@ -226,7 +234,8 @@ class CaptureWriter { for (const auto& [objnum, object] : objects_) { ok = ok && archive.WriteDWord(objnum) && archive.WriteString(" ") && archive.WriteDWord(object->GetGenNum()) && - archive.WriteString(" obj\n") && WriteBody(&archive, object.Get()) && + archive.WriteString(" obj\n") && + WriteBody(&archive, object.Get(), stream_bytes_) && archive.WriteString("\nendobj\n"); } if (!ok) { @@ -313,20 +322,31 @@ class CaptureWriter { } // An object's body. A stream is written with its bytes as stored, never - // encoded again, so it comes back byte for byte. - static bool WriteBody(IFX_ArchiveStream* archive, const CPDF_Object* object) { + // encoded again, so it comes back byte for byte; or, as it reads, its + // decoded bytes without the keys that say how they were stored. + static bool WriteBody(IFX_ArchiveStream* archive, + const CPDF_Object* object, + StreamBytes stream_bytes) { const CPDF_Stream* stream = object->AsStream(); if (!stream) { return object->WriteTo(archive, nullptr); } + RetainPtr dict = ToDictionary(stream->GetDict()->Clone()); DataVector raw; - if (stream->IsMemoryBased()) { + if (stream_bytes == StreamBytes::kAsRead) { + auto read = + pdfium::MakeRetain(pdfium::WrapRetain(stream)); + read->LoadAllDataFiltered(); + raw = read->DetachData(); + for (const char* key : {"Filter", "DecodeParms", "DL"}) { + dict->RemoveFor(key); + } + } else if (stream->IsMemoryBased()) { pdfium::span data = stream->GetInMemoryRawData(); raw.assign(data.begin(), data.end()); } else { raw = stream->ReadAllRawData(); } - RetainPtr dict = ToDictionary(stream->GetDict()->Clone()); dict->SetNewFor("Length", pdfium::checked_cast(raw.size())); return dict->WriteTo(archive, nullptr) && @@ -335,6 +355,7 @@ class CaptureWriter { } UnownedPtr const layer_; + const StreamBytes stream_bytes_; std::set outline_items_; std::set pages_; std::set visited_; @@ -603,6 +624,48 @@ EPDFDoc_ExportDictRawToOwnedBuffer(FPDF_DOCUMENT document, return ToOwnedBuffer(writer.Write(manifest.Get()), out_size); } +FPDF_EXPORT void* FPDF_CALLCONV +EPDFDoc_ExportDictCanonicalToOwnedBuffer(FPDF_DOCUMENT document, + uint32_t objnum, + FPDF_BYTESTRING deep_keys, + FPDF_BYTESTRING skip_keys, + unsigned long* out_size) { + if (out_size) { + *out_size = 0; + } + CPDF_LayerDocument* layer = LayerOf(document); + if (!layer || !objnum) { + return nullptr; + } + CPDF_DocumentViewScope view(layer); + RetainPtr dict = + ToDictionary(layer->GetOrParseIndirectObject(objnum)); + if (!dict) { + return nullptr; + } + // No /Base: whether the layer knows its file's hash may change between two + // reads, and the bytes must not. + auto manifest = pdfium::MakeRetain(); + manifest->SetNewFor("Kind", "Dict"); + manifest->SetNewFor("Obj", pdfium::checked_cast(objnum)); + RetainPtr value = ToDictionary(dict->Clone()); + for (const ByteString& key : + fxcrt::Split(ByteString(skip_keys ? skip_keys : ""), ' ')) { + if (!key.IsEmpty()) { + value->RemoveFor(key.AsStringView()); + } + } + manifest->SetFor("Value", value); + CaptureWriter writer(layer, StreamBytes::kAsRead); + for (const ByteString& key : + fxcrt::Split(ByteString(deep_keys ? deep_keys : ""), ' ')) { + if (!key.IsEmpty()) { + writer.AddReachOf(value->GetObjectFor(key.AsStringView()).Get()); + } + } + return ToOwnedBuffer(writer.Write(manifest.Get()), out_size); +} + FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFDoc_ImportDictRaw(FPDF_DOCUMENT document, const void* capture, diff --git a/fpdfsdk/epdf_capture_embeddertest.cpp b/fpdfsdk/epdf_capture_embeddertest.cpp index 667d2fcf34..f4a2d86568 100644 --- a/fpdfsdk/epdf_capture_embeddertest.cpp +++ b/fpdfsdk/epdf_capture_embeddertest.cpp @@ -210,6 +210,16 @@ std::vector ExportDict(FPDF_DOCUMENT doc, return Take(buffer, size); } +std::vector CanonicalDict(FPDF_DOCUMENT doc, + uint32_t objnum, + const char* deep_keys, + const char* skip_keys) { + unsigned long size = 0; + void* buffer = EPDFDoc_ExportDictCanonicalToOwnedBuffer( + doc, objnum, deep_keys, skip_keys, &size); + return Take(buffer, size); +} + bool ImportDict(FPDF_DOCUMENT doc, const std::vector& capture) { return EPDFDoc_ImportDictRaw(doc, capture.data(), static_cast(capture.size())); @@ -636,6 +646,41 @@ TEST_F(EPDFCaptureEmbedderTest, ImportRefusesACaptureHoldingTheCatalog) { EXPECT_TRUE(Annots(layer.get(), 1).empty()); } +// A dictionary's canonical bytes change only with what it reads as: not +// when a key the caller leaves out changes (the page turned and turned back +// writes /Rotate 0), nor when a save encodes its streams again (a reopen), +// but when what it holds changes (its square recoloured). +TEST_F(EPDFCaptureEmbedderTest, CanonicalBytesChangeOnlyWithWhatReads) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + ASSERT_TRUE(CreateSquare(layer.get(), 1, 20)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + const std::vector before = + CanonicalDict(layer.get(), 4, "Annots", "Parent Rotate"); + ASSERT_FALSE(before.empty()); + + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + ASSERT_TRUE(EPDFDoc_SetPageRotationByObjectNumber(layer.get(), 4, 1)); + ASSERT_TRUE(EPDFDoc_SetPageRotationByObjectNumber(layer.get(), 4, 0)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_EQ(before, CanonicalDict(layer.get(), 4, "Annots", "Parent Rotate")); + const std::vector stored = ExportDict(layer.get(), 4, "Annots"); + + ScopedFPDFDocument reopened = Reopen(layer.get()); + ASSERT_TRUE(reopened); + // The save encoded the square's appearance: its stored bytes differ. + EXPECT_NE(stored, ExportDict(reopened.get(), 4, "Annots")); + EXPECT_EQ(before, + CanonicalDict(reopened.get(), 4, "Annots", "Parent Rotate")); + + ASSERT_TRUE(EPDFLayer_BeginTransaction(reopened.get())); + ASSERT_TRUE(Recolor(reopened.get(), 20, 1, 0, 0)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(reopened.get())); + EXPECT_NE(before, + CanonicalDict(reopened.get(), 4, "Annots", "Parent Rotate")); +} + // A dictionary captured before an update comes back exactly: every key as it // was, none added, and the appearance under a deep key with its bytes, also // after a save and reopen dropped the old appearance. diff --git a/public/epdf_capture.h b/public/epdf_capture.h index a3ca7581dc..e5854fff53 100644 --- a/public/epdf_capture.h +++ b/public/epdf_capture.h @@ -78,6 +78,22 @@ EPDFDoc_ExportDictRawToOwnedBuffer(FPDF_DOCUMENT document, FPDF_BYTESTRING deep_keys, unsigned long* out_size); +// Experimental EmbedPDF Extension API. +// What EPDFDoc_ExportDictRawToOwnedBuffer() holds of the dictionary |objnum|, +// as bytes a guard compares to tell whether it reads the same: the +// dictionary without the keys named in |skip_keys| (separated by spaces; +// NULL for none), and under |deep_keys| every object the layer holds a +// version of that they reach, each stream as it reads (its decoded bytes, +// without /Filter, /DecodeParms and /DL). So a save that only encodes a +// stream again changes nothing. Never imported. NULL when |objnum| is not a +// dictionary. +FPDF_EXPORT void* FPDF_CALLCONV +EPDFDoc_ExportDictCanonicalToOwnedBuffer(FPDF_DOCUMENT document, + uint32_t objnum, + FPDF_BYTESTRING deep_keys, + FPDF_BYTESTRING skip_keys, + unsigned long* out_size); + // Experimental EmbedPDF Extension API. // Puts the dictionary of |capture| back as it was: the objects under its deep // keys where the layer holds no version, then exactly the keys it held, with From c2878056bee629cf8282aa99661769e997440c4d Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Tue, 20 Oct 2026 13:22:49 +0300 Subject: [PATCH 3/3] Add embedded attachment capture APIs Add raw and canonical export plus transactional import for embedded files, preserving their file specifications and reachable layer objects. Cover round trips, name-tree layouts, key conflicts, and canonical identity. --- fpdfsdk/epdf_c_api_test.c | 3 + fpdfsdk/epdf_capture.cpp | 143 +++++++++++ fpdfsdk/epdf_capture_embeddertest.cpp | 334 ++++++++++++++++++++++++++ public/epdf_capture.h | 35 +++ 4 files changed, 515 insertions(+) diff --git a/fpdfsdk/epdf_c_api_test.c b/fpdfsdk/epdf_c_api_test.c index aa322265e0..99c0ec32c5 100644 --- a/fpdfsdk/epdf_c_api_test.c +++ b/fpdfsdk/epdf_c_api_test.c @@ -217,6 +217,9 @@ int CheckEmbedPDFCApi(void) { CHK(EPDFDoc_GetAttachmentKey); // epdf_capture.h + CHK(EPDFAttachment_ExportCanonicalToOwnedBuffer); + CHK(EPDFAttachment_ExportRawToOwnedBuffer); + CHK(EPDFAttachment_ImportRaw); CHK(EPDFDoc_ExportDictCanonicalToOwnedBuffer); CHK(EPDFDoc_ExportDictRawToOwnedBuffer); CHK(EPDFDoc_ImportDictRaw); diff --git a/fpdfsdk/epdf_capture.cpp b/fpdfsdk/epdf_capture.cpp index 08e51c2caf..2c549dca53 100644 --- a/fpdfsdk/epdf_capture.cpp +++ b/fpdfsdk/epdf_capture.cpp @@ -26,6 +26,7 @@ #include "core/fpdfapi/parser/cpdf_string.h" #include "core/fpdfapi/parser/cpdf_syntax_parser.h" #include "core/fpdfdoc/cpdf_bookmarktree.h" +#include "core/fpdfdoc/cpdf_nametree.h" #include "core/fxcrt/bytestring.h" #include "core/fxcrt/cfx_read_only_span_stream.h" #include "core/fxcrt/compiler_specific.h" @@ -101,6 +102,13 @@ DataVector SyntaxOf(const CPDF_Object* value) { return DataVector(bytes.begin(), bytes.end()); } +// Whether |dict| can be an embedded file's file specification: it says so, +// or it holds the file (/EF). +bool IsFileSpec(const CPDF_Dictionary* dict) { + return dict && + (dict->GetNameFor("Type") == "Filespec" || dict->KeyExist("EF")); +} + // Objects a capture never holds: the catalog, the page tree and its pages, // save the page a page capture is of (CaptureWriter::AddPage()). bool IsBoundaryObject(const CPDF_Object* object) { @@ -1163,3 +1171,138 @@ EPDFPage_ImportRaw(FPDF_DOCUMENT document, } return true; } + +FPDF_EXPORT void* FPDF_CALLCONV +EPDFAttachment_ExportRawToOwnedBuffer(FPDF_DOCUMENT document, + FPDF_WIDESTRING key, + unsigned long* out_size) { + if (out_size) { + *out_size = 0; + } + CPDF_LayerDocument* layer = LayerOf(document); + if (!layer || !key) { + return nullptr; + } + CPDF_DocumentViewScope view(layer); + // SAFETY: required from caller. + const WideString name = UNSAFE_BUFFERS(WideStringFromFPDFWideString(key)); + auto tree = CPDF_NameTree::CreateForReading(layer, "EmbeddedFiles"); + RetainPtr spec = + tree && !name.IsEmpty() ? ToDictionary(tree->LookupValue(name)) : nullptr; + if (!spec) { + return nullptr; + } + + // The file, under its key (Key): its file specification, an object of its + // own (Obj) or written in the tree (Value), and the objects the layer + // holds that it reaches. + RetainPtr manifest = NewManifest(layer, "Attachment"); + manifest->SetNewFor("Key", name.AsStringView()); + CaptureWriter writer(layer); + if (const uint32_t objnum = spec->GetObjNum()) { + manifest->SetNewFor("Obj", pdfium::checked_cast(objnum)); + writer.AddObject(objnum); + } else { + manifest->SetFor("Value", spec->Clone()); + writer.AddReachOf(spec.Get()); + } + return ToOwnedBuffer(writer.Write(manifest.Get()), out_size); +} + +FPDF_EXPORT void* FPDF_CALLCONV +EPDFAttachment_ExportCanonicalToOwnedBuffer(FPDF_DOCUMENT document, + FPDF_WIDESTRING key, + unsigned long* out_size) { + if (out_size) { + *out_size = 0; + } + CPDF_LayerDocument* layer = LayerOf(document); + if (!layer || !key) { + return nullptr; + } + CPDF_DocumentViewScope view(layer); + // SAFETY: required from caller. + const WideString name = UNSAFE_BUFFERS(WideStringFromFPDFWideString(key)); + auto tree = CPDF_NameTree::CreateForReading(layer, "EmbeddedFiles"); + RetainPtr spec = + tree && !name.IsEmpty() ? ToDictionary(tree->LookupValue(name)) : nullptr; + if (!spec) { + return nullptr; + } + // No /Base, as for EPDFDoc_ExportDictCanonicalToOwnedBuffer(). The number + // is part of what it reads as: another file under the key, even with the + // same bytes, is another file. + auto manifest = pdfium::MakeRetain(); + manifest->SetNewFor("Kind", "Attachment"); + manifest->SetNewFor("Key", name.AsStringView()); + if (const uint32_t objnum = spec->GetObjNum()) { + manifest->SetNewFor("Obj", pdfium::checked_cast(objnum)); + } + RetainPtr value = spec->Clone(); + manifest->SetFor("Value", value); + CaptureWriter writer(layer, StreamBytes::kAsRead); + writer.AddReachOf(value.Get()); + return ToOwnedBuffer(writer.Write(manifest.Get()), out_size); +} + +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFAttachment_ImportRaw(FPDF_DOCUMENT document, + const void* capture, + unsigned long size) { + CPDF_LayerDocument* layer = LayerOf(document); + if (!layer || !layer->InTransaction() || !capture || !size) { + return false; + } + CPDF_DocumentViewScope view(layer); + // SAFETY: required from caller. + std::optional read = ReadCapture( + layer, + UNSAFE_BUFFERS(pdfium::span(static_cast(capture), size)), + "Attachment"); + if (!read) { + return false; + } + const WideString key = read->manifest->GetUnicodeTextFor("Key"); + if (key.IsEmpty()) { + return false; + } + // A key another file holds now stays with it. + if (auto tree = CPDF_NameTree::CreateForReading(layer, "EmbeddedFiles"); + tree && tree->LookupValue(key)) { + return false; + } + + // What goes back under the key: a reference to the file specification, + // which is what its number holds once the capture is in (the layer's own + // version stays, else the capture's, else the file's original); or the + // specification written in the tree. Either must be a file specification. + RetainPtr entry; + if (const int objnum = read->manifest->GetIntegerFor("Obj"); objnum > 0) { + const uint32_t number = static_cast(objnum); + if (number > layer->GetLastObjNum()) { + return false; + } + auto captured = read->objects.find(number); + RetainPtr comes_back = + layer->FindLayerVersion(number) || captured == read->objects.end() + ? layer->GetOrParseIndirectObject(number) + : captured->second; + if (!IsFileSpec(ToDictionary(comes_back).Get())) { + return false; + } + entry = pdfium::MakeRetain(layer, number); + } else { + RetainPtr value = + read->manifest->GetDictFor("Value"); + if (!IsFileSpec(value.Get())) { + return false; + } + entry = value->Clone(); + } + + if (!InstallObjects(layer, *read)) { + return false; + } + auto tree = CPDF_NameTree::CreateWithRootNameArray(layer, "EmbeddedFiles"); + return tree && tree->AddValueAndName(std::move(entry), key); +} diff --git a/fpdfsdk/epdf_capture_embeddertest.cpp b/fpdfsdk/epdf_capture_embeddertest.cpp index f4a2d86568..227567e89c 100644 --- a/fpdfsdk/epdf_capture_embeddertest.cpp +++ b/fpdfsdk/epdf_capture_embeddertest.cpp @@ -30,6 +30,7 @@ #include "fpdfsdk/epdf_edit_test_util.h" #include "public/cpp/fpdf_scopers.h" #include "public/epdf_annot.h" +#include "public/epdf_attachment.h" #include "public/epdf_capture.h" #include "public/epdf_edit.h" #include "public/epdf_form.h" @@ -39,6 +40,7 @@ #include "public/epdf_save.h" #include "public/epdf_view.h" #include "public/fpdf_annot.h" +#include "public/fpdf_attachment.h" #include "public/fpdf_edit.h" #include "public/fpdf_save.h" #include "public/fpdf_transformpage.h" @@ -1367,3 +1369,335 @@ TEST_F(EPDFPageCaptureEmbedderTest, CapturesNeedAPageAndALayer) { ASSERT_TRUE(plain); EXPECT_TRUE(ExportPage(plain.get(), kIntro).empty()); } + +namespace { + +// One page and two embedded files: a.txt, whose file specification is an +// object of its own (4, its stream 5), and b.txt, whose specification is +// written in the tree (its stream 6). +std::string MakeAttachmentsDocument() { + return MakePdf({ + "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 7 0 R >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + "<< /Type /Filespec /F (a.txt) /UF (a.txt) /EF << /F 5 0 R >> >>", + "<< /Type /EmbeddedFile /Length 5 >>\nstream\nhello\nendstream", + "<< /Type /EmbeddedFile /Length 3 >>\nstream\nbye\nendstream", + "<< /Names [(a.txt) 4 0 R (b.txt) << /Type /Filespec /F (b.txt) " + "/UF (b.txt) /EF << /F 6 0 R >> >>] >>", + }); +} + +// The same files in a tree with two leaves: a.txt in the first, b.txt in +// the second. +std::string MakeAttachmentsTreeDocument() { + return MakePdf({ + "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 7 0 R >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + "<< /Type /Filespec /F (a.txt) /UF (a.txt) /EF << /F 5 0 R >> >>", + "<< /Type /EmbeddedFile /Length 5 >>\nstream\nhello\nendstream", + "<< /Type /EmbeddedFile /Length 3 >>\nstream\nbye\nendstream", + "<< /Kids [8 0 R 9 0 R] >>", + "<< /Limits [(a.txt) (a.txt)] /Names [(a.txt) 4 0 R] >>", + "<< /Limits [(b.txt) (b.txt)] /Names [(b.txt) 10 0 R] >>", + "<< /Type /Filespec /F (b.txt) /UF (b.txt) /EF << /F 6 0 R >> >>", + }); +} + +// The keys of |doc|'s embedded files, in tree order. +std::vector AttachmentKeys(FPDF_DOCUMENT doc) { + std::vector keys; + for (int i = 0; i < FPDFDoc_GetAttachmentCount(doc); ++i) { + const unsigned long length = EPDFDoc_GetAttachmentKey(doc, i, nullptr, 0); + std::vector buffer = GetFPDFWideStringBuffer(length); + EPDFDoc_GetAttachmentKey(doc, i, buffer.data(), length); + keys.push_back(GetPlatformWString(buffer.data())); + } + return keys; +} + +// The decoded bytes of the file under |key|, or "(none)". +std::string AttachmentContent(FPDF_DOCUMENT doc, const wchar_t* key) { + ScopedFPDFWideString name = GetFPDFWideString(key); + const int index = EPDFDoc_GetAttachmentIndexByKey(doc, name.get()); + FPDF_ATTACHMENT attachment = + index >= 0 ? FPDFDoc_GetAttachment(doc, index) : nullptr; + unsigned long length = 0; + if (!attachment || !FPDFAttachment_GetFile(attachment, nullptr, 0, &length)) { + return "(none)"; + } + std::string bytes(length, '\0'); + FPDFAttachment_GetFile(attachment, bytes.data(), length, &length); + return bytes; +} + +bool DeleteAttachment(FPDF_DOCUMENT doc, const wchar_t* key) { + ScopedFPDFWideString name = GetFPDFWideString(key); + const int index = EPDFDoc_GetAttachmentIndexByKey(doc, name.get()); + return index >= 0 && FPDFDoc_DeleteAttachment(doc, index); +} + +// Adds |key| with |content| and returns its file specification's number. +uint32_t AddAttachment(FPDF_DOCUMENT doc, + const wchar_t* key, + const std::string& content) { + ScopedFPDFWideString name = GetFPDFWideString(key); + FPDF_ATTACHMENT attachment = FPDFDoc_AddAttachment(doc, name.get()); + if (!attachment || !FPDFAttachment_SetFile(attachment, doc, content.data(), + content.size())) { + return 0; + } + return CPDFObjectFromFPDFAttachment(attachment)->GetObjNum(); +} + +std::vector ExportAttachment(FPDF_DOCUMENT doc, const wchar_t* key) { + ScopedFPDFWideString name = GetFPDFWideString(key); + unsigned long size = 0; + void* buffer = EPDFAttachment_ExportRawToOwnedBuffer(doc, name.get(), &size); + return Take(buffer, size); +} + +std::vector CanonicalAttachment(FPDF_DOCUMENT doc, + const wchar_t* key) { + ScopedFPDFWideString name = GetFPDFWideString(key); + unsigned long size = 0; + void* buffer = + EPDFAttachment_ExportCanonicalToOwnedBuffer(doc, name.get(), &size); + return Take(buffer, size); +} + +bool ImportAttachment(FPDF_DOCUMENT doc, const std::vector& capture) { + return EPDFAttachment_ImportRaw(doc, capture.data(), + static_cast(capture.size())); +} + +} // namespace + +class EPDFAttachmentCaptureEmbedderTest : public EmbedderTest { + protected: + void TearDown() override { + if (base_) { + EPDF_ReleaseBaseDocument(base_); + } + EmbedderTest::TearDown(); + } + + ScopedFPDFDocument OpenLayer(std::string bytes = MakeAttachmentsDocument()) { + input_ = std::move(bytes); + base_ = EPDF_LoadMemBaseDocument(input_.data(), input_.size(), nullptr); + if (!base_) { + return nullptr; + } + ScopedFPDFDocument layer( + EPDFLayer_OpenLayer(base_, nullptr, nullptr, nullptr)); + if (layer && !EPDFLayer_RaiseLastObjectNumber(layer.get(), kRaised)) { + return nullptr; + } + return layer; + } + + // The layer saved and opened again: what nothing reaches is gone. + ScopedFPDFDocument Reopen(FPDF_DOCUMENT layer) { + ClearString(); + EXPECT_TRUE(EPDFLayer_SaveLayerArtifact(layer, this, nullptr)); + artifact_ = GetString(); + FPDF_FILEACCESS access = {}; + access.m_FileLen = artifact_.size(); + access.m_GetBlock = GetBlockFromString; + access.m_Param = &artifact_; + EPDFLayerOpenStatus status = EPDFLayerOpenStatus_kOpenFailed; + ScopedFPDFDocument reopened( + EPDFLayer_OpenLayerArtifact(base_, &access, nullptr, &status)); + EXPECT_EQ(EPDFLayerOpenStatus_kSuccess, status); + return reopened; + } + + std::string input_; + std::string artifact_; + EPDF_BASE_DOCUMENT base_ = nullptr; +}; + +// A file of the uploaded file comes back under its key, with its bytes: +// one whose specification is an object of its own, and one written in the +// tree. Neither was edited, so the capture holds no copy of them. +TEST_F(EPDFAttachmentCaptureEmbedderTest, FileAttachmentsComeBack) { + for (const wchar_t* key : {L"a.txt", L"b.txt"}) { + SCOPED_TRACE(GetPlatformString(GetFPDFWideString(key).get())); + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const std::vector capture = ExportAttachment(layer.get(), key); + ASSERT_FALSE(capture.empty()); + ASSERT_TRUE(DeleteAttachment(layer.get(), key)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_EQ(1u, AttachmentKeys(layer.get()).size()); + + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + ASSERT_TRUE(ImportAttachment(layer.get(), capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_THAT(AttachmentKeys(layer.get()), ElementsAre(L"a.txt", L"b.txt")); + EXPECT_EQ("hello", AttachmentContent(layer.get(), L"a.txt")); + EXPECT_EQ("bye", AttachmentContent(layer.get(), L"b.txt")); + } +} + +// A file the layer added comes back with its bytes, also when a save and +// reopen dropped it, which nothing reached once it was deleted. +TEST_F(EPDFAttachmentCaptureEmbedderTest, LayerAttachmentComesBack) { + for (bool reopen : {false, true}) { + SCOPED_TRACE(reopen ? "reopened between delete and import" + : "same session"); + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + ASSERT_TRUE(AddAttachment(layer.get(), L"c.txt", "made here")); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const std::vector capture = + ExportAttachment(layer.get(), L"c.txt"); + ASSERT_FALSE(capture.empty()); + ASSERT_TRUE(DeleteAttachment(layer.get(), L"c.txt")); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + + ScopedFPDFDocument target = reopen ? Reopen(layer.get()) : std::move(layer); + ASSERT_TRUE(target); + EXPECT_EQ("(none)", AttachmentContent(target.get(), L"c.txt")); + ASSERT_TRUE(EPDFLayer_BeginTransaction(target.get())); + ASSERT_TRUE(ImportAttachment(target.get(), capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(target.get())); + EXPECT_THAT(AttachmentKeys(target.get()), + ElementsAre(L"a.txt", L"b.txt", L"c.txt")); + EXPECT_EQ("made here", AttachmentContent(target.get(), L"c.txt")); + } +} + +// A file comes back into the leaf of a tree with kids where its key goes. +TEST_F(EPDFAttachmentCaptureEmbedderTest, ComesBackIntoATreeWithKids) { + ScopedFPDFDocument layer = OpenLayer(MakeAttachmentsTreeDocument()); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const std::vector capture = ExportAttachment(layer.get(), L"b.txt"); + ASSERT_FALSE(capture.empty()); + ASSERT_TRUE(DeleteAttachment(layer.get(), L"b.txt")); + ASSERT_TRUE(ImportAttachment(layer.get(), capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_THAT(AttachmentKeys(layer.get()), ElementsAre(L"a.txt", L"b.txt")); + EXPECT_EQ("bye", AttachmentContent(layer.get(), L"b.txt")); +} + +// A key another file took since stays with it. +TEST_F(EPDFAttachmentCaptureEmbedderTest, ImportLeavesAKeyAnotherFileTook) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const std::vector capture = ExportAttachment(layer.get(), L"a.txt"); + ASSERT_TRUE(DeleteAttachment(layer.get(), L"a.txt")); + ASSERT_TRUE(AddAttachment(layer.get(), L"a.txt", "someone else's")); + EXPECT_FALSE(ImportAttachment(layer.get(), capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_EQ("someone else's", AttachmentContent(layer.get(), L"a.txt")); +} + +// What goes back under the key is what the file specification's number +// holds once the capture is in. A save drops a deleted layer file, so its +// number is free again; an outline item that took it never becomes a file. +TEST_F(EPDFAttachmentCaptureEmbedderTest, + ImportRefusesWhenItsNumberHoldsSomethingElse) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const uint32_t spec = AddAttachment(layer.get(), L"c.txt", "made here"); + ASSERT_TRUE(spec); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const std::vector capture = ExportAttachment(layer.get(), L"c.txt"); + ASSERT_TRUE(DeleteAttachment(layer.get(), L"c.txt")); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + + ScopedFPDFDocument reopened = Reopen(layer.get()); + ASSERT_TRUE(reopened); + ASSERT_TRUE(EPDFLayer_RaiseLastObjectNumber(reopened.get(), kRaised)); + ASSERT_TRUE(EPDFLayer_BeginTransaction(reopened.get())); + ScopedFPDFWideString title = GetFPDFWideString(L"Taken"); + ASSERT_EQ(spec, + EPDFOutline_CreateItem(reopened.get(), spec, 0, 0, title.get())); + EXPECT_FALSE(ImportAttachment(reopened.get(), capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(reopened.get())); + EXPECT_THAT(AttachmentKeys(reopened.get()), ElementsAre(L"a.txt", L"b.txt")); +} + +// A file reads the same while it is the same file: across a save and reopen, +// and when its capture brought it back. Another file under its key, with the +// same bytes, reads differently. +TEST_F(EPDFAttachmentCaptureEmbedderTest, CanonicalBytesTellTheSameFile) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + ASSERT_TRUE(AddAttachment(layer.get(), L"c.txt", "made here")); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + const std::vector made = CanonicalAttachment(layer.get(), L"c.txt"); + const std::vector inline_file = + CanonicalAttachment(layer.get(), L"b.txt"); + ASSERT_FALSE(made.empty()); + ASSERT_FALSE(inline_file.empty()); + EXPECT_NE(made, CanonicalAttachment(layer.get(), L"a.txt")); + + ScopedFPDFDocument reopened = Reopen(layer.get()); + ASSERT_TRUE(reopened); + EXPECT_EQ(made, CanonicalAttachment(reopened.get(), L"c.txt")); + EXPECT_EQ(inline_file, CanonicalAttachment(reopened.get(), L"b.txt")); + + ASSERT_TRUE(EPDFLayer_RaiseLastObjectNumber(reopened.get(), kRaised)); + for (const wchar_t* key : {L"c.txt", L"b.txt"}) { + SCOPED_TRACE(GetPlatformString(GetFPDFWideString(key).get())); + const std::vector before = + CanonicalAttachment(reopened.get(), key); + ASSERT_TRUE(EPDFLayer_BeginTransaction(reopened.get())); + const std::vector capture = ExportAttachment(reopened.get(), key); + ASSERT_TRUE(DeleteAttachment(reopened.get(), key)); + EXPECT_TRUE(CanonicalAttachment(reopened.get(), key).empty()); + ASSERT_TRUE(ImportAttachment(reopened.get(), capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(reopened.get())); + EXPECT_EQ(before, CanonicalAttachment(reopened.get(), key)); + } + + ASSERT_TRUE(EPDFLayer_BeginTransaction(reopened.get())); + ASSERT_TRUE(DeleteAttachment(reopened.get(), L"c.txt")); + ASSERT_TRUE(AddAttachment(reopened.get(), L"c.txt", "made here")); + ASSERT_TRUE(EPDFLayer_CommitTransaction(reopened.get())); + EXPECT_NE(made, CanonicalAttachment(reopened.get(), L"c.txt")); + + EXPECT_TRUE(CanonicalAttachment(reopened.get(), L"none.txt").empty()); + ScopedFPDFDocument plain( + FPDF_LoadMemDocument(input_.data(), input_.size(), nullptr)); + ASSERT_TRUE(plain); + EXPECT_TRUE(CanonicalAttachment(plain.get(), L"a.txt").empty()); +} + +TEST_F(EPDFAttachmentCaptureEmbedderTest, CapturesNeedAFileAndALayer) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + EXPECT_TRUE(ExportAttachment(layer.get(), L"none.txt").empty()); + EXPECT_TRUE(ExportAttachment(layer.get(), L"").empty()); + const std::vector capture = ExportAttachment(layer.get(), L"a.txt"); + ASSERT_FALSE(capture.empty()); + ASSERT_TRUE(DeleteAttachment(layer.get(), L"a.txt")); + EXPECT_FALSE( + ImportAttachment(layer.get(), capture)); // outside a transaction + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + EXPECT_FALSE(ImportAttachment(layer.get(), {})); + const std::vector cut(capture.begin(), capture.end() - 4); + EXPECT_FALSE(ImportAttachment(layer.get(), cut)); + // A capture of another kind, and a file capture as another kind. + EXPECT_FALSE(EPDFPage_ImportRaw(layer.get(), capture.data(), + static_cast(capture.size()))); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_THAT(AttachmentKeys(layer.get()), ElementsAre(L"b.txt")); + + ScopedFPDFDocument plain( + FPDF_LoadMemDocument(input_.data(), input_.size(), nullptr)); + ASSERT_TRUE(plain); + EXPECT_TRUE(ExportAttachment(plain.get(), L"a.txt").empty()); +} diff --git a/public/epdf_capture.h b/public/epdf_capture.h index e5854fff53..5ef254eca4 100644 --- a/public/epdf_capture.h +++ b/public/epdf_capture.h @@ -182,6 +182,41 @@ EPDFPage_ImportRaw(FPDF_DOCUMENT document, const void* capture, unsigned long size); +// Experimental EmbedPDF Extension API. +// The embedded file under |key| in /Root /Names /EmbeddedFiles, as +// FPDFDoc_DeleteAttachment() removes it: its key, its file specification +// (an object of its own, or written in the tree) and every object the layer +// holds a version of that it reaches (its /EF streams). NULL when no entry +// has |key|. +FPDF_EXPORT void* FPDF_CALLCONV +EPDFAttachment_ExportRawToOwnedBuffer(FPDF_DOCUMENT document, + FPDF_WIDESTRING key, + unsigned long* out_size); + +// Experimental EmbedPDF Extension API. +// What EPDFAttachment_ExportRawToOwnedBuffer() holds of the file under |key|, +// as bytes a guard compares to tell whether it is still the same file, as it +// was: its key, the number of its file specification (none when it is +// written in the tree), the specification, and every object the layer holds +// a version of that it reaches, each stream as it reads (as +// EPDFDoc_ExportDictCanonicalToOwnedBuffer() writes them). Never imported. +// NULL when no entry has |key|. +FPDF_EXPORT void* FPDF_CALLCONV +EPDFAttachment_ExportCanonicalToOwnedBuffer(FPDF_DOCUMENT document, + FPDF_WIDESTRING key, + unsigned long* out_size); + +// Experimental EmbedPDF Extension API. +// Puts the file of |capture| back under its key: its objects where the layer +// holds no version, then the entry, in key order (the tree is made when the +// document has none). False outside a transaction, for a malformed capture, +// when another file holds the key now, or when what its number holds isn't a +// file specification. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFAttachment_ImportRaw(FPDF_DOCUMENT document, + const void* capture, + unsigned long size); + #ifdef __cplusplus } // extern "C" #endif