Skip to main content

zpdf_document/
embedded_files.rs

1//! Embedded files and associated files (ISO 32000-1 §7.11, ISO 32000-2 §7.11.4).
2//!
3//! Two related PDF features surface here through one [`EmbeddedFile`] model:
4//!
5//! * **Embedded files** — file streams stored inside the PDF and registered in
6//!   the catalog's `/Names /EmbeddedFiles` *name tree* (the "attachments" panel
7//!   of a viewer; ISO 32000-1). Each name maps to a *file specification*
8//!   dictionary whose `/EF` entry points at the embedded-file stream.
9//!
10//! * **Associated files (`/AF`)** — a PDF 2.0 addition: an array of file
11//!   specifications attached to the catalog, a page, an annotation, an XObject,
12//!   etc., each carrying an `/AFRelationship` that states *why* the file is
13//!   attached (`/Source`, `/Data`, `/Alternative`, …). This is the mechanism
14//!   PDF/A-3 and ZUGFeRD/Factur-X use to embed the source XML of an invoice.
15//!   PDF 2.0 requires every associated file to *also* appear in the
16//!   `/Names /EmbeddedFiles` tree, so the two lists usually overlap.
17//!
18//! This module only *parses and exposes* metadata and the embedded stream's
19//! object id; it never decodes the (potentially large) payload. Callers pull the
20//! bytes on demand via [`crate::PdfDocument::embedded_file_bytes`], which routes
21//! through the parser's filter pipeline (and so respects `ParseLimits`).
22
23use std::collections::HashSet;
24
25use zpdf_core::{ObjectId, PdfDict, PdfObject};
26use zpdf_parser::PdfFile;
27
28use crate::forms::pdf_string_to_unicode;
29
30/// Maximum depth of a `/Names /EmbeddedFiles` name-tree walk. Real trees are a
31/// handful of levels; this only bounds adversarial input (in concert with the
32/// visited-set cycle guard).
33const MAX_NAME_TREE_DEPTH: usize = 64;
34
35/// Defensive cap on the total number of embedded-file entries collected from one
36/// name tree — bounds a maliciously enormous (or cyclic-but-distinct) tree.
37const MAX_EMBEDDED_FILES: usize = 16_384;
38
39/// Defensive cap on the number of entries read from one `/AF` array.
40const MAX_AF_ENTRIES: usize = 8_192;
41
42/// File-name keys on a file-specification dictionary, in preference order:
43/// the Unicode name (`/UF`, PDF 1.7) first, then the platform-independent `/F`,
44/// then the legacy platform-specific names. The same order picks the embedded
45/// stream out of an `/EF` dictionary.
46const FILE_NAME_KEYS: [&str; 5] = ["UF", "F", "Unix", "DOS", "Mac"];
47
48/// Where an [`EmbeddedFile`] was discovered.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub enum EmbeddedSource {
51    /// The catalog's `/Names /EmbeddedFiles` name tree (document attachments).
52    NameTree,
53    /// An `/AF` associated-files array (PDF 2.0). The semantic relationship is
54    /// carried separately in [`EmbeddedFile::relationship`]; the owning scope
55    /// (catalog vs. page) is implied by which accessor returned it.
56    AssociatedFile,
57}
58
59/// One embedded or associated file: the file-specification metadata plus the
60/// object id of its embedded-file stream (when it carries one). The payload is
61/// not decoded here — fetch it with [`crate::PdfDocument::embedded_file_bytes`].
62#[derive(Debug, Clone, PartialEq, Eq, Hash)]
63pub struct EmbeddedFile {
64    /// Best available file name: `/UF` (Unicode) if present, else `/F`, else a
65    /// platform-specific name, else the name-tree key it was registered under.
66    /// May be empty if the file specification carries no name at all.
67    pub name: String,
68    /// `/Desc` — a human-readable description, if present.
69    pub description: Option<String>,
70    /// `/AFRelationship` (PDF 2.0): the relationship an associated file has to
71    /// the content it is attached to — `Source`, `Data`, `Alternative`,
72    /// `Supplement`, `EncryptedPayload`, `FormData`, `Schema`, `Unspecified`.
73    /// `None` when absent (the common case for plain name-tree attachments).
74    pub relationship: Option<String>,
75    /// `/Subtype` of the embedded-file stream — a MIME type such as
76    /// `"application/xml"` (the PDF name `application#2Fxml`). `None` when the
77    /// file carries no embedded stream or the stream omits `/Subtype`.
78    pub subtype: Option<String>,
79    /// `/Params /Size` — the uncompressed size in bytes the producer declared.
80    /// Advisory: the actual decoded length is whatever the stream yields.
81    pub size: Option<i64>,
82    /// `/Params /CreationDate`, as the raw PDF date string (e.g. `D:20240101…`).
83    pub creation_date: Option<String>,
84    /// `/Params /ModDate`, as the raw PDF date string.
85    pub mod_date: Option<String>,
86    /// `/Params /CheckSum` — a 16-byte MD5 of the *uncompressed* bytes, if the
87    /// producer included one. Stored raw (not decoded text).
88    pub checksum: Option<Vec<u8>>,
89    /// Object id of the embedded-file stream (`/EF` → chosen name key). `None`
90    /// for an external file reference or a malformed spec with no `/EF`.
91    pub stream: Option<ObjectId>,
92    /// Whether this came from the name tree or an `/AF` array.
93    pub source: EmbeddedSource,
94}
95
96impl EmbeddedFile {
97    /// Whether this file specification actually has embedded bytes to extract
98    /// (as opposed to merely naming an external file).
99    pub fn is_embedded(&self) -> bool {
100        self.stream.is_some()
101    }
102}
103
104/// Document-level embedded files from the catalog's `/Names /EmbeddedFiles`
105/// name tree. Empty when the document declares none.
106pub fn parse_embedded_files(file: &PdfFile) -> Vec<EmbeddedFile> {
107    let Some(root) = catalog_dict(file) else {
108        return Vec::new();
109    };
110    // /Root /Names is a dictionary of name trees (/EmbeddedFiles, /Dests, …).
111    let Some(names) = resolve_dict(file, root.get("Names")) else {
112        return Vec::new();
113    };
114
115    let mut out = Vec::new();
116    let mut visited = HashSet::new();
117    // Seed the cycle guard with the tree-root reference itself so a root that
118    // lists itself as a kid terminates.
119    if let Some(PdfObject::Ref(r)) = names.get("EmbeddedFiles") {
120        visited.insert(*r);
121    }
122    let Some(tree_root) = resolve_dict(file, names.get("EmbeddedFiles")) else {
123        return Vec::new();
124    };
125    walk_name_tree(file, &tree_root, &mut out, &mut visited, 0);
126    out
127}
128
129/// Catalog-level associated files (`/Root /AF`, PDF 2.0). Empty for most files.
130pub fn parse_associated_files(file: &PdfFile) -> Vec<EmbeddedFile> {
131    let Some(root) = catalog_dict(file) else {
132        return Vec::new();
133    };
134    collect_af_array(file, root.get("AF"))
135}
136
137/// Page-level associated files (`/Page /AF`, PDF 2.0), read off an
138/// already-resolved leaf page dictionary. `/AF` is *not* an inheritable page
139/// attribute, so this looks only at the leaf.
140pub fn parse_page_associated_files(file: &PdfFile, page_dict: &PdfDict) -> Vec<EmbeddedFile> {
141    collect_af_array(file, page_dict.get("AF"))
142}
143
144/// Walk a name-tree node, appending embedded-file entries. A leaf node carries
145/// `/Names [key0 val0 key1 val1 …]`; an interior node carries `/Kids [refs]`.
146/// Bounded by depth, a per-reference visited set, and the global entry cap.
147fn walk_name_tree(
148    file: &PdfFile,
149    node: &PdfDict,
150    out: &mut Vec<EmbeddedFile>,
151    visited: &mut HashSet<ObjectId>,
152    depth: usize,
153) {
154    if depth > MAX_NAME_TREE_DEPTH || out.len() >= MAX_EMBEDDED_FILES {
155        return;
156    }
157
158    // Leaf entries: alternating (name-string, file-specification) pairs.
159    if let Some(names) = resolve_array(file, node.get("Names")) {
160        let mut i = 0;
161        while i + 1 < names.len() {
162            if out.len() >= MAX_EMBEDDED_FILES {
163                return;
164            }
165            let key = match &names[i] {
166                PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
167                _ => None,
168            };
169            if let Some(ef) = parse_file_spec(file, &names[i + 1], key, EmbeddedSource::NameTree) {
170                out.push(ef);
171            }
172            i += 2;
173        }
174    }
175
176    // Interior children.
177    if let Some(kids) = resolve_array(file, node.get("Kids")) {
178        for kid in &kids {
179            let kid_dict = match kid {
180                PdfObject::Ref(r) => {
181                    // Cycle guard: only descend through each node once.
182                    if !visited.insert(*r) {
183                        continue;
184                    }
185                    resolve_dict(file, Some(kid))
186                }
187                PdfObject::Dict(_) => resolve_dict(file, Some(kid)),
188                _ => None,
189            };
190            if let Some(d) = kid_dict {
191                walk_name_tree(file, &d, out, visited, depth + 1);
192            }
193        }
194    }
195}
196
197/// Parse the entries of an `/AF` array (each a file specification, possibly an
198/// indirect reference). Bounded by [`MAX_AF_ENTRIES`].
199fn collect_af_array(file: &PdfFile, obj: Option<&PdfObject>) -> Vec<EmbeddedFile> {
200    let Some(arr) = resolve_array(file, obj) else {
201        return Vec::new();
202    };
203    let mut out = Vec::new();
204    for elem in arr.iter().take(MAX_AF_ENTRIES) {
205        if let Some(ef) = parse_file_spec(file, elem, None, EmbeddedSource::AssociatedFile) {
206            out.push(ef);
207        }
208    }
209    out
210}
211
212/// Resolve a value to a file specification and extract its metadata. The value
213/// is usually an indirect reference to a `/Filespec` dictionary; a bare string
214/// is a *simple* file specification (an external path, no embedded stream).
215/// `tree_key` is the name-tree key, used as a fallback file name.
216fn parse_file_spec(
217    file: &PdfFile,
218    obj: &PdfObject,
219    tree_key: Option<String>,
220    source: EmbeddedSource,
221) -> Option<EmbeddedFile> {
222    let resolved = match obj {
223        PdfObject::Ref(r) => file.resolve(*r).ok()?,
224        other => other.clone(),
225    };
226    match resolved {
227        PdfObject::Dict(d) => Some(parse_file_spec_dict(file, &d, tree_key, source)),
228        PdfObject::String(s) => {
229            // Simple (external) file specification: a path string, no payload.
230            let name = non_empty(pdf_string_to_unicode(s.as_bytes()))
231                .or(tree_key)
232                .unwrap_or_default();
233            Some(EmbeddedFile {
234                name,
235                description: None,
236                relationship: None,
237                subtype: None,
238                size: None,
239                creation_date: None,
240                mod_date: None,
241                checksum: None,
242                stream: None,
243                source,
244            })
245        }
246        _ => None,
247    }
248}
249
250fn parse_file_spec_dict(
251    file: &PdfFile,
252    dict: &PdfDict,
253    tree_key: Option<String>,
254    source: EmbeddedSource,
255) -> EmbeddedFile {
256    let name = file_spec_name(file, dict).or(tree_key).unwrap_or_default();
257    let description = text(file, dict, "Desc");
258    let relationship = name_value(file, dict, "AFRelationship");
259
260    // /EF maps name keys → embedded-file stream references. Pick the stream by
261    // the same preference order as the file name.
262    let stream = resolve_dict(file, dict.get("EF")).and_then(|ef| pick_ef_stream(&ef));
263
264    // Read /Subtype and /Params off the stream *dictionary* without decoding the
265    // payload — enough for a listing. Each value may be an indirect reference.
266    let mut subtype = None;
267    let mut size = None;
268    let mut creation_date = None;
269    let mut mod_date = None;
270    let mut checksum = None;
271    if let Some(stream_dict) = stream
272        .and_then(|id| file.resolve(id).ok())
273        .and_then(|o| o.as_stream().ok().map(|s| s.dict.clone()))
274    {
275        subtype = name_value(file, &stream_dict, "Subtype");
276        if let Some(params) = resolve_dict(file, stream_dict.get("Params")) {
277            size = integer(file, &params, "Size");
278            creation_date = text(file, &params, "CreationDate");
279            mod_date = text(file, &params, "ModDate");
280            checksum = string_bytes(file, &params, "CheckSum");
281        }
282    }
283
284    EmbeddedFile {
285        name,
286        description,
287        relationship,
288        subtype,
289        size,
290        creation_date,
291        mod_date,
292        checksum,
293        stream,
294        source,
295    }
296}
297
298/// First non-empty file name on a file-specification dict, in `/UF`,`/F`,…
299/// preference order. Each name may itself be an indirect string reference.
300fn file_spec_name(file: &PdfFile, dict: &PdfDict) -> Option<String> {
301    FILE_NAME_KEYS
302        .iter()
303        .find_map(|k| text(file, dict, k).and_then(non_empty))
304}
305
306/// The embedded-file stream reference from an `/EF` dictionary, by preference
307/// order. (Stream objects are always indirect, so these are references.)
308fn pick_ef_stream(ef: &PdfDict) -> Option<ObjectId> {
309    FILE_NAME_KEYS.iter().find_map(|k| ef.get_ref(k).ok())
310}
311
312/// The catalog dictionary (`/Root`), or `None` if unreachable.
313fn catalog_dict(file: &PdfFile) -> Option<PdfDict> {
314    let root_ref = file.trailer.get_ref("Root").ok()?;
315    file.resolve(root_ref).ok()?.as_dict().ok().cloned()
316}
317
318/// Resolve a dictionary value that may be given directly or indirectly.
319fn resolve_dict(file: &PdfFile, obj: Option<&PdfObject>) -> Option<PdfDict> {
320    match obj? {
321        PdfObject::Dict(d) => Some(d.clone()),
322        // A stream object also satisfies dictionary lookups for the node / /EF /
323        // /Params positions, which a lax producer may emit as a stream; expose
324        // its dictionary. (The file-spec value itself goes through
325        // `parse_file_spec`, not here.)
326        PdfObject::Stream(s) => Some(s.dict.clone()),
327        PdfObject::Ref(r) => match file.resolve(*r).ok()? {
328            PdfObject::Dict(d) => Some(d),
329            PdfObject::Stream(s) => Some(s.dict),
330            _ => None,
331        },
332        _ => None,
333    }
334}
335
336/// Resolve an array value that may be given directly or indirectly.
337fn resolve_array(file: &PdfFile, obj: Option<&PdfObject>) -> Option<Vec<PdfObject>> {
338    match obj? {
339        PdfObject::Array(a) => Some(a.clone()),
340        PdfObject::Ref(r) => match file.resolve(*r).ok()? {
341            PdfObject::Array(a) => Some(a),
342            _ => None,
343        },
344        _ => None,
345    }
346}
347
348/// Decode a text-string dict entry (UTF-16BE with BOM, else PDFDocEncoding),
349/// following one indirect reference.
350fn text(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<String> {
351    let value = match dict.get(key)? {
352        PdfObject::String(s) => return Some(pdf_string_to_unicode(s.as_bytes())),
353        PdfObject::Ref(r) => file.resolve(*r).ok()?,
354        _ => return None,
355    };
356    match value {
357        PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
358        _ => None,
359    }
360}
361
362/// Read a Name-valued dict entry, following one indirect reference. (Any object
363/// value may be written indirectly, so `/Subtype 9 0 R` and `/AFRelationship
364/// 9 0 R` must resolve rather than drop.)
365fn name_value(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<String> {
366    let value = match dict.get(key)? {
367        PdfObject::Name(n) => return Some(n.as_str().to_string()),
368        PdfObject::Ref(r) => file.resolve(*r).ok()?,
369        _ => return None,
370    };
371    match value {
372        PdfObject::Name(n) => Some(n.as_str().to_string()),
373        _ => None,
374    }
375}
376
377/// Read an integer-valued dict entry, following one indirect reference and
378/// accepting a whole-valued Real (some producers write `/Size 42.0`).
379fn integer(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<i64> {
380    let value = match dict.get(key)? {
381        PdfObject::Ref(r) => file.resolve(*r).ok()?,
382        other => other.clone(),
383    };
384    match value {
385        PdfObject::Integer(n) => Some(n),
386        PdfObject::Real(r) if r.is_finite() && r.fract() == 0.0 => Some(r as i64),
387        _ => None,
388    }
389}
390
391/// Raw bytes of a string-valued dict entry (e.g. `/CheckSum`), following one
392/// indirect reference. Not text-decoded — a checksum is binary.
393fn string_bytes(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<Vec<u8>> {
394    let value = match dict.get(key)? {
395        PdfObject::String(s) => return Some(s.0.clone()),
396        PdfObject::Ref(r) => file.resolve(*r).ok()?,
397        _ => return None,
398    };
399    match value {
400        PdfObject::String(s) => Some(s.0),
401        _ => None,
402    }
403}
404
405fn non_empty(s: String) -> Option<String> {
406    if s.is_empty() {
407        None
408    } else {
409        Some(s)
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::test_util::build_pdf;
417    use crate::PdfDocument;
418
419    fn open(objects: &[&str]) -> PdfDocument {
420        PdfDocument::open(build_pdf(objects)).expect("open pdf")
421    }
422
423    // A minimal page tree shared by the fixtures (objects 2 and 3).
424    const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
425    const PAGE: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
426
427    #[test]
428    fn name_tree_single_leaf_with_stream() {
429        let doc = open(&[
430            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
431            PAGES,
432            PAGE,
433            "<< /Names [ (hello.txt) 5 0 R ] >>",
434            "<< /Type /Filespec /F (hello.txt) /UF (hello.txt) /Desc (greeting) \
435             /EF << /F 6 0 R >> >>",
436            "<< /Type /EmbeddedFile /Subtype /text#2Fplain /Params << /Size 5 >> /Length 5 >>\n\
437             stream\nHello\nendstream",
438        ]);
439        let efs = doc.embedded_files();
440        assert_eq!(efs.len(), 1);
441        let ef = &efs[0];
442        assert_eq!(ef.name, "hello.txt");
443        assert_eq!(ef.description.as_deref(), Some("greeting"));
444        assert_eq!(ef.subtype.as_deref(), Some("text/plain"));
445        assert_eq!(ef.size, Some(5));
446        assert_eq!(ef.stream, Some(ObjectId(6, 0)));
447        assert_eq!(ef.source, EmbeddedSource::NameTree);
448        assert!(ef.is_embedded());
449        // Payload extraction round-trips through the filter pipeline.
450        let bytes = doc.embedded_file_bytes(ef).expect("extract");
451        assert_eq!(bytes, b"Hello");
452    }
453
454    #[test]
455    fn name_tree_interior_kids_node() {
456        let doc = open(&[
457            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
458            PAGES,
459            PAGE,
460            "<< /Kids [5 0 R] >>",
461            "<< /Limits [(a.txt) (a.txt)] /Names [ (a.txt) 6 0 R ] >>",
462            "<< /Type /Filespec /UF (a.txt) /EF << /F 7 0 R >> >>",
463            "<< /Type /EmbeddedFile /Length 1 >>\nstream\nx\nendstream",
464        ]);
465        let efs = doc.embedded_files();
466        assert_eq!(efs.len(), 1);
467        assert_eq!(efs[0].name, "a.txt");
468    }
469
470    #[test]
471    fn associated_file_with_relationship() {
472        let doc = open(&[
473            "<< /Type /Catalog /Pages 2 0 R /AF [4 0 R] >>",
474            PAGES,
475            PAGE,
476            "<< /Type /Filespec /F (invoice.xml) /UF (invoice.xml) \
477             /AFRelationship /Data /EF << /F 5 0 R >> >>",
478            "<< /Type /EmbeddedFile /Subtype /application#2Fxml /Length 7 >>\n\
479             stream\n<x></x>\nendstream",
480        ]);
481        let afs = doc.associated_files();
482        assert_eq!(afs.len(), 1);
483        assert_eq!(afs[0].name, "invoice.xml");
484        assert_eq!(afs[0].relationship.as_deref(), Some("Data"));
485        assert_eq!(afs[0].subtype.as_deref(), Some("application/xml"));
486        assert_eq!(afs[0].source, EmbeddedSource::AssociatedFile);
487        assert!(
488            doc.embedded_files().is_empty(),
489            "AF is not in the name tree here"
490        );
491    }
492
493    #[test]
494    fn page_level_associated_file() {
495        let doc = open(&[
496            "<< /Type /Catalog /Pages 2 0 R >>",
497            PAGES,
498            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /AF [4 0 R] >>",
499            "<< /Type /Filespec /F (page-data.bin) /AFRelationship /Supplement \
500             /EF << /F 5 0 R >> >>",
501            "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
502        ]);
503        let page = doc.page(0).expect("page");
504        let afs = doc.page_associated_files(&page);
505        assert_eq!(afs.len(), 1);
506        assert_eq!(afs[0].name, "page-data.bin");
507        assert_eq!(afs[0].relationship.as_deref(), Some("Supplement"));
508        // Catalog-level AF is empty for this document.
509        assert!(doc.associated_files().is_empty());
510    }
511
512    #[test]
513    fn utf16be_unicode_name_decodes() {
514        // /UF <FEFF 0066 0069 006C 0065 002E 0074 0078 0074> = "file.txt".
515        let doc = open(&[
516            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
517            PAGES,
518            PAGE,
519            "<< /Names [ (k) 5 0 R ] >>",
520            "<< /Type /Filespec /F (fallback.txt) \
521             /UF <FEFF00660069006C0065002E007400780074> /EF << /F 6 0 R >> >>",
522            "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
523        ]);
524        let efs = doc.embedded_files();
525        assert_eq!(efs.len(), 1);
526        // /UF is preferred over /F.
527        assert_eq!(efs[0].name, "file.txt");
528    }
529
530    #[test]
531    fn name_tree_self_cycle_terminates() {
532        // The tree root lists itself as a kid; the walk must terminate.
533        let doc = open(&[
534            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
535            PAGES,
536            PAGE,
537            "<< /Kids [4 0 R] /Names [ (a) 5 0 R ] >>",
538            "<< /Type /Filespec /UF (a) /EF << /F 6 0 R >> >>",
539            "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
540        ]);
541        let efs = doc.embedded_files();
542        // The single leaf entry is collected exactly once; no hang.
543        assert_eq!(efs.len(), 1);
544    }
545
546    #[test]
547    fn filespec_without_ef_is_listed_but_not_embedded() {
548        let doc = open(&[
549            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
550            PAGES,
551            PAGE,
552            "<< /Names [ (ext) 5 0 R ] >>",
553            "<< /Type /Filespec /F (external.txt) >>",
554        ]);
555        let efs = doc.embedded_files();
556        assert_eq!(efs.len(), 1);
557        assert_eq!(efs[0].name, "external.txt");
558        assert_eq!(efs[0].stream, None);
559        assert!(!efs[0].is_embedded());
560        // Asking for bytes on a non-embedded spec is an error, not a panic.
561        assert!(doc.embedded_file_bytes(&efs[0]).is_err());
562    }
563
564    #[test]
565    fn no_names_dict_is_empty() {
566        let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES, PAGE]);
567        assert!(doc.embedded_files().is_empty());
568        assert!(doc.associated_files().is_empty());
569    }
570
571    #[test]
572    fn params_dates_and_checksum_parsed() {
573        let doc = open(&[
574            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
575            PAGES,
576            PAGE,
577            "<< /Names [ (d) 5 0 R ] >>",
578            "<< /Type /Filespec /UF (d.bin) /EF << /F 6 0 R >> >>",
579            "<< /Type /EmbeddedFile /Length 0 \
580             /Params << /Size 42 /CreationDate (D:20240101000000Z) /ModDate (D:20240102000000Z) \
581             /CheckSum <00112233445566778899aabbccddeeff> >> >>\nstream\n\nendstream",
582        ]);
583        let ef = &doc.embedded_files()[0];
584        assert_eq!(ef.size, Some(42));
585        assert_eq!(ef.creation_date.as_deref(), Some("D:20240101000000Z"));
586        assert_eq!(ef.mod_date.as_deref(), Some("D:20240102000000Z"));
587        assert_eq!(ef.checksum.as_ref().map(|c| c.len()), Some(16));
588    }
589
590    #[test]
591    fn multi_pair_leaf_collects_all_in_order() {
592        // The core walker loop: a single leaf with three (key, filespec) pairs.
593        let doc = open(&[
594            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
595            PAGES,
596            PAGE,
597            "<< /Names [ (a) 5 0 R (b) 6 0 R (c) 7 0 R ] >>",
598            "<< /Type /Filespec /UF (a.txt) /EF << /F 8 0 R >> >>",
599            "<< /Type /Filespec /UF (b.txt) /EF << /F 8 0 R >> >>",
600            "<< /Type /Filespec /UF (c.txt) /EF << /F 8 0 R >> >>",
601            "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
602        ]);
603        let names: Vec<_> = doc.embedded_files().into_iter().map(|e| e.name).collect();
604        assert_eq!(names, ["a.txt", "b.txt", "c.txt"]);
605    }
606
607    #[test]
608    fn two_sibling_kids_each_contribute() {
609        let doc = open(&[
610            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
611            PAGES,
612            PAGE,
613            "<< /Kids [5 0 R 6 0 R] >>",
614            "<< /Names [ (a) 7 0 R ] >>",
615            "<< /Names [ (b) 8 0 R ] >>",
616            "<< /Type /Filespec /UF (a.txt) /EF << /F 9 0 R >> >>",
617            "<< /Type /Filespec /UF (b.txt) /EF << /F 9 0 R >> >>",
618            "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
619        ]);
620        assert_eq!(doc.embedded_files().len(), 2);
621    }
622
623    #[test]
624    fn odd_length_names_drops_trailing_key() {
625        // A dangling trailing key (odd-length /Names) is dropped, not paired or panicked.
626        let doc = open(&[
627            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
628            PAGES,
629            PAGE,
630            "<< /Names [ (a) 5 0 R (orphan) ] >>",
631            "<< /Type /Filespec /UF (a.txt) /EF << /F 6 0 R >> >>",
632            "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
633        ]);
634        let efs = doc.embedded_files();
635        assert_eq!(efs.len(), 1);
636        assert_eq!(efs[0].name, "a.txt");
637    }
638
639    #[test]
640    fn inline_dict_filespec_value() {
641        // The name-tree value is a DIRECT Filespec dict, not an indirect ref.
642        let doc = open(&[
643            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
644            PAGES,
645            PAGE,
646            "<< /Names [ (x) << /Type /Filespec /UF (x.txt) /EF << /F 5 0 R >> >> ] >>",
647            "<< /Type /EmbeddedFile /Length 2 >>\nstream\nhi\nendstream",
648        ]);
649        let efs = doc.embedded_files();
650        assert_eq!(efs.len(), 1);
651        assert_eq!(efs[0].name, "x.txt");
652        assert_eq!(efs[0].stream, Some(ObjectId(5, 0)));
653        assert_eq!(doc.embedded_file_bytes(&efs[0]).expect("bytes"), b"hi");
654    }
655
656    #[test]
657    fn inline_dict_kid_node() {
658        // An interior /Kids entry that is a direct (inline) leaf dict.
659        let doc = open(&[
660            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
661            PAGES,
662            PAGE,
663            "<< /Kids [ << /Names [ (a.txt) 5 0 R ] >> ] >>",
664            "<< /Type /Filespec /UF (a.txt) /EF << /F 6 0 R >> >>",
665            "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
666        ]);
667        assert_eq!(doc.embedded_files().len(), 1);
668    }
669
670    #[test]
671    fn bare_string_external_af_has_no_stream() {
672        let doc = open(&[
673            "<< /Type /Catalog /Pages 2 0 R /AF [ (../external.dat) ] >>",
674            PAGES,
675            PAGE,
676        ]);
677        let afs = doc.associated_files();
678        assert_eq!(afs.len(), 1);
679        assert_eq!(afs[0].name, "../external.dat");
680        assert_eq!(afs[0].stream, None);
681        assert!(!afs[0].is_embedded());
682        assert!(doc.embedded_file_bytes(&afs[0]).is_err());
683    }
684
685    #[test]
686    fn tree_key_is_name_fallback_when_filespec_unnamed() {
687        // The Filespec carries no /UF or /F; the name-tree key supplies the name.
688        let doc = open(&[
689            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
690            PAGES,
691            PAGE,
692            "<< /Names [ (keyname.txt) 5 0 R ] >>",
693            "<< /Type /Filespec /EF << /F 6 0 R >> >>",
694            "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
695        ]);
696        assert_eq!(doc.embedded_files()[0].name, "keyname.txt");
697    }
698
699    #[test]
700    fn names_dict_without_embeddedfiles_is_empty() {
701        // /Names present but with a sibling tree (no /EmbeddedFiles) — distinct
702        // exit from "no /Names at all".
703        let doc = open(&[
704            "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 4 0 R >> >>",
705            PAGES,
706            PAGE,
707            "<< /Names [ (foo) (bar) ] >>",
708        ]);
709        assert!(doc.embedded_files().is_empty());
710        assert!(doc.associated_files().is_empty());
711    }
712
713    #[test]
714    fn indirect_uf_name_and_desc_resolve() {
715        // /UF, /F, and /Desc given as indirect string references must resolve.
716        let doc = open(&[
717            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
718            PAGES,
719            PAGE,
720            "<< /Names [ (k) 5 0 R ] >>",
721            "<< /Type /Filespec /UF 7 0 R /F (fallback.txt) /Desc 8 0 R /EF << /F 6 0 R >> >>",
722            "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
723            "(indirect.txt)",
724            "(indirect desc)",
725        ]);
726        let ef = &doc.embedded_files()[0];
727        assert_eq!(ef.name, "indirect.txt"); // indirect /UF wins over direct /F
728        assert_eq!(ef.description.as_deref(), Some("indirect desc"));
729    }
730
731    #[test]
732    fn indirect_subtype_and_relationship_resolve() {
733        // /AFRelationship and /Subtype given as indirect name references resolve.
734        let doc = open(&[
735            "<< /Type /Catalog /Pages 2 0 R /AF [4 0 R] >>",
736            PAGES,
737            PAGE,
738            "<< /Type /Filespec /F (i.xml) /AFRelationship 6 0 R /EF << /F 5 0 R >> >>",
739            "<< /Type /EmbeddedFile /Subtype 7 0 R /Length 0 >>\nstream\n\nendstream",
740            "/Data",
741            "/application#2Fxml",
742        ]);
743        let af = &doc.associated_files()[0];
744        assert_eq!(af.relationship.as_deref(), Some("Data"));
745        assert_eq!(af.subtype.as_deref(), Some("application/xml"));
746    }
747
748    #[test]
749    fn params_size_accepts_real_and_indirect() {
750        // Whole-valued Real /Size.
751        let real = open(&[
752            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
753            PAGES,
754            PAGE,
755            "<< /Names [ (r) 5 0 R ] >>",
756            "<< /Type /Filespec /UF (r.bin) /EF << /F 6 0 R >> >>",
757            "<< /Type /EmbeddedFile /Length 0 /Params << /Size 42.0 >> >>\nstream\n\nendstream",
758        ]);
759        assert_eq!(real.embedded_files()[0].size, Some(42));
760
761        // Indirect integer /Size.
762        let indirect = open(&[
763            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
764            PAGES,
765            PAGE,
766            "<< /Names [ (r) 5 0 R ] >>",
767            "<< /Type /Filespec /UF (r.bin) /EF << /F 6 0 R >> >>",
768            "<< /Type /EmbeddedFile /Length 0 /Params << /Size 7 0 R >> >>\nstream\n\nendstream",
769            "99",
770        ]);
771        assert_eq!(indirect.embedded_files()[0].size, Some(99));
772    }
773
774    #[test]
775    fn name_tree_root_is_a_stream_object() {
776        // A lax producer makes the tree-root node a stream whose dict carries
777        // /Names — resolve_dict exposes the stream's dict so the walk proceeds.
778        let doc = open(&[
779            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
780            PAGES,
781            PAGE,
782            "<< /Names [ (s.bin) 5 0 R ] /Length 0 >>\nstream\n\nendstream",
783            "<< /Type /Filespec /UF (s.bin) /EF << /F 6 0 R >> >>",
784            "<< /Type /EmbeddedFile /Length 1 >>\nstream\nx\nendstream",
785        ]);
786        let efs = doc.embedded_files();
787        assert_eq!(efs.len(), 1);
788        assert_eq!(efs[0].name, "s.bin");
789        assert_eq!(efs[0].stream, Some(ObjectId(6, 0)));
790    }
791
792    #[test]
793    fn mid_tree_reference_cycle_terminates() {
794        // A back-edge deep in the tree (obj6 → obj5) must be cut by the visited set.
795        let doc = open(&[
796            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
797            PAGES,
798            PAGE,
799            "<< /Kids [5 0 R] >>",
800            "<< /Kids [6 0 R] >>",
801            "<< /Kids [5 0 R] /Names [ (a) 7 0 R ] >>",
802            "<< /Type /Filespec /UF (a.txt) /EF << /F 8 0 R >> >>",
803            "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
804        ]);
805        assert_eq!(doc.embedded_files().len(), 1);
806    }
807
808    #[test]
809    fn over_deep_name_tree_is_pruned() {
810        // A /Kids chain deeper than the depth cap: the leaf below it is never
811        // reached and the walk returns (no stack overflow / hang).
812        let depth = MAX_NAME_TREE_DEPTH + 5;
813        let mut objs: Vec<String> = vec![
814            "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>".into(),
815            PAGES.into(),
816            PAGE.into(),
817        ];
818        for k in 0..depth {
819            objs.push(format!("<< /Kids [{} 0 R] >>", 4 + k + 1));
820        }
821        let leaf = 4 + depth;
822        objs.push(format!("<< /Names [ (deep) {} 0 R ] >>", leaf + 1));
823        objs.push(format!(
824            "<< /Type /Filespec /UF (deep) /EF << /F {} 0 R >> >>",
825            leaf + 2
826        ));
827        objs.push("<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream".into());
828        let refs: Vec<&str> = objs.iter().map(|s| s.as_str()).collect();
829        let doc = PdfDocument::open(build_pdf(&refs)).expect("open");
830        assert!(doc.embedded_files().is_empty());
831    }
832}