diff --git a/CHANGELOG.md b/CHANGELOG.md index a15f365b..d48f9b0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). document reports on re-opening now include `Extract`. Documents written with `Extract` (the default, since `All` includes it) carry the same `/P` value as before. See Fixed, below, for why. (#397) +- **A preflight finding's message text changes for a named value longer than 32 characters, and for + any message longer than 1024 characters.** Ten rule sites that quote a producer-supplied name (a + stream `/Filter`, an action `/S` or named action `/N`, an annotation `/Subtype` or an extra `/AP` + key, a composite font's `/Encoding` CMap name in two rules, a `/RoleMap` key, a `/Perms` key, a + blend mode) now keep the first 32 characters followed by `... (N bytes)`, and every + `PreflightAssertion.Message` past 1024 characters ends in `... (N chars)`. `vellum-preflight`'s + text, JSON and SARIF output carries the same text. Verdicts, rule ids, clauses and assertion + counts are unaffected, and at these ten sites a message whose named value is 32 characters or + shorter is byte-identical to before. See Fixed, below, for why. (#403) ### Fixed @@ -131,6 +140,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). writes `-4` instead of `-516`, the same value as `All`; at `/R` 6 the `/Perms` seal (Algorithm 10) changes with it, since it seals the same `/P` value. A document written without `Extract` therefore reads back with `Extract` included in `PdfEncryptionInfo.Permissions`. (#397) +- **A preflight finding could retain a producer-sized string for the result's lifetime.** A rule + that names the offending value in its message interpolated it whole, so one oversized `/Filter` + name shared by 400 pages kept 705.7 MiB (GC delta) of message text alive from a 990 KB file + (measured in #403). Every `PreflightAssertion.Message` is now cut at 1024 characters with its + length appended, and the ten sites that quoted a name whole keep the first 32 characters plus the + byte count; the remaining sites that interpolate a producer value rely on the 1024-character cut + and are listed in #405. What this bounds is the retained result; the transient allocation the + Reader makes while building its own exception message for an unknown filter is unchanged and + tracked in #406. (#403) ## [2.3.0] - 2026-09-01 diff --git a/src/VellumPdf.Conformance/PdfPreflight.cs b/src/VellumPdf.Conformance/PdfPreflight.cs index 302d8370..cd3a603a 100644 --- a/src/VellumPdf.Conformance/PdfPreflight.cs +++ b/src/VellumPdf.Conformance/PdfPreflight.cs @@ -342,6 +342,10 @@ internal static PreflightResult Validate(PdfDocumentReader reader, PdfConformanc // starts), but should a future lazily-decoded reader feature let a rule raise either, // "cannot evaluate" and "wrong password" are both a distinct signal that should // propagate to the caller rather than be reported as a conformance violation. + // + // ex.Message can itself quote a whole oversized token (a filter name, a raw + // parser token); PreflightContext.Report bounds the retained message at + // MaxMessageChars, so that whole is not retained here either (#403). context.Report(rule.RuleId, rule.Clause, PreflightSeverity.Error, $"Rule evaluation failed: {ex.Message}"); } diff --git a/src/VellumPdf.Conformance/PreflightAssertion.cs b/src/VellumPdf.Conformance/PreflightAssertion.cs index d8254b5b..5fa2cfed 100644 --- a/src/VellumPdf.Conformance/PreflightAssertion.cs +++ b/src/VellumPdf.Conformance/PreflightAssertion.cs @@ -26,7 +26,15 @@ public sealed class PreflightAssertion /// The severity of the finding. public PreflightSeverity Severity { get; } - /// A human-readable description of the finding. + /// + /// A human-readable description of the finding. Not a compatibility contract: the wording may + /// change across releases, so a caller that needs to branch on the condition should switch on + /// instead of matching text here. The text is bounded: past 1024 + /// characters it is replaced by ... (N chars) with N the full length. Ten sites + /// additionally excerpt the producer name they quote, keeping an oversized one to its first + /// 32 characters followed by ... (N bytes); every other producer value is bounded by + /// the 1024-character cut alone. + /// public string Message { get; } /// diff --git a/src/VellumPdf.Conformance/Rules/Actions/ActionRule.cs b/src/VellumPdf.Conformance/Rules/Actions/ActionRule.cs index e489bb68..4045ce3c 100644 --- a/src/VellumPdf.Conformance/Rules/Actions/ActionRule.cs +++ b/src/VellumPdf.Conformance/Rules/Actions/ActionRule.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using VellumPdf.Core; +using VellumPdf.Reader; namespace VellumPdf.Conformance.Rules.Actions; @@ -137,7 +138,8 @@ private void CheckAction(PreflightContext context, PdfObject? actionObj, HashSet s is null ? "An action dictionary has no /S action-type key (only GoTo, GoToR, GoToE, Thread, " + "URI, Named, and SubmitForm are permitted in PDF/A)." - : $"The action type /{s.Value} is not permitted in PDF/A."); + : $"The action type /{DiagnosticExcerpt.Quote(s.Value)} is not permitted " + + "in PDF/A."); } else if (s.Value == "Named" && context.Resolve(action.Get(_n)) is PdfName named @@ -148,8 +150,8 @@ s is null "ISO19005-2:6.5.1-named-action", "ISO 19005-2:2011, 6.5.1", PreflightSeverity.Error, - $"The named action /{named.Value} is not permitted in PDF/A " - + "(only NextPage, PrevPage, FirstPage, and LastPage are allowed)."); + $"The named action /{DiagnosticExcerpt.Quote(named.Value)} is not permitted " + + "in PDF/A (only NextPage, PrevPage, FirstPage, and LastPage are allowed)."); } // /Next is either a single action or an array of actions executed afterwards. diff --git a/src/VellumPdf.Conformance/Rules/Annotations/AnnotationRule.cs b/src/VellumPdf.Conformance/Rules/Annotations/AnnotationRule.cs index 17525740..d857f99f 100644 --- a/src/VellumPdf.Conformance/Rules/Annotations/AnnotationRule.cs +++ b/src/VellumPdf.Conformance/Rules/Annotations/AnnotationRule.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using VellumPdf.Core; +using VellumPdf.Reader; namespace VellumPdf.Conformance.Rules.Annotations; @@ -63,7 +64,12 @@ public void Evaluate(PreflightContext context) continue; } - var label = subtype is null ? "An annotation" : $"A /{subtype} annotation"; + // Quoted here, once, because label is reused by the nine messages below; an unquoted + // oversized /Subtype would otherwise be cut by the sink in each of them instead of + // excerpted once (#403). + var label = subtype is null + ? "An annotation" + : $"A /{DiagnosticExcerpt.Quote(subtype)} annotation"; // §6.3.2: a /Popup annotation is driven by its parent and is exempt from the annotation // flag requirements (it need not even carry an /F). Every other annotation's flags are @@ -95,7 +101,8 @@ public void Evaluate(PreflightContext context) { apHasOnlyN = false; context.Report(RuleId, Clause, PreflightSeverity.Error, - $"{label}'s appearance dictionary (/AP) shall contain only the /N entry (found /{entry.Key.Value})."); + $"{label}'s appearance dictionary (/AP) shall contain only the /N " + + $"entry (found /{DiagnosticExcerpt.Quote(entry.Key.Value)})."); break; } diff --git a/src/VellumPdf.Conformance/Rules/Fonts/FontStructureRule.cs b/src/VellumPdf.Conformance/Rules/Fonts/FontStructureRule.cs index 511d70d8..3730fd65 100644 --- a/src/VellumPdf.Conformance/Rules/Fonts/FontStructureRule.cs +++ b/src/VellumPdf.Conformance/Rules/Fonts/FontStructureRule.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using VellumPdf.Core; +using VellumPdf.Reader; namespace VellumPdf.Conformance.Rules.Fonts; @@ -399,8 +400,9 @@ private void CheckCMapEncoding(PreflightContext context, PdfDictionary font) return; if (context.Resolve(font.Get(_encoding)) is PdfName name && !_predefinedCMaps.Contains(name.Value)) Report(context, "6.2.11.3.3-cmap-name", "ISO 19005-2:2011, 6.2.11.3.3", - $"A composite font's /Encoding names the CMap /{name.Value}, which is neither one of the " - + "predefined CMaps nor an embedded CMap stream."); + $"A composite font's /Encoding names the CMap " + + $"/{DiagnosticExcerpt.Quote(name.Value)}, which is neither one of the predefined " + + "CMaps nor an embedded CMap stream."); } // §6.2.11.3.1-1: the CIDSystemInfo of a composite font's descendant CIDFont and its CMap must be diff --git a/src/VellumPdf.Conformance/Rules/PreflightContext.cs b/src/VellumPdf.Conformance/Rules/PreflightContext.cs index 070802ee..e61cfd9e 100644 --- a/src/VellumPdf.Conformance/Rules/PreflightContext.cs +++ b/src/VellumPdf.Conformance/Rules/PreflightContext.cs @@ -515,11 +515,40 @@ public IEnumerable EnumerateStreams() /// public ReadOnlyMemory DecryptedRawBody(ParsedStream stream) => Reader.DecryptedStreamView(stream).RawBody; + /// + /// The longest message a retains. A message identifies a + /// finding; it carries at most an excerpt of an oversized producer value, never the whole of + /// one. Many rules interpolate a name, a string or a keyword the document controls, and + /// ISO 32000-2 Annex C.1 sets no bound on any of those ("In general, this PDF standard does + /// not restrict the size or quantity of things described in the PDF file format"), so + /// without this cut one + /// 900,000-byte /Filter name shared by 400 pages retained 705.7 MiB (GC delta) of message text + /// from a 990 KB file (measured in #403). 1024 characters is roughly twice the longest sentence + /// any rule composes on its own (522 characters, A2aContentItemTaggingRule) and short enough + /// that a result list of thousands of findings stays a few megabytes. + /// + /// This cut is the only bound most messages have. Ten sites whose message names a producer + /// value (a /Filter, an action type, a named action, an annotation /Subtype or /AP key, a + /// composite font's /Encoding CMap name in two rules, a /RoleMap or /Perms key, a blend mode) + /// additionally excerpt it through the Reader's before + /// interpolating, so the sentence keeps its shape; every other producer-controlled + /// interpolation is cut mid-value here when the value is oversized (#405 lists them). The two + /// differ in what they can assume: a parsed from a document is Latin-1 + /// (one character per byte, never a surrogate pair), so + /// slices freely and counts bytes, while this cut + /// sees text decoded from UTF-16BE too and has to step around a surrogate pair. + /// + /// + internal const int MaxMessageChars = 1024; + /// Records a finding for the current validation pass. /// Stable rule identifier (typically the rule's ). /// Specification clause citation. /// The finding's severity. - /// Human-readable description. + /// Human-readable description. Text past + /// characters is replaced by ... (N chars), N being the full length in characters + /// (UTF-16 code units). One character less is kept when the cut would split a surrogate + /// pair. /// Optional "N 0 R" object location. public void Report( string ruleId, @@ -527,5 +556,20 @@ public void Report( PreflightSeverity severity, string message, string? objectRef = null) - => _assertions.Add(new PreflightAssertion(ruleId, clause, severity, message, objectRef)); + { + if (message is { Length: > MaxMessageChars }) + { + // A message can end mid-surrogate-pair when the producer value came from UTF-16BE + // text (A2aLangSyntaxRule, UaLangSyntaxRule, XmpPacket): cutting inside the pair would + // leave a lone high surrogate in the retained string, which the CLI's SARIF writer + // (Formatter.WriteSarif) would then have to serialize (#403). + var cut = MaxMessageChars; + if (char.IsHighSurrogate(message[cut - 1])) + cut--; + message = string.Concat(message.AsSpan(0, cut), "... (", message.Length.ToString(), + " chars)"); + } + + _assertions.Add(new PreflightAssertion(ruleId, clause, severity, message, objectRef)); + } } diff --git a/src/VellumPdf.Conformance/Rules/Structure/LogicalStructureRule.cs b/src/VellumPdf.Conformance/Rules/Structure/LogicalStructureRule.cs index 78930086..73c03e62 100644 --- a/src/VellumPdf.Conformance/Rules/Structure/LogicalStructureRule.cs +++ b/src/VellumPdf.Conformance/Rules/Structure/LogicalStructureRule.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using VellumPdf.Core; +using VellumPdf.Reader; namespace VellumPdf.Conformance.Rules.Structure; @@ -69,7 +70,8 @@ private void CheckRoleMapAcyclic(PreflightContext context, PdfDictionary roleMap RuleId, Clause, PreflightSeverity.Error, - $"The structure tree /RoleMap entry /{entry.Key.Value} shall map to a name."); + $"The structure tree /RoleMap entry " + + $"/{DiagnosticExcerpt.Quote(entry.Key.Value)} shall map to a name."); } } diff --git a/src/VellumPdf.Conformance/Rules/Structure/PermissionsRule.cs b/src/VellumPdf.Conformance/Rules/Structure/PermissionsRule.cs index 25a730d1..31a1a1f0 100644 --- a/src/VellumPdf.Conformance/Rules/Structure/PermissionsRule.cs +++ b/src/VellumPdf.Conformance/Rules/Structure/PermissionsRule.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using VellumPdf.Core; +using VellumPdf.Reader; namespace VellumPdf.Conformance.Rules.Structure; @@ -39,7 +40,8 @@ public void Evaluate(PreflightContext context) continue; context.Report( RuleId, Clause, PreflightSeverity.Error, - $"The permissions dictionary contains the key /{entry.Key.Value}; only /UR3 and /DocMDP " + $"The permissions dictionary contains the key " + + $"/{DiagnosticExcerpt.Quote(entry.Key.Value)}; only /UR3 and /DocMDP " + "are permitted in PDF/A-2."); return; // One report suffices; the verdict is unaffected by the count. } diff --git a/src/VellumPdf.Conformance/Rules/Structure/StreamRule.cs b/src/VellumPdf.Conformance/Rules/Structure/StreamRule.cs index 3d455fc2..484250e8 100644 --- a/src/VellumPdf.Conformance/Rules/Structure/StreamRule.cs +++ b/src/VellumPdf.Conformance/Rules/Structure/StreamRule.cs @@ -150,11 +150,15 @@ private static bool CheckOneFilter(PreflightContext context, PdfName filterName, } // Any other filter name (including LZWDecode) is forbidden. + // The name is producer-controlled and unbounded (Annex C.1); the excerpt is what + // the retained message keeps of it (#403). NumericLimitsRule reports the length + // violation itself. context.Report( "ISO19005-2:6.1.7.2-1-filter", "ISO 19005-2:2011, 6.1.7.2", PreflightSeverity.Error, - $"A stream uses the /{filterName.Value} filter, which is not permitted in PDF/A-2."); + $"A stream uses the /{DiagnosticExcerpt.Quote(filterName.Value)} filter, " + + "which is not permitted in PDF/A-2."); return true; } diff --git a/src/VellumPdf.Conformance/Rules/Transparency/BlendModeRule.cs b/src/VellumPdf.Conformance/Rules/Transparency/BlendModeRule.cs index 603df571..46c3bb34 100644 --- a/src/VellumPdf.Conformance/Rules/Transparency/BlendModeRule.cs +++ b/src/VellumPdf.Conformance/Rules/Transparency/BlendModeRule.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using VellumPdf.Core; +using VellumPdf.Reader; namespace VellumPdf.Conformance.Rules.Transparency; @@ -98,7 +99,8 @@ private void ReportIfInvalid(PreflightContext context, PdfName blendMode) RuleId, Clause, PreflightSeverity.Error, - $"The blend mode /{blendMode.Value} is not one of the standard blend modes permitted in PDF/A-2."); + $"The blend mode /{DiagnosticExcerpt.Quote(blendMode.Value)} is not one of the " + + "standard blend modes permitted in PDF/A-2."); } } } diff --git a/src/VellumPdf.Conformance/Rules/Ua/UaCMapRule.cs b/src/VellumPdf.Conformance/Rules/Ua/UaCMapRule.cs index acd5407f..23cc6b08 100644 --- a/src/VellumPdf.Conformance/Rules/Ua/UaCMapRule.cs +++ b/src/VellumPdf.Conformance/Rules/Ua/UaCMapRule.cs @@ -81,8 +81,9 @@ public void Evaluate(PreflightContext context) RuleId1, Clause, PreflightSeverity.Error, - $"A composite font's /Encoding names the CMap /{nameVal.Value}, which is neither " - + "one of the predefined CMaps nor an embedded CMap stream (§7.21.3.3)."); + $"A composite font's /Encoding names the CMap " + + $"/{DiagnosticExcerpt.Quote(nameVal.Value)}, which is neither one of the " + + "predefined CMaps nor an embedded CMap stream (§7.21.3.3)."); } // Predefined name (including Identity-H/V): §7.21.3.3-2/-3 have no embedded program // to check — they do not apply to predefined-name encodings. diff --git a/tests/VellumPdf.Conformance.Tests/PreflightMessageBoundTests.cs b/tests/VellumPdf.Conformance.Tests/PreflightMessageBoundTests.cs new file mode 100644 index 00000000..ba914fe4 --- /dev/null +++ b/tests/VellumPdf.Conformance.Tests/PreflightMessageBoundTests.cs @@ -0,0 +1,555 @@ +// Copyright © Timothy van der Ham (@Tim81) +// SPDX-License-Identifier: Apache-2.0 + +using System.Text; +using VellumPdf.Canvas; +using VellumPdf.Conformance.Rules; +using VellumPdf.Core; +using VellumPdf.Document; +using VellumPdf.Fonts; +using VellumPdf.Reader; + +namespace VellumPdf.Conformance.Tests; + +/// +/// Value-level tests for the #403 message bound: cuts a +/// message at , and the ten sites that quoted a +/// producer whole (nine .Value interpolations and the annotation +/// label AnnotationRule builds from one) excerpt it through +/// first, so the sentence shape survives instead of +/// being cut mid-word by the sink. Every other producer-controlled interpolation relies on the +/// sink cut alone; #405 lists them. +/// +public sealed class PreflightMessageBoundTests +{ + // ── Fixture helpers (copied from PdfPreflightTests.AssemblePdf and kept independent; that ──── + // file is over 12,000 lines already and does not need a #403-only dependency added to it) ──── + + /// + /// A single indirect object for . For a non-stream object, + /// is the complete object text. For a stream object, is + /// the dictionary's inner entries only; the assembler wraps it and appends the /Length. + /// + private sealed record PdfObj(string Dict, byte[]? Stream = null); + + private static readonly PdfObj _pagesObj = new("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + private static readonly PdfObj _pageObj = new("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"); + + private static byte[] AssemblePdf( + IReadOnlyList objects, + string xmpConformance = "B") + { + var all = new List(objects); + + var metaObjNum = all.Count + 1; + var xmp = XmpBytes("2", xmpConformance); + all.Add(new PdfObj("/Type /Metadata /Subtype /XML", xmp)); + all[0] = all[0] with { Dict = InjectIntoDict(all[0].Dict, $"/Metadata {metaObjNum} 0 R") }; + + var ms = new MemoryStream(); + void W(string s) => ms.Write(Encoding.ASCII.GetBytes(s)); + + W("%PDF-1.7\n"); + ms.Write([(byte)'%', 0xE2, 0xE3, 0xCF, 0xD3, (byte)'\n']); + + var offsets = new int[all.Count + 1]; + for (var i = 0; i < all.Count; i++) + { + offsets[i + 1] = (int)ms.Position; + var n = i + 1; + if (all[i].Stream is { } body) + { + W($"{n} 0 obj\n<< {all[i].Dict} /Length {body.Length} >>\nstream\n"); + ms.Write(body); + W("\nendstream\nendobj\n"); + } + else + { + W($"{n} 0 obj\n{all[i].Dict}\nendobj\n"); + } + } + + var xrefOffset = (int)ms.Position; + var size = all.Count + 1; + W($"xref\n0 {size}\n"); + W($"{0:D10} 65535 f \n"); + for (var i = 1; i <= all.Count; i++) + W($"{offsets[i]:D10} 00000 n \n"); + W($"trailer\n<< /Size {size} /Root 1 0 R " + + "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>] >>\n"); + W($"startxref\n{xrefOffset}\n%%EOF\n"); + + return ms.ToArray(); + } + + private static string InjectIntoDict(string dict, string entry) + { + var i = dict.LastIndexOf(">>", StringComparison.Ordinal); + return i < 0 ? dict : string.Concat(dict[..i], entry, " ", dict[i..]); + } + + private static byte[] XmpBytes(string part, string conformance) + { + var xmp = + "" + + "" + + "" + + $"{part}" + + $"{conformance}" + + ""; + return Encoding.UTF8.GetBytes(xmp); + } + + /// + /// Builds a doc whose page's /Contents stream carries the given /Filter name. + /// + private static byte[] BuildOversizedFilterPdf(string filterName) + => AssemblePdf( + [ + new("<< /Type /Catalog /Pages 2 0 R >>"), + _pagesObj, + new("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>"), + new($"/Filter /{filterName}", []), + ]); + + /// + /// Same shape as PdfPreflightTests.BuildFontPdf: a Type0 font selected via Tf. + /// + private static byte[] BuildFontPdf(params PdfObj[] fontObjects) + { + var contentObjNum = 6 + fontObjects.Length; + var objects = new List + { + new("<< /Type /Catalog /Pages 2 0 R >>"), + _pagesObj, + new($"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources 4 0 R " + + $"/Contents {contentObjNum} 0 R >>"), + new("<< /Font 5 0 R >>"), + new("<< /F0 6 0 R >>"), + }; + objects.AddRange(fontObjects); + objects.Add(new PdfObj(string.Empty, Encoding.ASCII.GetBytes("BT /F0 12 Tf ET"))); + return AssemblePdf(objects); + } + + /// + /// Builds a UA-1 tagged document with one embedded (Type0) font selected via Tf, mirroring + /// OracleCorpus.WriterPdfTagged. Kept independent because that method is private to + /// OracleCorpus. + /// + private static byte[] BuildUa1TaggedPdf() + { + using var doc = new PdfDocument + { + Conformance = VellumPdf.Document.PdfConformance.PdfUA1, + Language = "en-US", + }; + doc.Info.Title = "PreflightMessageBoundTests fixture"; + var page = doc.AddPage(PageSize.A4); + var handle = doc.EmbedStandard14Font(Standard14.Helvetica); + doc.RegisterEmbeddedFontUsage(page, handle); + + var canvas = new PdfCanvas(page); + var mcid = canvas.BeginMarkedContent("P"); + canvas.BeginText().SetFontByName(handle.ResourceName, 12).SetTextMatrix(1, 0, 0, 1, 72, 720); + var gids = new ushort[7]; + var count = handle.GetGlyphIds("Tagged.", gids); + canvas.ShowGlyphs(gids.AsSpan(0, count)); + canvas.EndText(); + canvas.EndMarkedContent(); + canvas.Finish(); + + var p = new PdfStructElem("P") { Page = page, Mcid = mcid }; + var root = new PdfStructElem("Document"); + root.AddChild(p); + doc.RegisterStructElem(root); + + using var ms = new MemoryStream(); + doc.Save(ms); + return ms.ToArray(); + } + + /// + /// Rewrites the fixture's Type0 font's /Encoding to an oversized name via an incremental + /// update, mirroring OracleCorpus.Ua1BadCMapName's clone-and-AppendRevision step (that + /// method hardcodes a ten-character name, so it cannot produce an oversized one). + /// + private static byte[] BuildUa1OversizedCMapNamePdf(int nameBytes) + { + var baseline = BuildUa1TaggedPdf(); + using var reader = PdfReader.Open(baseline); + var pagesRef = (PdfIndirectReference)reader.Catalog.Get(PdfName.Pages)!; + var pages = (PdfDictionary)reader.Resolve(pagesRef.ObjectNumber)!; + var kidsObj = pages.Get(new PdfName("Kids")); + var kids = kidsObj is PdfIndirectReference kr + ? (PdfArray)reader.Resolve(kr.ObjectNumber)! + : (PdfArray)kidsObj!; + var pageRef = (PdfIndirectReference)kids[0]; + var page = (PdfDictionary)reader.Resolve(pageRef.ObjectNumber)!; + var resources = (PdfDictionary)reader.ResolveValue(page.Get(new PdfName("Resources"))!)!; + var fontDict = (PdfDictionary)reader.ResolveValue(resources.Get(PdfName.Font)!)!; + var type0Ref = (PdfIndirectReference)fontDict.Entries.First().Value; + var type0 = (PdfDictionary)reader.Resolve(type0Ref.ObjectNumber)!; + + var clone = new PdfDictionary(); + foreach (var kv in type0.Entries) + clone.Set(kv.Key, kv.Value); + clone.Set(new PdfName("Encoding"), new PdfName(new string('A', nameBytes))); + + return reader.AppendRevision([(type0Ref.ObjectNumber, 0, clone)]); + } + + // ── The shared oversized-/Filter fixture: the StreamRule site, and the sink ───────────────── + + [Fact] + public void StreamFilter_withAnOversizedName_reportsAFixedExcerpt() + { + var bytes = BuildOversizedFilterPdf(new string('A', 1_048_576)); + + var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B); + + var assertion = Assert.Single( + result.Assertions, a => a.RuleId == "ISO19005-2:6.1.7.2-1-filter"); + Assert.Equal( + "A stream uses the /" + new string('A', 32) + "... (1048576 bytes) filter, " + + "which is not permitted in PDF/A-2.", + assertion.Message); + } + + [Fact] + public void RuleEvaluationFailure_withAnOversizedToken_isBoundedByTheSink() + { + var bytes = BuildOversizedFilterPdf(new string('A', 1_048_576)); + + var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B); + + var full = "Rule evaluation failed: Unknown PDF filter: /" + new string('A', 1_048_576); + var expected = full[..1024] + $"... ({full.Length} chars)"; + + // Several rules try to decode the same oversized-filter /Contents stream and each wraps + // the same InvalidDataException as its own "Rule evaluation failed" finding (#403); assert + // every one of them, not just the first, since they all quote the identical thrown message. + var matching = result.Assertions + .Where(a => a.Message.StartsWith( + "Rule evaluation failed: Unknown PDF filter: /", StringComparison.Ordinal)) + .ToList(); + Assert.NotEmpty(matching); + Assert.All(matching, a => Assert.Equal(expected, a.Message)); + } + + // ── One test per remaining site that quotes a producer name ────────────────────────────────── + + [Fact] + public void ActionType_withAnOversizedName_reportsAFixedExcerpt() + { + var name = new string('A', 1_048_576); + var bytes = AssemblePdf( + [ + new($"<< /Type /Catalog /Pages 2 0 R /OpenAction << /S /{name} >> >>"), + _pagesObj, + _pageObj, + ]); + + var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B); + + var assertion = Assert.Single( + result.Assertions, a => a.RuleId == "ISO19005-2:6.5.1-action"); + Assert.Equal( + "The action type /" + new string('A', 32) + "... (1048576 bytes) is not permitted in PDF/A.", + assertion.Message); + } + + [Fact] + public void NamedAction_withAnOversizedName_reportsAFixedExcerpt() + { + var name = new string('A', 1_048_576); + var bytes = AssemblePdf( + [ + new("<< /Type /Catalog /Pages 2 0 R /OpenAction 4 0 R >>"), + _pagesObj, + _pageObj, + new($"<< /S /Named /N /{name} >>"), + ]); + + var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B); + + var assertion = Assert.Single( + result.Assertions, a => a.RuleId == "ISO19005-2:6.5.1-named-action"); + Assert.Equal( + "The named action /" + new string('A', 32) + "... (1048576 bytes) is not permitted " + + "in PDF/A (only NextPage, PrevPage, FirstPage, and LastPage are allowed).", + assertion.Message); + } + + [Fact] + public void AnnotationAppearanceExtraKey_withAnOversizedName_reportsAFixedExcerpt() + { + var key = new string('A', 1_048_576); + var bytes = AssemblePdf( + [ + new("<< /Type /Catalog /Pages 2 0 R >>"), + _pagesObj, + new("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [4 0 R] >>"), + new("<< /Type /Annot /Subtype /Text /Rect [10 10 50 50] /F 4 /Contents (n) " + + $"/AP << /N 5 0 R /{key} 5 0 R >> >>"), + new("/Type /XObject /Subtype /Form /BBox [0 0 1 1]", Stream: []), + ]); + + var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B); + + var assertion = Assert.Single( + result.Assertions, + a => a.RuleId == "ISO19005-2:6.3-annotation" && a.Message.Contains("(/AP)", StringComparison.Ordinal)); + Assert.Equal( + "A /Text annotation's appearance dictionary (/AP) shall contain only the /N entry (found /" + + new string('A', 32) + "... (1048576 bytes)).", + assertion.Message); + } + + [Fact] + public void AnnotationLabel_withAnOversizedSubtype_reportsAFixedExcerptEverywhereItIsReused() + { + var subtype = new string('A', 1_048_576); + var bytes = AssemblePdf( + [ + new("<< /Type /Catalog /Pages 2 0 R >>"), + _pagesObj, + new("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [4 0 R] >>"), + new($"<< /Type /Annot /Subtype /{subtype} /Rect [10 10 50 50] >>"), + ]); + + var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B); + + var label = "A /" + new string('A', 32) + "... (1048576 bytes) annotation"; + var annotationFindings = result.Assertions + .Where(a => a.RuleId == "ISO19005-2:6.3-annotation") + .ToList(); + // No /F and no /AP: the Print-flag message and the missing-appearance message both fire, + // and both must carry the excerpt rather than the whole /Subtype. + Assert.Equal(2, annotationFindings.Count); + Assert.All( + annotationFindings, + a => Assert.StartsWith(label, a.Message, StringComparison.Ordinal)); + Assert.Contains( + annotationFindings, + a => a.Message == label + " shall have the Print flag set."); + Assert.Contains( + annotationFindings, + a => a.Message == label + " shall have a normal appearance (/AP /N)."); + } + + [Fact] + public void Type0CMapName_withAnOversizedName_reportsAFixedExcerpt() + { + var cmapName = new string('A', 1_048_576); + var bytes = BuildFontPdf( + new PdfObj($"<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /{cmapName} " + + "/DescendantFonts [7 0 R] >>"), + new PdfObj("<< /Type /Font /Subtype /CIDFontType2 /BaseFont /X /FontDescriptor 8 0 R " + + "/CIDToGIDMap /Identity >>"), + new PdfObj("<< /Type /FontDescriptor /FontName /X /FontFile2 9 0 R >>"), + new PdfObj("/Length1 4", [1, 2, 3, 4])); + + var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B); + + var assertion = Assert.Single( + result.Assertions, a => a.RuleId == "ISO19005-2:6.2.11.3.3-cmap-name"); + Assert.Equal( + "A composite font's /Encoding names the CMap /" + new string('A', 32) + + "... (1048576 bytes), which is neither one of the predefined CMaps nor an embedded " + + "CMap stream.", + assertion.Message); + } + + [Fact] + public void RoleMapEntry_withAnOversizedName_reportsAFixedExcerpt() + { + // LogicalStructureRule's "shall map to a name" branch needs a /RoleMap value that is not a + // name (a name key alone cannot reach it); 42 is an arbitrary non-name. + var roleKey = new string('A', 1_048_576); + var bytes = AssemblePdf( + [ + new("<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 4 0 R >>"), + _pagesObj, + _pageObj, + new($"<< /Type /StructTreeRoot /RoleMap << /{roleKey} 42 >> >>"), + ], + xmpConformance: "A"); + + var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2A); + + var assertion = Assert.Single( + result.Assertions, a => a.RuleId == "ISO19005-2:6.8-logical-structure"); + Assert.Equal( + "The structure tree /RoleMap entry /" + new string('A', 32) + + "... (1048576 bytes) shall map to a name.", + assertion.Message); + } + + [Fact] + public void PermissionsKey_withAnOversizedName_reportsAFixedExcerpt() + { + var key = new string('A', 1_048_576); + var bytes = AssemblePdf( + [ + new($"<< /Type /Catalog /Pages 2 0 R /Perms << /{key} << /Type /Sig >> >> >>"), + _pagesObj, + _pageObj, + ]); + + var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B); + + var assertion = Assert.Single( + result.Assertions, a => a.RuleId == "ISO19005-2:6.1.12-1-permissions"); + Assert.Equal( + "The permissions dictionary contains the key /" + new string('A', 32) + + "... (1048576 bytes); only /UR3 and /DocMDP are permitted in PDF/A-2.", + assertion.Message); + } + + [Fact] + public void BlendMode_withAnOversizedName_reportsAFixedExcerpt() + { + var bm = new string('A', 1_048_576); + var bytes = AssemblePdf( + [ + new("<< /Type /Catalog /Pages 2 0 R >>"), + _pagesObj, + new("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources 4 0 R /Contents 7 0 R >>"), + new("<< /ExtGState 5 0 R >>"), + new("<< /GS0 6 0 R >>"), + new($"<< /Type /ExtGState /BM /{bm} >>"), + new(string.Empty, Encoding.ASCII.GetBytes("q /GS0 gs Q")), + ]); + + var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2B); + + var assertion = Assert.Single( + result.Assertions, a => a.RuleId == "ISO19005-2:6.2.10-blend-mode"); + Assert.Equal( + "The blend mode /" + new string('A', 32) + "... (1048576 bytes) is not one of the " + + "standard blend modes permitted in PDF/A-2.", + assertion.Message); + } + + [Fact] + public void UaType0CMapName_withAnOversizedName_reportsAFixedExcerpt() + { + var bytes = BuildUa1OversizedCMapNamePdf(1_048_576); + + var result = PdfPreflight.Validate(bytes, PdfConformance.PdfUA1); + + var assertion = Assert.Single( + result.Assertions, a => a.RuleId == "ISO14289-1:7.21.3.3-1"); + Assert.Equal( + "A composite font's /Encoding names the CMap /" + new string('A', 32) + + "... (1048576 bytes), which is neither one of the predefined CMaps nor an embedded " + + "CMap stream (§7.21.3.3).", + assertion.Message); + } + + // ── Layer A: the sink's own cut, at the boundary ───────────────────────────────────────────── + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Report_atTheBoundary_keepsExactlyMaxMessageChars(int scenario) + { + var bytes = AssemblePdf([new("<< /Type /Catalog /Pages 2 0 R >>"), _pagesObj, _pageObj]); + using var reader = PdfReader.Open(bytes); + var assertions = new List(); + var context = new PreflightContext(reader, PdfConformance.PdfA2B, assertions); + + var message = scenario switch + { + 0 => new string('x', PreflightContext.MaxMessageChars), + 1 => new string('x', PreflightContext.MaxMessageChars + 1), + _ => new string('x', 1023) + "\U0001F600" + "trailing text past the boundary", + }; + + context.Report("VellumTest:boundary", "n/a", PreflightSeverity.Error, message); + + var retained = Assert.Single(assertions).Message; + switch (scenario) + { + case 0: + Assert.Equal(message, retained); + break; + case 1: + Assert.Equal(message[..1024] + $"... ({message.Length} chars)", retained); + break; + default: + Assert.Equal(message[..1023] + $"... ({message.Length} chars)", retained); + break; + } + + // The Assert.Equal above is the discriminating check: with the surrogate step-back in + // Report removed, scenario 2 keeps 1024 characters and lands between the two halves of + // U+1F600, so the expected 1023-character prefix no longer matches. The walk below restates + // the invariant that check implies (no lone surrogate anywhere in the retained text) in the + // form the CLI's SARIF writer needs. + for (var i = 0; i < retained.Length; i++) + { + if (char.IsHighSurrogate(retained[i])) + { + Assert.True(i + 1 < retained.Length && char.IsLowSurrogate(retained[i + 1]), + $"Lone high surrogate at index {i}."); + i++; + } + else + { + Assert.False(char.IsLowSurrogate(retained[i]), $"Lone low surrogate at index {i}."); + } + } + } + + // ── The issue's own shape: 400 pages sharing one oversized /Filter name ───────────────────── + + [Fact] + public void FourHundredPagesSharingOneOversizedFilterName_retainOnlyBoundedMessages() + { + const int pageCount = 400; + const int nameBytes = 900_000; + var firstContentObj = 3 + pageCount; + var sharedFilterObj = 3 + pageCount * 2; + + var objects = new List + { + new("<< /Type /Catalog /Pages 2 0 R >>"), + new("<< /Type /Pages /Kids [" + + string.Join(" ", Enumerable.Range(3, pageCount).Select(n => $"{n} 0 R")) + + $"] /Count {pageCount} >>"), + }; + for (var i = 0; i < pageCount; i++) + objects.Add(new PdfObj( + $"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {firstContentObj + i} 0 R >>")); + for (var i = 0; i < pageCount; i++) + objects.Add(new PdfObj($"/Filter {sharedFilterObj} 0 R", [])); + objects.Add(new PdfObj("/" + new string('A', nameBytes))); + + var bytes = AssemblePdf(objects, xmpConformance: "A"); + + var result = PdfPreflight.Validate(bytes, PdfConformance.PdfA2A); + + // "... (" + up to ten digits of length + " chars)" is the longest suffix Report appends. + const int maxSuffixChars = 5 + 10 + 7; + Assert.All( + result.Assertions, + a => Assert.True( + a.Message.Length <= PreflightContext.MaxMessageChars + maxSuffixChars, + $"A retained message of rule {a.RuleId} was {a.Message.Length} chars.")); + + // 128 Ki characters for 407 findings; the pre-fix total for this fixture was 693.6 MiB of + // message text (#403's 705.7 MiB is a GC delta measured on a 990 KB variant of the same + // shape). + var totalLength = result.Assertions.Sum(a => a.Message.Length); + Assert.True(totalLength < 131_072, $"Total retained message length was {totalLength} chars."); + + Assert.Contains( + result.Assertions, + a => a.Message == "A stream uses the /" + new string('A', 32) + "... (900000 bytes) filter, " + + "which is not permitted in PDF/A-2."); + } +}