Skip to content

Commit 4db9e45

Browse files
committed
Add support for /AuthEvent, on-demand decryption
Normally entire PDFs are encrypted (or not). But it is also possible to only encrypt attachments. It is then also possible to *only* prompt for a password when the user opens them. In the existing flow, prompting for passwords happens because things are decrypted. A specific error is thrown, caught, and the user is prompted. To keep this flow working, this PR changes to decrypting attachments on demand, instead of eagerly. This sounds logical: to not read attachments on startup. I’ve extensively tested this, not only with regular attachments, but also with outline items and attachments in annotations. This PR builds on GH-21234. It’s an alternative to the naïve GH-20732. Closes GH-20049.
1 parent 19046a6 commit 4db9e45

23 files changed

Lines changed: 781 additions & 99 deletions

src/core/annotation.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ import { parseMarkedContentProps } from "./evaluator_utils.js";
7979
import { StringStream } from "./stream.js";
8080
import { XFAFactory } from "./xfa/factory.js";
8181

82+
/**
83+
* @import { Catalog } from "./catalog.js";
84+
*/
85+
8286
class AnnotationFactory {
8387
static createGlobals(pdfManager) {
8488
return Promise.all([
@@ -5207,11 +5211,39 @@ class FileAttachmentAnnotation extends MarkupAnnotation {
52075211
constructor(params) {
52085212
super(params);
52095213

5210-
const { dict } = params;
5211-
const file = new FileSpec(dict.get("FS"));
5214+
const { annotationGlobals, dict } = params;
5215+
const fileSpecRef = dict.getRaw("FS");
5216+
const fsDict = dict.get("FS");
5217+
const file = new FileSpec(fsDict);
5218+
/** @type {{catalog?: Catalog}} */
5219+
const { catalog } = annotationGlobals.pdfManager.pdfDocument;
5220+
5221+
// When this annotation references an embedded file that’s already in the
5222+
// catalog `NameTree` (such as `EFOpen`), reuse that `NameTree` id so the
5223+
// sidebar and annotation paths resolve the same attachment identity.
5224+
let fileId =
5225+
fileSpecRef instanceof Ref
5226+
? catalog?.attachmentIdByRef.get(fileSpecRef)
5227+
: undefined;
5228+
5229+
// Fallback ids are namespaced to keep annotation-local ids distinct from
5230+
// `NameTree` ids (which are filename-based).
5231+
if (catalog && fsDict instanceof Dict && typeof fileId !== "string") {
5232+
const baseFileId = `annotation:${this.data.id}`;
5233+
fileId = baseFileId;
5234+
5235+
let i = 1;
5236+
while (catalog.attachmentDictById.has(fileId)) {
5237+
fileId = `${baseFileId}-${i++}`;
5238+
}
5239+
5240+
// Cache only fallbacks.
5241+
catalog.attachmentDictById.set(fileId, fsDict);
5242+
}
52125243

52135244
this.data.hasOwnCanvas = this.data.noRotate;
52145245
this.data.noHTML = false;
5246+
this.data.fileId = fileId;
52155247
this.data.file = file.serializable;
52165248

52175249
const name = dict.get("Name");

src/core/base_stream.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,17 @@ class BaseStream {
2525
}
2626
}
2727

28+
/**
29+
* @returns {number}
30+
*/
2831
// eslint-disable-next-line getter-return
2932
get length() {
3033
unreachable("Abstract getter `length` accessed");
3134
}
3235

36+
/**
37+
* @returns {boolean}
38+
*/
3339
// eslint-disable-next-line getter-return
3440
get isEmpty() {
3541
unreachable("Abstract getter `isEmpty` accessed");
@@ -43,6 +49,10 @@ class BaseStream {
4349
unreachable("Abstract method `getByte` called");
4450
}
4551

52+
/**
53+
* @param {number | undefined} [length]
54+
* @returns {Uint8Array}
55+
*/
4656
getBytes(length) {
4757
unreachable("Abstract method `getBytes` called");
4858
}

src/core/catalog.js

Lines changed: 111 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,37 @@ import { MetadataParser } from "./metadata_parser.js";
5555
import { stringToPDFString } from "./string_utils.js";
5656
import { StructTreeRoot } from "./struct_tree.js";
5757

58+
/**
59+
* @import {XRef} from "./xref.js";
60+
*/
61+
62+
/**
63+
* @callback GetAttachmentContent
64+
* Callback used to lazily fetch attachment content.
65+
* @param {string} id
66+
* Unique attachment identifier.
67+
* @returns {CatalogAttachmentContent}
68+
* Result.
69+
*/
70+
71+
/**
72+
* @typedef {Uint8Array | null} CatalogAttachmentContent
73+
* Attachment value.
74+
*/
75+
76+
/**
77+
* @typedef CatalogAttachment
78+
* Attachment metadata.
79+
* @property {CatalogAttachmentContent | undefined} [content]
80+
* Value, when already available.
81+
* @property {string} description
82+
* Description.
83+
* @property {string} filename
84+
* Filename (just the basename) for display.
85+
* @property {string} rawFilename
86+
* File path.
87+
*/
88+
5889
const isRef = v => v instanceof Ref;
5990

6091
const isValidExplicitDest = _isValidExplicitDest.bind(
@@ -88,8 +119,18 @@ function fetchRemoteDest(action) {
88119
class Catalog {
89120
#actualNumPages = null;
90121

122+
/** @type {RefSetCache | null} */
123+
#attachmentIdByRef = null;
124+
91125
#catDict = null;
92126

127+
/**
128+
* Attachment dictionaries keyed by attachment id.
129+
*
130+
* @type {Map<string, Dict>}
131+
*/
132+
attachmentDictById = new Map();
133+
93134
builtInCMapCache = new Map();
94135

95136
fontCache = new RefSetCache();
@@ -123,6 +164,29 @@ class Catalog {
123164
this.toplevelPagesDict; // eslint-disable-line no-unused-expressions
124165
}
125166

167+
/**
168+
* Attachment ids keyed by embedded-file reference.
169+
*
170+
* @type {RefSetCache}
171+
*/
172+
get attachmentIdByRef() {
173+
if (this.#attachmentIdByRef) {
174+
return this.#attachmentIdByRef;
175+
}
176+
177+
const attachmentIdByRef = new RefSetCache();
178+
for (const [name, ref] of this.rawEmbeddedFiles || []) {
179+
if (!(ref instanceof Ref)) {
180+
continue;
181+
}
182+
attachmentIdByRef.put(
183+
ref,
184+
stringToPDFString(name, /* keepEscapeSequence = */ true)
185+
);
186+
}
187+
return (this.#attachmentIdByRef = attachmentIdByRef);
188+
}
189+
126190
cloneDict() {
127191
return this.#catDict.clone();
128192
}
@@ -369,6 +433,7 @@ class Catalog {
369433

370434
const outlineItem = {
371435
action: data.action,
436+
attachmentId: data.attachmentId,
372437
attachment: data.attachment,
373438
dest: data.dest,
374439
url: data.url,
@@ -1068,8 +1133,15 @@ class Catalog {
10681133
);
10691134
}
10701135

1136+
/**
1137+
* Get attachments.
1138+
*
1139+
* @returns {Record<string, CatalogAttachment> | null}
1140+
* Attachments.
1141+
*/
10711142
get attachments() {
10721143
const obj = this.#catDict.get("Names");
1144+
/** @type {Record<string, CatalogAttachment> | null} */
10731145
let attachments = null;
10741146

10751147
if (obj instanceof Dict && obj.has("EmbeddedFiles")) {
@@ -1084,6 +1156,32 @@ class Catalog {
10841156
return shadow(this, "attachments", attachments);
10851157
}
10861158

1159+
/**
1160+
* Get content for an attachment.
1161+
*
1162+
* @param {string} id
1163+
* Unique attachment identifier (required).
1164+
* @returns {CatalogAttachmentContent}
1165+
* Content.
1166+
*/
1167+
attachmentContent(id) {
1168+
const dict = this.attachmentDictById.get(id);
1169+
if (dict) {
1170+
return FileSpec.readContent(dict);
1171+
}
1172+
1173+
const obj = this.#catDict.get("Names");
1174+
if (obj instanceof Dict && obj.has("EmbeddedFiles")) {
1175+
const nameTree = new NameTree(obj.getRaw("EmbeddedFiles"), this.xref);
1176+
for (const [key, value] of nameTree.getAll()) {
1177+
if (stringToPDFString(key, /* keepEscapeSequence = */ true) === id) {
1178+
return FileSpec.readContent(value);
1179+
}
1180+
}
1181+
}
1182+
return null;
1183+
}
1184+
10871185
get rawEmbeddedFiles() {
10881186
const obj = this.#catDict.get("Names");
10891187
if (!(obj instanceof Dict) || !obj.has("EmbeddedFiles")) {
@@ -1182,6 +1280,9 @@ class Catalog {
11821280

11831281
async cleanup(manuallyTriggered = false) {
11841282
clearGlobalCaches();
1283+
this.#attachmentIdByRef?.clear();
1284+
this.#attachmentIdByRef = null;
1285+
this.attachmentDictById.clear();
11851286
this.globalColorSpaceCache.clear();
11861287
this.globalImageCache.clear(/* onlyData = */ manuallyTriggered);
11871288
this.pageKidsCountCache.clear();
@@ -1556,8 +1657,8 @@ class Catalog {
15561657
* properties will be placed.
15571658
* @property {string} [docBaseUrl] - The document base URL that is used when
15581659
* attempting to recover valid absolute URLs from relative ones.
1559-
* @property {Object} [docAttachments] - The document attachments (may not
1560-
* exist in most PDF documents).
1660+
* @property {Record<string, CatalogAttachment> | null} [docAttachments] - The
1661+
* document attachments (may not exist in most PDF documents).
15611662
*/
15621663

15631664
/**
@@ -1740,8 +1841,7 @@ class Catalog {
17401841
case "GoToR":
17411842
const urlDict = action.get("F");
17421843
if (urlDict instanceof Dict) {
1743-
const fs = new FileSpec(urlDict, /* skipContent = */ true);
1744-
({ rawFilename: url } = fs.serializable);
1844+
url = new FileSpec(urlDict).filename;
17451845
} else if (typeof urlDict === "string") {
17461846
url = urlDict;
17471847
} else {
@@ -1766,22 +1866,21 @@ class Catalog {
17661866

17671867
case "GoToE":
17681868
const target = action.get("T");
1769-
let attachment;
1869+
/** @type {string | null} */
1870+
let id = null;
17701871

1771-
if (docAttachments && target instanceof Dict) {
1872+
if (target instanceof Dict) {
17721873
const relationship = target.get("R");
17731874
const name = target.get("N");
17741875

17751876
if (isName(relationship, "C") && typeof name === "string") {
1776-
attachment =
1777-
docAttachments[
1778-
stringToPDFString(name, /* keepEscapeSequence = */ true)
1779-
];
1877+
id = stringToPDFString(name, /* keepEscapeSequence = */ true);
17801878
}
17811879
}
17821880

1783-
if (attachment) {
1784-
resultObj.attachment = attachment;
1881+
if (docAttachments && id) {
1882+
resultObj.attachmentId = id;
1883+
resultObj.attachment = docAttachments[id];
17851884

17861885
// NOTE: the destination is relative to the *attachment*.
17871886
const attachmentDest = fetchRemoteDest(action);

0 commit comments

Comments
 (0)