From 1fcc78c6ff79d78057a721c636d87dc1dbd9f0ff Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Wed, 28 Oct 2026 15:27:02 +0200 Subject: [PATCH 1/3] Read and replace the catalog's metadata stream EPDFCatalog_GetMetadata returns the document's XMP packet, decoded. EPDFCatalog_SetMetadata replaces its bytes, unfiltered and marked /Type /Metadata /Subtype /XML so a save leaves them uncompressed, keeping the stream's object number; a catalog without one gets a new stream, and NULL removes /Metadata. What the packet says is the engine's to read and write: the fork moves the bytes. --- fpdfsdk/BUILD.gn | 4 + fpdfsdk/epdf_c_api_test.c | 2 + fpdfsdk/epdf_catalog.cpp | 96 +++++++++ fpdfsdk/epdf_catalog_embeddertest.cpp | 276 ++++++++++++++++++++++++++ public/epdf_catalog.h | 29 ++- 5 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 fpdfsdk/epdf_catalog.cpp create mode 100644 fpdfsdk/epdf_catalog_embeddertest.cpp diff --git a/fpdfsdk/BUILD.gn b/fpdfsdk/BUILD.gn index 69bc28458b..9f89e96739 100644 --- a/fpdfsdk/BUILD.gn +++ b/fpdfsdk/BUILD.gn @@ -22,6 +22,8 @@ source_set("fpdfsdk") { "epdf_base_document.cpp", # EmbedPDF: captures of what a layer write loses (EPDF*_Export*Raw). "epdf_capture.cpp", + # EmbedPDF: the catalog's metadata stream (public/epdf_catalog.h). + "epdf_catalog.cpp", # EmbedPDF: implements EPDFFont_* runtime font registration APIs. "epdf_font.cpp", # EmbedPDF: session-free AcroForm model snapshot (EPDFForm_* APIs). @@ -251,6 +253,8 @@ pdfium_embeddertest_source_set("embeddertests") { "epdf_edit_test_util.h", # EmbedPDF: covers captures and their imports (EPDF*_Export*Raw). "epdf_capture_embeddertest.cpp", + # EmbedPDF: covers the catalog's metadata stream (EPDFCatalog_*Metadata). + "epdf_catalog_embeddertest.cpp", # EmbedPDF: covers appearance states and modes, widgets in page renders, # and the check box and radio button symbols. "epdf_appearance_state_embeddertest.cpp", diff --git a/fpdfsdk/epdf_c_api_test.c b/fpdfsdk/epdf_c_api_test.c index 99c0ec32c5..75f57ca61c 100644 --- a/fpdfsdk/epdf_c_api_test.c +++ b/fpdfsdk/epdf_c_api_test.c @@ -234,6 +234,8 @@ int CheckEmbedPDFCApi(void) { // epdf_catalog.h CHK(EPDFCatalog_GetLanguage); + CHK(EPDFCatalog_GetMetadata); + CHK(EPDFCatalog_SetMetadata); // epdf_digest.h CHK(EPDF_DigestBuffer); diff --git a/fpdfsdk/epdf_catalog.cpp b/fpdfsdk/epdf_catalog.cpp new file mode 100644 index 0000000000..a155660fb6 --- /dev/null +++ b/fpdfsdk/epdf_catalog.cpp @@ -0,0 +1,96 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include "core/fpdfapi/parser/cpdf_dictionary.h" +#include "core/fpdfapi/parser/cpdf_document.h" +#include "core/fpdfapi/parser/cpdf_document_view_scope.h" +#include "core/fpdfapi/parser/cpdf_name.h" +#include "core/fpdfapi/parser/cpdf_reference.h" +#include "core/fpdfapi/parser/cpdf_stream.h" +#include "core/fxcrt/compiler_specific.h" +#include "core/fxcrt/retain_ptr.h" +#include "core/fxcrt/span.h" +#include "fpdfsdk/cpdfsdk_helpers.h" +#include "public/epdf_catalog.h" + +FPDF_EXPORT unsigned long FPDF_CALLCONV +EPDFCatalog_GetMetadata(FPDF_DOCUMENT document, + void* buffer, + unsigned long length) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(document); + if (!doc) { + return 0; + } + // References a base document's objects hold resolve through the layer. + CPDF_DocumentViewScope document_view(doc); + const CPDF_Dictionary* catalog = doc->GetRoot(); + RetainPtr stream = + catalog ? catalog->GetStreamFor("Metadata") : nullptr; + if (!stream) { + return 0; + } + if (!buffer) { + length = 0; + } + // SAFETY: required from caller. + return DecodeStreamMaybeCopyAndReturnLength( + std::move(stream), + UNSAFE_BUFFERS(pdfium::span(static_cast(buffer), + static_cast(length)))); +} + +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFCatalog_SetMetadata(FPDF_DOCUMENT document, + const void* data, + unsigned long size) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(document); + if (!doc || (!data && size > 0)) { + return false; + } + CPDF_DocumentViewScope document_view(doc); + const CPDF_Dictionary* catalog = doc->GetRoot(); + if (!catalog) { + return false; + } + if (!data) { + if (!catalog->KeyExist("Metadata")) { + return true; + } + RetainPtr writable = doc->GetMutableRoot(); + if (!writable) { + return false; + } + writable->RemoveFor("Metadata"); + return true; + } + + // The stream is written through its object number, so on a layer the + // write lands in the layer's copy and never in the base's. + RetainPtr reference = + ToReference(catalog->GetObjectFor("Metadata")); + RetainPtr stream = + reference + ? ToStream(doc->GetMutableIndirectObject(reference->GetRefObjNum())) + : nullptr; + if (!stream) { + RetainPtr writable = doc->GetMutableRoot(); + if (!writable) { + return false; + } + stream = doc->NewIndirect( + pdfium::MakeRetain()); + writable->SetNewFor("Metadata", doc, stream->GetObjNum()); + } + // SAFETY: required from caller. + stream->SetDataAndRemoveFilter(UNSAFE_BUFFERS( + pdfium::span(static_cast(data), + static_cast(size)))); + RetainPtr dict = stream->GetMutableDict(); + dict->SetNewFor("Type", "Metadata"); + dict->SetNewFor("Subtype", "XML"); + return true; +} diff --git a/fpdfsdk/epdf_catalog_embeddertest.cpp b/fpdfsdk/epdf_catalog_embeddertest.cpp new file mode 100644 index 0000000000..eab8ad4f33 --- /dev/null +++ b/fpdfsdk/epdf_catalog_embeddertest.cpp @@ -0,0 +1,276 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#include "public/epdf_catalog.h" + +#include +#include +#include +#include + +#include "core/fpdfapi/parser/cpdf_dictionary.h" +#include "core/fpdfapi/parser/cpdf_document.h" +#include "core/fpdfapi/parser/cpdf_document_view_scope.h" +#include "core/fpdfapi/parser/cpdf_reference.h" +#include "core/fpdfapi/parser/cpdf_stream.h" +#include "fpdfsdk/cpdfsdk_helpers.h" +#include "fpdfsdk/epdf_edit_test_util.h" +#include "public/cpp/fpdf_scopers.h" +#include "public/epdf_layer.h" +#include "public/epdf_save.h" +#include "public/fpdf_save.h" +#include "public/fpdfview.h" +#include "testing/embedder_test.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +constexpr char kOldPacket[] = + "" + "" + "OLDPRODUCER" + ""; + +constexpr char kNewPacket[] = + "" + ""; + +std::string Hex(const std::string& bytes) { + static constexpr char kDigits[] = "0123456789ABCDEF"; + std::string hex; + for (unsigned char byte : bytes) { + hex += kDigits[byte >> 4]; + hex += kDigits[byte & 0xf]; + } + return hex + ">"; +} + +// A one-page PDF whose catalog has a /Metadata stream (object 4) holding +// kOldPacket hex-encoded, so reading it applies a filter. +std::string PdfWithPacket() { + const std::string encoded = Hex(kOldPacket); + return MakePdf({ + "<< /Type /Catalog /Pages 2 0 R /Metadata 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>", + "<< /Type /Metadata /Subtype /XML /Filter /ASCIIHexDecode /Length " + + std::to_string(encoded.size()) + " >>\nstream\n" + encoded + + "\nendstream", + }); +} + +std::string PdfWithoutPacket() { + return MakePdf({ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>", + }); +} + +// A PDF file opened as a document, or as a layer over it. Both read +// `input`, which must outlive them. +struct OpenedPdf { + std::unique_ptr, + decltype(&EPDF_ReleaseBaseDocument)> + base{nullptr, EPDF_ReleaseBaseDocument}; + ScopedFPDFDocument doc; +}; + +OpenedPdf OpenPdf(const std::string& input, bool layer) { + OpenedPdf opened; + if (layer) { + opened.base.reset( + EPDF_LoadMemBaseDocument64(input.data(), input.size(), nullptr)); + if (opened.base) { + opened.doc.reset( + EPDFLayer_OpenLayer(opened.base.get(), nullptr, nullptr, nullptr)); + } + } else { + opened.doc.reset( + FPDF_LoadMemDocument64(input.data(), input.size(), nullptr)); + } + return opened; +} + +std::string MetadataOf(FPDF_DOCUMENT doc) { + const unsigned long size = EPDFCatalog_GetMetadata(doc, nullptr, 0); + std::string bytes(size, '\0'); + if (size > 0) { + EXPECT_EQ(size, EPDFCatalog_GetMetadata(doc, bytes.data(), size)); + } + return bytes; +} + +// Sets the packet; on a layer in a transaction, as the engine runs every +// write. +void SetPacket(FPDF_DOCUMENT doc, bool layer, const char* packet) { + if (layer) { + ASSERT_TRUE(EPDFLayer_BeginTransaction(doc)); + } + ASSERT_TRUE(EPDFCatalog_SetMetadata( + doc, packet, packet ? std::char_traits::length(packet) : 0)); + if (layer) { + ASSERT_TRUE(EPDFLayer_CommitTransaction(doc)); + } +} + +// The catalog's /Metadata entry: its object number, and its stream. +struct MetadataEntry { + uint32_t objnum = 0; + RetainPtr stream; +}; + +MetadataEntry EntryOf(FPDF_DOCUMENT doc) { + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc); + CPDF_DocumentViewScope document_view(pdf); + MetadataEntry entry; + RetainPtr reference = + ToReference(pdf->GetRoot()->GetObjectFor("Metadata")); + if (reference) { + entry.objnum = reference->GetRefObjNum(); + } + entry.stream = pdf->GetRoot()->GetStreamFor("Metadata"); + return entry; +} + +} // namespace + +class EPDFCatalogEmbedderTest : public EmbedderTest {}; + +TEST_F(EPDFCatalogEmbedderTest, GetMetadataReadsThePacketDecoded) { + const std::string input = PdfWithPacket(); + for (bool layer : {false, true}) { + SCOPED_TRACE(layer ? "layer" : "document"); + OpenedPdf opened = OpenPdf(input, layer); + ASSERT_TRUE(opened.doc); + + const unsigned long size = std::char_traits::length(kOldPacket); + EXPECT_EQ(size, EPDFCatalog_GetMetadata(opened.doc.get(), nullptr, 0)); + // A buffer that can't hold every byte gets none. + std::string small(size - 1, '-'); + EXPECT_EQ(size, EPDFCatalog_GetMetadata(opened.doc.get(), small.data(), + small.size())); + EXPECT_EQ(std::string(size - 1, '-'), small); + EXPECT_EQ(kOldPacket, MetadataOf(opened.doc.get())); + } + + const std::string empty = PdfWithoutPacket(); + OpenedPdf without = OpenPdf(empty, false); + ASSERT_TRUE(without.doc); + EXPECT_EQ(0u, EPDFCatalog_GetMetadata(without.doc.get(), nullptr, 0)); + EXPECT_EQ(0u, EPDFCatalog_GetMetadata(nullptr, nullptr, 0)); +} + +TEST_F(EPDFCatalogEmbedderTest, SetMetadataReplacesThePacketInPlace) { + const std::string input = PdfWithPacket(); + for (bool layer : {false, true}) { + for (FPDF_DWORD flags : {FPDF_NO_INCREMENTAL, FPDF_INCREMENTAL}) { + SCOPED_TRACE(layer ? "layer" : "document"); + SCOPED_TRACE(flags == FPDF_INCREMENTAL ? "incremental" : "rewrite"); + OpenedPdf opened = OpenPdf(input, layer); + ASSERT_TRUE(opened.doc); + ASSERT_NO_FATAL_FAILURE(SetPacket(opened.doc.get(), layer, kNewPacket)); + + EXPECT_EQ(kNewPacket, MetadataOf(opened.doc.get())); + MetadataEntry entry = EntryOf(opened.doc.get()); + EXPECT_EQ(4u, entry.objnum); + ASSERT_TRUE(entry.stream); + EXPECT_FALSE(entry.stream->HasFilter()); + EXPECT_EQ("Metadata", entry.stream->GetDict()->GetNameFor("Type")); + EXPECT_EQ("XML", entry.stream->GetDict()->GetNameFor("Subtype")); + + ClearString(); + ASSERT_TRUE(FPDF_SaveAsCopy(opened.doc.get(), this, flags)); + const std::string saved = GetString(); + // Written as it is, so XMP readers that scan the file find it. + EXPECT_NE(std::string::npos, saved.find(kNewPacket)); + if (flags == FPDF_NO_INCREMENTAL) { + EXPECT_EQ(std::string::npos, saved.find(Hex(kOldPacket))); + } + ScopedSavedDoc reopened = OpenScopedSavedDocument(); + ASSERT_TRUE(reopened); + EXPECT_EQ(kNewPacket, MetadataOf(reopened.get())); + } + } +} + +TEST_F(EPDFCatalogEmbedderTest, SetMetadataOnALayerLeavesItsBaseAlone) { + const std::string input = PdfWithPacket(); + std::unique_ptr, + decltype(&EPDF_ReleaseBaseDocument)> + base(EPDF_LoadMemBaseDocument64(input.data(), input.size(), nullptr), + EPDF_ReleaseBaseDocument); + ASSERT_TRUE(base); + ScopedFPDFDocument edited( + EPDFLayer_OpenLayer(base.get(), nullptr, nullptr, nullptr)); + ScopedFPDFDocument sibling( + EPDFLayer_OpenLayer(base.get(), nullptr, nullptr, nullptr)); + ASSERT_TRUE(edited); + ASSERT_TRUE(sibling); + ASSERT_NO_FATAL_FAILURE(SetPacket(edited.get(), true, kNewPacket)); + EXPECT_EQ(kNewPacket, MetadataOf(edited.get())); + EXPECT_EQ(kOldPacket, MetadataOf(sibling.get())); + + // The delta carries the new packet back onto the base. + ClearString(); + EPDFLayerSaveStatus status = EPDFLayerSaveStatus_kSaveFailed; + ASSERT_TRUE(EPDFLayer_SaveDelta(edited.get(), this, &status)); + std::string delta = GetString(); + FPDF_FILEACCESS delta_access = {}; + delta_access.m_FileLen = delta.size(); + delta_access.m_GetBlock = GetBlockFromString; + delta_access.m_Param = δ + ScopedFPDFDocument replayed( + EPDFLayer_OpenLayer(base.get(), &delta_access, nullptr, nullptr)); + ASSERT_TRUE(replayed); + EXPECT_EQ(kNewPacket, MetadataOf(replayed.get())); +} + +TEST_F(EPDFCatalogEmbedderTest, SetMetadataMakesAStreamWhenThereIsNone) { + const std::string input = PdfWithoutPacket(); + for (bool layer : {false, true}) { + SCOPED_TRACE(layer ? "layer" : "document"); + OpenedPdf opened = OpenPdf(input, layer); + ASSERT_TRUE(opened.doc); + ASSERT_NO_FATAL_FAILURE(SetPacket(opened.doc.get(), layer, kNewPacket)); + EXPECT_EQ(kNewPacket, MetadataOf(opened.doc.get())); + MetadataEntry entry = EntryOf(opened.doc.get()); + EXPECT_GT(entry.objnum, 3u); + + ClearString(); + ASSERT_TRUE( + FPDF_SaveAsCopy(opened.doc.get(), this, FPDF_NO_INCREMENTAL)); + EXPECT_NE(std::string::npos, GetString().find(kNewPacket)); + ScopedSavedDoc reopened = OpenScopedSavedDocument(); + ASSERT_TRUE(reopened); + EXPECT_EQ(kNewPacket, MetadataOf(reopened.get())); + } +} + +TEST_F(EPDFCatalogEmbedderTest, SetMetadataWithoutDataRemovesThePacket) { + const std::string input = PdfWithPacket(); + for (bool layer : {false, true}) { + SCOPED_TRACE(layer ? "layer" : "document"); + OpenedPdf opened = OpenPdf(input, layer); + ASSERT_TRUE(opened.doc); + EXPECT_FALSE(EPDFCatalog_SetMetadata(opened.doc.get(), nullptr, 5)); + EXPECT_EQ(kOldPacket, MetadataOf(opened.doc.get())); + + ASSERT_NO_FATAL_FAILURE(SetPacket(opened.doc.get(), layer, nullptr)); + EXPECT_EQ(0u, EPDFCatalog_GetMetadata(opened.doc.get(), nullptr, 0)); + EXPECT_EQ(0u, EntryOf(opened.doc.get()).objnum); + // Removing what isn't there is no failure. + ASSERT_NO_FATAL_FAILURE(SetPacket(opened.doc.get(), layer, nullptr)); + + ClearString(); + ASSERT_TRUE( + FPDF_SaveAsCopy(opened.doc.get(), this, FPDF_NO_INCREMENTAL)); + EXPECT_EQ(std::string::npos, GetString().find(Hex(kOldPacket))); + ScopedSavedDoc reopened = OpenScopedSavedDocument(); + ASSERT_TRUE(reopened); + EXPECT_EQ(0u, EPDFCatalog_GetMetadata(reopened.get(), nullptr, 0)); + } +} diff --git a/public/epdf_catalog.h b/public/epdf_catalog.h index fc32813b32..a889621c1b 100644 --- a/public/epdf_catalog.h +++ b/public/epdf_catalog.h @@ -11,7 +11,8 @@ extern "C" { #endif // __cplusplus -// EmbedPDF's additions to public/fpdf_catalog.h: the catalog's language. +// EmbedPDF's additions to public/fpdf_catalog.h: the catalog's language and +// its metadata stream. // Experimental EmbedPDF API. // Gets the Catalog /Lang value as a UTF-16LE string with trailing NUL. @@ -31,6 +32,32 @@ extern "C" { FPDF_EXPORT unsigned long FPDF_CALLCONV EPDFCatalog_GetLanguage(FPDF_DOCUMENT document, void* buffer, unsigned long length); +// Experimental EmbedPDF API. +// The catalog's /Metadata stream (the document's XMP packet) as it reads: its +// bytes with every filter applied. What they say is the caller's to read. +// +// document - handle to a document. +// buffer - destination for the bytes (can be NULL to probe). +// length - size of |buffer| in bytes. +// +// Returns the number of bytes, copying them only when |buffer| holds them +// all. 0 when the catalog has no /Metadata stream, or an empty one. +FPDF_EXPORT unsigned long FPDF_CALLCONV +EPDFCatalog_GetMetadata(FPDF_DOCUMENT document, void* buffer, unsigned long length); + +// Experimental EmbedPDF API. +// Replaces the bytes of the catalog's /Metadata stream with the |size| bytes +// at |data|, unfiltered, in a stream marked /Type /Metadata /Subtype /XML (so +// a save leaves them uncompressed, as XMP readers expect). The stream keeps +// its object number; a catalog without one gets a new stream. NULL |data| +// removes /Metadata from the catalog. The bytes are not checked: what they +// say is the caller's. +// +// Returns false for an invalid |document|, or for NULL |data| with a +// non-zero |size|. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFCatalog_SetMetadata(FPDF_DOCUMENT document, const void* data, unsigned long size); + #ifdef __cplusplus } // extern "C" #endif // __cplusplus From c0da5ca7fdec3e899c28ec28df9939b9fcbd90cb Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Wed, 28 Oct 2026 15:27:13 +0200 Subject: [PATCH 2/3] Write a filtered metadata stream decoded A save writes the metadata stream unencoded, so XMP readers find it in the file's bytes. Upstream's CPDF_FlateEncoder decoded such a stream, then wrote its encoded bytes without the /Filter that decodes them, so a rewrite turned a compressed or hex-encoded packet into noise. It now writes the decoded bytes, without /Filter and /DecodeParms; bytes that don't decode are written as they are, filter and all. --- core/fpdfapi/parser/cpdf_flateencoder.cpp | 27 ++++++++++++++++------- fpdfsdk/epdf_catalog_embeddertest.cpp | 18 +++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/core/fpdfapi/parser/cpdf_flateencoder.cpp b/core/fpdfapi/parser/cpdf_flateencoder.cpp index 098e71167c..85fba3918b 100644 --- a/core/fpdfapi/parser/cpdf_flateencoder.cpp +++ b/core/fpdfapi/parser/cpdf_flateencoder.cpp @@ -26,14 +26,25 @@ CPDF_FlateEncoder::CPDF_FlateEncoder(RetainPtr pStream, bool bHasFilter = pStream->HasFilter(); if (bHasFilter && !bFlateEncode) { - auto pDestAcc = pdfium::MakeRetain(pStream); - pDestAcc->LoadAllDataFiltered(); - - data_ = acc_->GetSpan(); - cloned_dict_ = ToDictionary(pStream->GetDict()->Clone()); - cloned_dict_->RemoveFor("Filter"); - DCHECK(!dict_); - return; + // EmbedPDF(bug-fix): a stream written unencoded (the metadata stream, + // which XMP readers look for in the file's bytes) is written decoded. + // Upstream decoded it, then wrote its encoded bytes without the filter + // that decodes them. Bytes that don't decode stay as they are, filter and + // all. + std::optional decoders = GetDecoderArray(pStream->GetDict()); + std::optional decoded; + if (decoders.has_value() && !decoders.value().empty()) { + decoded = PDF_DataDecode(acc_->GetSpan(), 0, false, decoders.value()); + } + if (decoded.has_value() && decoded.value().image_encoding.IsEmpty() && + !decoded.value().data.empty()) { + data_ = std::move(decoded.value().data); + cloned_dict_ = ToDictionary(pStream->GetDict()->Clone()); + cloned_dict_->RemoveFor("Filter"); + cloned_dict_->RemoveFor(pdfium::stream::kDecodeParms); + DCHECK(!dict_); + return; + } } if (bHasFilter || !bFlateEncode) { data_ = acc_->GetSpan(); diff --git a/fpdfsdk/epdf_catalog_embeddertest.cpp b/fpdfsdk/epdf_catalog_embeddertest.cpp index eab8ad4f33..7b8f4b5c5a 100644 --- a/fpdfsdk/epdf_catalog_embeddertest.cpp +++ b/fpdfsdk/epdf_catalog_embeddertest.cpp @@ -250,6 +250,24 @@ TEST_F(EPDFCatalogEmbedderTest, SetMetadataMakesAStreamWhenThereIsNone) { } } +// A save writes the packet decoded, without its filter, where XMP readers +// find it in the file's bytes. +TEST_F(EPDFCatalogEmbedderTest, SavesWriteAFilteredPacketDecoded) { + const std::string input = PdfWithPacket(); + for (bool layer : {false, true}) { + SCOPED_TRACE(layer ? "layer" : "document"); + OpenedPdf opened = OpenPdf(input, layer); + ASSERT_TRUE(opened.doc); + ClearString(); + ASSERT_TRUE( + FPDF_SaveAsCopy(opened.doc.get(), this, FPDF_NO_INCREMENTAL)); + EXPECT_NE(std::string::npos, GetString().find(kOldPacket)); + ScopedSavedDoc reopened = OpenScopedSavedDocument(); + ASSERT_TRUE(reopened); + EXPECT_EQ(kOldPacket, MetadataOf(reopened.get())); + } +} + TEST_F(EPDFCatalogEmbedderTest, SetMetadataWithoutDataRemovesThePacket) { const std::string input = PdfWithPacket(); for (bool layer : {false, true}) { From 59c98e46335fe58b5e974563643c3b4678648d49 Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Wed, 28 Oct 2026 15:36:26 +0200 Subject: [PATCH 3/3] Encrypt the metadata stream when saving an encrypted file Upstream's CPDF_Stream::WriteTo never encrypted a /Type /Metadata stream, while the file's /EncryptMetadata (true unless it says false) tells readers to decrypt it. A save of an encrypted file, rewrite or incremental, wrote its XMP in the clear and every reader decrypted it into noise. The metadata stream is now encrypted like any other; the creator gives no encryptor only to the document's metadata stream of a file that keeps it in the clear, which is the one the parser doesn't decrypt. --- core/fpdfapi/edit/cpdf_creator.cpp | 16 ++++++++- core/fpdfapi/edit/cpdf_creator.h | 3 ++ core/fpdfapi/parser/cpdf_stream.cpp | 7 +++- fpdfsdk/epdf_catalog_embeddertest.cpp | 47 +++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/core/fpdfapi/edit/cpdf_creator.cpp b/core/fpdfapi/edit/cpdf_creator.cpp index ab2693011f..02ce2f1cf3 100644 --- a/core/fpdfapi/edit/cpdf_creator.cpp +++ b/core/fpdfapi/edit/cpdf_creator.cpp @@ -256,7 +256,8 @@ bool CPDF_Creator::WriteIndirectObj(uint32_t objnum, const CPDF_Object* pObj) { } std::unique_ptr encryptor; - if (GetCryptoHandler() && pObj != encrypt_dict_) { + // EmbedPDF(bug-fix): see IsClearMetadata(). + if (GetCryptoHandler() && pObj != encrypt_dict_ && !IsClearMetadata(objnum)) { encryptor = std::make_unique(GetCryptoHandler(), objnum, generation); } @@ -268,6 +269,19 @@ bool CPDF_Creator::WriteIndirectObj(uint32_t objnum, const CPDF_Object* pObj) { return archive_->WriteString("\r\nendobj\r\n"); } +// EmbedPDF(bug-fix): the document's metadata stream stays in the clear when +// the encryption dictionary says /EncryptMetadata false, as the parser reads +// it. Every other stream is encrypted, other metadata streams included. +bool CPDF_Creator::IsClearMetadata(uint32_t objnum) const { + if (!security_handler_ || security_handler_->IsMetadataEncrypted()) { + return false; + } + const CPDF_Dictionary* root = document_->GetRoot(); + RetainPtr metadata = + root ? ToReference(root->GetObjectFor("Metadata")) : nullptr; + return metadata && metadata->GetRefObjNum() == objnum; +} + // EmbedPDF(save-generations): the generation an incremental save writes for an // object: the layer's version's, or the input file's. A full save writes 0. uint32_t CPDF_Creator::GetObjectGeneration(uint32_t object_number) const { diff --git a/core/fpdfapi/edit/cpdf_creator.h b/core/fpdfapi/edit/cpdf_creator.h index 1949ab13b3..ddefca81f1 100644 --- a/core/fpdfapi/edit/cpdf_creator.h +++ b/core/fpdfapi/edit/cpdf_creator.h @@ -141,6 +141,9 @@ class CPDF_Creator final : public CPDF_WriteContext { std::vector NewObjectNumbers() const; bool WriteNewObjs(); bool WriteIndirectObj(uint32_t objnum, const CPDF_Object* pObj); + // EmbedPDF(bug-fix): whether |objnum| is the document's metadata stream, + // kept in the clear by /EncryptMetadata false. + bool IsClearMetadata(uint32_t objnum) const; // EmbedPDF(layer-save): refuses an offset an entry can't hold. bool CheckEmittedOffset(FX_FILESIZE offset); // EmbedPDF(save-generations): the creator as CPDF_WriteContext, and diff --git a/core/fpdfapi/parser/cpdf_stream.cpp b/core/fpdfapi/parser/cpdf_stream.cpp index cc0c65a631..2203808c35 100644 --- a/core/fpdfapi/parser/cpdf_stream.cpp +++ b/core/fpdfapi/parser/cpdf_stream.cpp @@ -296,7 +296,12 @@ bool CPDF_Stream::WriteTo(IFX_ArchiveStream* archive, DataVector encrypted_data; pdfium::span data = encoder.GetSpan(); - if (encryptor && !is_metadata) { + // EmbedPDF(bug-fix): a metadata stream is encrypted like any other stream; + // the creator gives the one a file keeps in the clear (/EncryptMetadata + // false) no encryptor. Upstream never encrypted one, so a save of an + // encrypted file wrote its XMP in the clear, and readers decrypted it into + // noise. + if (encryptor) { encrypted_data = encryptor->Encrypt(data); data = encrypted_data; } diff --git a/fpdfsdk/epdf_catalog_embeddertest.cpp b/fpdfsdk/epdf_catalog_embeddertest.cpp index 7b8f4b5c5a..e74f3e39bc 100644 --- a/fpdfsdk/epdf_catalog_embeddertest.cpp +++ b/fpdfsdk/epdf_catalog_embeddertest.cpp @@ -18,6 +18,7 @@ #include "public/cpp/fpdf_scopers.h" #include "public/epdf_layer.h" #include "public/epdf_save.h" +#include "public/epdf_view.h" #include "public/fpdf_save.h" #include "public/fpdfview.h" #include "testing/embedder_test.h" @@ -268,6 +269,52 @@ TEST_F(EPDFCatalogEmbedderTest, SavesWriteAFilteredPacketDecoded) { } } +// A save of an encrypted file encrypts its packet with everything else +// (/EncryptMetadata defaults to true), so readers decrypt it back. +TEST_F(EPDFCatalogEmbedderTest, EncryptedSavesKeepThePacketReadable) { + const std::string input = PdfWithPacket(); + std::string encrypted; + { + OpenedPdf opened = OpenPdf(input, false); + ASSERT_TRUE(opened.doc); + ASSERT_TRUE(EPDF_SetEncryption(opened.doc.get(), "", "owner", + EPDF_PERM_PRINT)); + ClearString(); + ASSERT_TRUE( + FPDF_SaveAsCopy(opened.doc.get(), this, FPDF_NO_INCREMENTAL)); + encrypted = GetString(); + EXPECT_EQ(std::string::npos, encrypted.find("OLDPRODUCER")); + } + for (bool layer : {false, true}) { + for (FPDF_DWORD flags : {FPDF_NO_INCREMENTAL, FPDF_INCREMENTAL}) { + SCOPED_TRACE(layer ? "layer" : "document"); + SCOPED_TRACE(flags == FPDF_INCREMENTAL ? "incremental" : "rewrite"); + OpenedPdf opened = OpenPdf(encrypted, layer); + ASSERT_TRUE(opened.doc); + ASSERT_TRUE(EPDF_IsEncrypted(opened.doc.get())); + EXPECT_EQ(kOldPacket, MetadataOf(opened.doc.get())); + + // Saved again as it is. + ClearString(); + ASSERT_TRUE(FPDF_SaveAsCopy(opened.doc.get(), this, flags)); + { + ScopedSavedDoc reopened = OpenScopedSavedDocument(); + ASSERT_TRUE(reopened); + EXPECT_EQ(kOldPacket, MetadataOf(reopened.get())); + } + + // And with a new packet. + ASSERT_NO_FATAL_FAILURE(SetPacket(opened.doc.get(), layer, kNewPacket)); + ClearString(); + ASSERT_TRUE(FPDF_SaveAsCopy(opened.doc.get(), this, flags)); + EXPECT_EQ(std::string::npos, GetString().find(kNewPacket)); + ScopedSavedDoc reopened = OpenScopedSavedDocument(); + ASSERT_TRUE(reopened); + EXPECT_EQ(kNewPacket, MetadataOf(reopened.get())); + } + } +} + TEST_F(EPDFCatalogEmbedderTest, SetMetadataWithoutDataRemovesThePacket) { const std::string input = PdfWithPacket(); for (bool layer : {false, true}) {