Skip to main content

zpdf_document/
pdfa.rs

1//! PDF/A conformance validation (profiles A-1b and A-2b).
2//!
3//! A **rule engine** over the parsed document: each check inspects one aspect
4//! of the file and yields zero or more [`Violation`]s. This does not aim for
5//! veraPDF-level completeness — it covers the high-signal, machine-checkable
6//! clauses of ISO 19005-1 (PDF/A-1b) and 19005-2 (PDF/A-2b):
7//!
8//! - file structure: header version, no encryption, trailer /ID present
9//! - fonts: every used font embedded (except the standard 14 in no profile —
10//!   PDF/A requires embedding even for those)
11//! - XMP metadata: present, with a `pdfaid:part`/`conformance` claim
12//! - output intent: a PDF/A output intent with an embedded ICC profile
13//! - forbidden features: JavaScript/actions, embedded files (A-1),
14//!   transparency (A-1: soft masks / group /S /Transparency), LZW (A-1),
15//!   encryption of any kind
16//!
17//! Everything is best-effort and read-only over `ParseLimits`-bounded APIs;
18//! a check that cannot run (e.g. a malformed font dict) reports what it saw.
19
20use std::collections::HashSet;
21
22use zpdf_core::{ObjectId, PdfDict, PdfObject};
23use zpdf_parser::PdfFile;
24
25/// The validation profile.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Profile {
28    /// ISO 19005-1 Level B (PDF/A-1b): PDF 1.4 model, no transparency.
29    A1b,
30    /// ISO 19005-2 Level B (PDF/A-2b): PDF 1.7 model, transparency allowed.
31    A2b,
32}
33
34impl Profile {
35    pub fn as_str(self) -> &'static str {
36        match self {
37            Profile::A1b => "PDF/A-1b",
38            Profile::A2b => "PDF/A-2b",
39        }
40    }
41}
42
43/// One conformance violation.
44#[derive(Debug, Clone)]
45pub struct Violation {
46    /// Short rule identifier, e.g. `"encryption"`, `"font-not-embedded"`.
47    pub rule: &'static str,
48    /// Human-readable explanation with the offending object where known.
49    pub message: String,
50}
51
52/// The outcome of a validation run.
53#[derive(Debug)]
54pub struct ValidationReport {
55    pub profile: Profile,
56    pub violations: Vec<Violation>,
57    /// The `pdfaid:part`/`pdfaid:conformance` the document itself claims via
58    /// XMP, e.g. `Some(("1", "B"))` — independent of whether it conforms.
59    pub claimed: Option<(String, String)>,
60}
61
62impl ValidationReport {
63    pub fn conforms(&self) -> bool {
64        self.violations.is_empty()
65    }
66}
67
68/// Validate `file` against `profile`.
69pub fn validate(file: &PdfFile, profile: Profile) -> ValidationReport {
70    let mut v: Vec<Violation> = Vec::new();
71
72    check_structure(file, profile, &mut v);
73    check_xmp(file, &mut v);
74    let claimed = xmp_claim(file);
75    check_output_intent(file, &mut v);
76    check_fonts(file, &mut v);
77    check_forbidden_features(file, profile, &mut v);
78
79    ValidationReport {
80        profile,
81        violations: v,
82        claimed,
83    }
84}
85
86// ---------------------------------------------------------------------------
87// File structure
88// ---------------------------------------------------------------------------
89
90fn check_structure(file: &PdfFile, profile: Profile, out: &mut Vec<Violation>) {
91    // Encryption is forbidden in every PDF/A part.
92    if file.is_encrypted() {
93        out.push(Violation {
94            rule: "encryption",
95            message: "document is encrypted (/Encrypt present); PDF/A forbids encryption".into(),
96        });
97    }
98
99    // Trailer /ID is required.
100    match file.trailer.get("ID") {
101        Some(PdfObject::Array(a)) if a.len() == 2 => {}
102        _ => out.push(Violation {
103            rule: "file-id",
104            message: "trailer /ID missing or not a two-element array".into(),
105        }),
106    }
107
108    // Header version ceiling: 1.4 for A-1, 1.7 for A-2. The parser records
109    // the header; a higher version is only a violation for A-1 (A-2 is
110    // based on 1.7 which is the cap of what zpdf writes anyway).
111    if profile == Profile::A1b {
112        let data = file.data();
113        if let Some(line) = data.get(..16) {
114            let header = String::from_utf8_lossy(line);
115            if let Some(ver) = header.strip_prefix("%PDF-1.") {
116                if let Some(minor) = ver.chars().next().and_then(|c| c.to_digit(10)) {
117                    if minor > 4 {
118                        out.push(Violation {
119                            rule: "header-version",
120                            message: format!(
121                                "header declares PDF 1.{minor}; PDF/A-1 is based on PDF 1.4"
122                            ),
123                        });
124                    }
125                }
126            }
127        }
128    }
129}
130
131// ---------------------------------------------------------------------------
132// XMP metadata
133// ---------------------------------------------------------------------------
134
135fn check_xmp(file: &PdfFile, out: &mut Vec<Violation>) {
136    let Some(xml) = crate::xmp::metadata_bytes(file) else {
137        out.push(Violation {
138            rule: "xmp-missing",
139            message: "catalog has no /Metadata XMP stream; PDF/A requires XMP metadata".into(),
140        });
141        return;
142    };
143    let text = String::from_utf8_lossy(&xml);
144    if !text.contains("pdfaid:part") && !text.contains("http://www.aiim.org/pdfa/ns/id/") {
145        out.push(Violation {
146            rule: "xmp-pdfaid",
147            message: "XMP metadata carries no PDF/A identification (pdfaid:part)".into(),
148        });
149    }
150}
151
152/// The (part, conformance) the XMP claims, when parseable.
153fn xmp_claim(file: &PdfFile) -> Option<(String, String)> {
154    let xml = crate::xmp::metadata_bytes(file)?;
155    let text = String::from_utf8_lossy(&xml);
156    let part = extract_xmp_value(&text, "pdfaid:part")?;
157    let conf = extract_xmp_value(&text, "pdfaid:conformance").unwrap_or_default();
158    Some((part, conf))
159}
160
161/// Pull `name`'s value out of XMP in either element (`<name>v</name>`) or
162/// attribute (`name="v"`) form.
163fn extract_xmp_value(text: &str, name: &str) -> Option<String> {
164    if let Some(start) = text.find(&format!("<{name}>")) {
165        let vstart = start + name.len() + 2;
166        let vend = text[vstart..].find('<')? + vstart;
167        return Some(text[vstart..vend].trim().to_string());
168    }
169    let attr = format!("{name}=\"");
170    if let Some(start) = text.find(&attr) {
171        let vstart = start + attr.len();
172        let vend = text[vstart..].find('"')? + vstart;
173        return Some(text[vstart..vend].trim().to_string());
174    }
175    None
176}
177
178// ---------------------------------------------------------------------------
179// Output intent
180// ---------------------------------------------------------------------------
181
182fn check_output_intent(file: &PdfFile, out: &mut Vec<Violation>) {
183    let intents = crate::output_intents::parse_output_intents(file);
184    let pdfa_intent = intents.iter().find(|i| i.subtype == "GTS_PDFA1");
185    match pdfa_intent {
186        None => out.push(Violation {
187            rule: "output-intent",
188            message: "no GTS_PDFA1 output intent; PDF/A requires one for device-dependent color"
189                .into(),
190        }),
191        Some(intent) => {
192            if intent.dest_output_profile.is_none() {
193                out.push(Violation {
194                    rule: "output-intent-profile",
195                    message: "PDF/A output intent has no embedded /DestOutputProfile ICC stream"
196                        .into(),
197                });
198            }
199        }
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Fonts
205// ---------------------------------------------------------------------------
206
207fn check_fonts(file: &PdfFile, out: &mut Vec<Violation>) {
208    // Walk every page's resource /Font entries and require an embedded font
209    // file in the descriptor (FontFile / FontFile2 / FontFile3). Type0 fonts
210    // recurse into their descendant. Type3 fonts have no descriptor (their
211    // glyphs are content streams) and are exempt.
212    let mut reported: HashSet<String> = HashSet::new();
213    for dict in collect_font_dicts(file) {
214        let subtype = dict.get_name("Subtype").unwrap_or("");
215        if subtype == "Type3" {
216            continue;
217        }
218        let base = dict.get_name("BaseFont").unwrap_or("?").to_string();
219
220        // Type0: check the descendant CIDFont's descriptor.
221        let target = if subtype == "Type0" {
222            match dict.get("DescendantFonts").map(|o| deref(file, o)) {
223                Some(PdfObject::Array(a)) if !a.is_empty() => match deref(file, &a[0]) {
224                    PdfObject::Dict(d) => Some(d),
225                    _ => None,
226                },
227                _ => None,
228            }
229        } else {
230            Some(dict.clone())
231        };
232
233        let embedded = target
234            .as_ref()
235            .and_then(|d| d.get("FontDescriptor").map(|o| deref(file, o)))
236            .and_then(|fd| match fd {
237                PdfObject::Dict(d) => Some(d),
238                _ => None,
239            })
240            .is_some_and(|fd| {
241                fd.get("FontFile").is_some()
242                    || fd.get("FontFile2").is_some()
243                    || fd.get("FontFile3").is_some()
244            });
245        if !embedded && reported.insert(base.clone()) {
246            out.push(Violation {
247                rule: "font-not-embedded",
248                message: format!("font '{base}' is not embedded; PDF/A requires embedding"),
249            });
250        }
251    }
252}
253
254/// Every font dictionary referenced from any page's /Resources /Font.
255fn collect_font_dicts(file: &PdfFile) -> Vec<zpdf_core::PdfDict> {
256    let mut out = Vec::new();
257    let mut seen: HashSet<ObjectId> = HashSet::new();
258    let Ok(root) = file.trailer.get_ref("Root") else {
259        return out;
260    };
261    let Ok(catalog) = file.resolve(root).and_then(|o| o.as_dict().cloned()) else {
262        return out;
263    };
264    let Ok(pages_root) = catalog.get_ref("Pages") else {
265        return out;
266    };
267    // Bounded page-tree walk collecting /Resources /Font values.
268    let mut stack = vec![(pages_root, 0usize)];
269    let mut visited: HashSet<ObjectId> = HashSet::new();
270    while let Some((node, depth)) = stack.pop() {
271        if depth > 64 || !visited.insert(node) {
272            continue;
273        }
274        let Ok(dict) = file.resolve(node).and_then(|o| o.as_dict().cloned()) else {
275            continue;
276        };
277        if dict.get("Resources").is_some() {
278            let res = match dict.get("Resources") {
279                Some(o) => deref(file, o),
280                None => PdfObject::Null,
281            };
282            if let PdfObject::Dict(res) = res {
283                if let Some(PdfObject::Dict(fonts)) = res.get("Font").map(|o| deref(file, o)) {
284                    for v in fonts.0.values() {
285                        if let PdfObject::Ref(r) = v {
286                            if !seen.insert(*r) {
287                                continue;
288                            }
289                        }
290                        if let PdfObject::Dict(f) = deref(file, v) {
291                            out.push(f);
292                        }
293                    }
294                }
295            }
296        }
297        if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(file, o)) {
298            for kid in kids {
299                if let PdfObject::Ref(r) = kid {
300                    stack.push((r, depth + 1));
301                }
302            }
303        }
304    }
305    out
306}
307
308// ---------------------------------------------------------------------------
309// Forbidden features
310// ---------------------------------------------------------------------------
311
312fn check_forbidden_features(file: &PdfFile, profile: Profile, out: &mut Vec<Violation>) {
313    let Ok(root) = file.trailer.get_ref("Root") else {
314        return;
315    };
316    let Ok(catalog) = file.resolve(root).and_then(|o| o.as_dict().cloned()) else {
317        return;
318    };
319
320    // JavaScript / launch actions (all parts).
321    if let Some(PdfObject::Dict(names)) = catalog.get("Names").map(|o| deref(file, o)).as_ref() {
322        if names.get("JavaScript").is_some() {
323            out.push(Violation {
324                rule: "javascript",
325                message: "document-level JavaScript name tree present; forbidden in PDF/A".into(),
326            });
327        }
328    }
329    if catalog.get("OpenAction").is_some() {
330        // /OpenAction with a destination array is fine; an action dict with
331        // /S /JavaScript or /Launch is not. Flag only the risky forms.
332        if let Some(PdfObject::Dict(action)) =
333            catalog.get("OpenAction").map(|o| deref(file, o)).as_ref()
334        {
335            let s = action.get_name("S").unwrap_or("");
336            if s == "JavaScript" || s == "Launch" {
337                out.push(Violation {
338                    rule: "open-action",
339                    message: format!("/OpenAction /S /{s} is forbidden in PDF/A"),
340                });
341            }
342        }
343    }
344
345    // Embedded files: forbidden in A-1; allowed (with conditions) in A-2 — we
346    // flag A-1 only (A-2's "must itself be PDF/A" condition is out of scope).
347    if profile == Profile::A1b {
348        if let Some(PdfObject::Dict(names)) = catalog.get("Names").map(|o| deref(file, o)).as_ref()
349        {
350            if names.get("EmbeddedFiles").is_some() {
351                out.push(Violation {
352                    rule: "embedded-files",
353                    message: "embedded files are forbidden in PDF/A-1".into(),
354                });
355            }
356        }
357    }
358
359    // A-1: transparency is forbidden — detect page-level transparency groups.
360    if profile == Profile::A1b {
361        let mut stack = vec![(catalog.get_ref("Pages").ok(), 0usize)];
362        let mut visited: HashSet<ObjectId> = HashSet::new();
363        while let Some((Some(node), depth)) = stack.pop() {
364            if depth > 64 || !visited.insert(node) {
365                continue;
366            }
367            let Ok(dict) = file.resolve(node).and_then(|o| o.as_dict().cloned()) else {
368                continue;
369            };
370            if let Some(PdfObject::Dict(group)) = dict.get("Group").map(|o| deref(file, o)).as_ref()
371            {
372                if group.get_name("S").ok() == Some("Transparency") {
373                    out.push(Violation {
374                        rule: "transparency",
375                        message: "transparency group on a page; forbidden in PDF/A-1".into(),
376                    });
377                    break;
378                }
379            }
380            if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(file, o)).as_ref()
381            {
382                for kid in kids {
383                    if let PdfObject::Ref(r) = kid {
384                        stack.push((Some(*r), depth + 1));
385                    }
386                }
387            }
388        }
389    }
390
391    // Forbidden annotation subtypes (all parts): 3D, Sound, Movie reference
392    // non-embedded interactive/multimedia content. FileAttachment carries an
393    // embedded file, forbidden in A-1 (A-2 permits it). Annotations whose /A
394    // action is JavaScript/Launch are also forbidden. Widget annotations
395    // (form fields) are permitted with properly embedded appearance fonts.
396    check_forbidden_annotations(file, profile, &catalog, out);
397}
398
399/// Annotation subtypes PDF/A forbids in every part (interactive/multimedia
400/// types referencing non-embedded external content or scripting).
401const FORBIDDEN_ANNOT_SUBTYPES_BOTH: &[&str] = &["3D", "Sound", "Movie"];
402
403/// Annotation subtypes PDF/A-1 additionally forbids. FileAttachment carries
404/// an embedded file, which A-1 disallows (A-2 permits embedded files).
405const FORBIDDEN_ANNOT_SUBTYPES_A1B: &[&str] = &["FileAttachment"];
406
407/// Walk the page tree and flag forbidden annotation subtypes and annotations
408/// whose `/A` action is a JavaScript/Launch action. Best-effort: bounded by
409/// page-tree depth; an unresolvable annotation is skipped, not flagged.
410fn check_forbidden_annotations(
411    file: &PdfFile,
412    profile: Profile,
413    catalog: &PdfDict,
414    out: &mut Vec<Violation>,
415) {
416    let Some(pages) = catalog.get_ref("Pages").ok() else {
417        return;
418    };
419    let a1b = profile == Profile::A1b;
420    let mut stack = vec![(pages, 0usize)];
421    let mut visited: HashSet<ObjectId> = HashSet::new();
422    while let Some((node, depth)) = stack.pop() {
423        if depth > 64 || !visited.insert(node) {
424            continue;
425        }
426        let Ok(dict) = file.resolve(node).and_then(|o| o.as_dict().cloned()) else {
427            continue;
428        };
429        if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(file, o)).as_ref() {
430            for kid in kids {
431                if let PdfObject::Ref(r) = kid {
432                    stack.push((*r, depth + 1));
433                }
434            }
435        }
436        let annots_obj = dict.get("Annots").map(|o| deref(file, o));
437        let Some(PdfObject::Array(annots)) = annots_obj.as_ref() else {
438            continue;
439        };
440        for a in annots {
441            let PdfObject::Dict(ad) = deref(file, a) else {
442                continue;
443            };
444            let subtype = ad.get_name("Subtype").unwrap_or("");
445            let forbidden = FORBIDDEN_ANNOT_SUBTYPES_BOTH.contains(&subtype)
446                || (a1b && FORBIDDEN_ANNOT_SUBTYPES_A1B.contains(&subtype));
447            if forbidden {
448                out.push(Violation {
449                    rule: "annotation-subtype",
450                    message: format!(
451                        "/Annot /Subtype /{subtype} is forbidden in PDF/A{}",
452                        if a1b && subtype == "FileAttachment" {
453                            "-1 (carries an embedded file)"
454                        } else {
455                            ""
456                        }
457                    ),
458                });
459                continue;
460            }
461            if let Some(PdfObject::Dict(action)) = ad.get("A").map(|o| deref(file, o)).as_ref() {
462                let s = action.get_name("S").unwrap_or("");
463                if s == "JavaScript" || s == "Launch" {
464                    out.push(Violation {
465                        rule: "annotation-action",
466                        message: format!("annotation /A /S /{s} action is forbidden in PDF/A"),
467                    });
468                }
469            }
470        }
471    }
472}
473
474fn deref(file: &PdfFile, obj: &PdfObject) -> PdfObject {
475    match obj {
476        PdfObject::Ref(r) => file.resolve(*r).unwrap_or(PdfObject::Null),
477        other => other.clone(),
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    fn minimal_pdf() -> Vec<u8> {
486        let mut data = Vec::new();
487        data.extend_from_slice(b"%PDF-1.4\n");
488        data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
489        data.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
490        data.extend_from_slice(
491            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
492        );
493        data.extend_from_slice(b"xref\n0 4\n");
494        data.extend_from_slice(b"0000000000 65535 f \n");
495        data.extend_from_slice(b"0000000009 00000 n \n");
496        data.extend_from_slice(b"0000000058 00000 n \n");
497        data.extend_from_slice(b"0000000117 00000 n \n");
498        data.extend_from_slice(b"trailer\n<< /Size 4 /Root 1 0 R >>\n");
499        data.extend_from_slice(b"startxref\n187\n%%EOF\n");
500        data
501    }
502
503    #[test]
504    fn bare_pdf_fails_with_specific_violations() {
505        let file = PdfFile::parse(minimal_pdf()).unwrap();
506        let report = validate(&file, Profile::A1b);
507        assert!(!report.conforms());
508        let rules: Vec<&str> = report.violations.iter().map(|v| v.rule).collect();
509        assert!(rules.contains(&"file-id"), "missing /ID flagged: {rules:?}");
510        assert!(
511            rules.contains(&"xmp-missing"),
512            "missing XMP flagged: {rules:?}"
513        );
514        assert!(
515            rules.contains(&"output-intent"),
516            "missing output intent flagged: {rules:?}"
517        );
518    }
519
520    #[test]
521    fn claim_extraction_from_attribute_and_element_forms() {
522        assert_eq!(
523            extract_xmp_value(r#"<x pdfaid:part="2"/>"#, "pdfaid:part").as_deref(),
524            Some("2")
525        );
526        assert_eq!(
527            extract_xmp_value("<pdfaid:part>1</pdfaid:part>", "pdfaid:part").as_deref(),
528            Some("1")
529        );
530        assert_eq!(extract_xmp_value("<nothing/>", "pdfaid:part"), None);
531    }
532
533    /// Build a PDF with a page carrying the given `/Annots` object bodies (each
534    /// becomes its own object after the page). Returns the bytes.
535    fn pdf_with_annots(annots: &[&str]) -> Vec<u8> {
536        let n_objs = 3 + annots.len();
537        let mut data = Vec::new();
538        data.extend_from_slice(b"%PDF-1.4\n");
539        let mut offsets = Vec::new();
540        // obj 1: catalog, 2: pages, 3: page with /Annots [4 0 R 5 0 R ...]
541        let annot_refs: Vec<String> = (0..annots.len())
542            .map(|i| format!("{} 0 R", 4 + i))
543            .collect();
544        let bodies = [
545            "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
546            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
547            format!(
548                "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [{}] >>",
549                annot_refs.join(" ")
550            ),
551        ];
552        for (i, body) in bodies.iter().enumerate() {
553            offsets.push(data.len());
554            data.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", i + 1, body).as_bytes());
555        }
556        for (i, body) in annots.iter().enumerate() {
557            offsets.push(data.len());
558            data.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", 4 + i, body).as_bytes());
559        }
560        let xref = data.len();
561        data.extend_from_slice(format!("xref\n0 {}\n", n_objs + 1).as_bytes());
562        data.extend_from_slice(b"0000000000 65535 f \n");
563        for off in &offsets {
564            data.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
565        }
566        data.extend_from_slice(
567            format!(
568                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
569                n_objs + 1
570            )
571            .as_bytes(),
572        );
573        data
574    }
575
576    #[test]
577    fn forbidden_annotation_subtypes_are_flagged() {
578        let pdf = pdf_with_annots(&[
579            "<< /Type /Annot /Subtype /Sound /Rect [0 0 10 10] >>",
580            "<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] >>",
581        ]);
582        let file = PdfFile::parse(pdf).unwrap();
583        let report = validate(&file, Profile::A1b);
584        let rules: Vec<&str> = report.violations.iter().map(|v| v.rule).collect();
585        assert!(
586            rules.contains(&"annotation-subtype"),
587            "Sound annotation must be flagged: {rules:?}"
588        );
589    }
590
591    #[test]
592    fn fileattachment_flagged_under_a1b_not_a2b() {
593        let pdf =
594            pdf_with_annots(&["<< /Type /Annot /Subtype /FileAttachment /Rect [0 0 10 10] >>"]);
595        let file = PdfFile::parse(pdf).unwrap();
596        let a1b = validate(&file, Profile::A1b);
597        let a2b = validate(&file, Profile::A2b);
598        let a1b_rules: Vec<&str> = a1b.violations.iter().map(|v| v.rule).collect();
599        let a2b_rules: Vec<&str> = a2b.violations.iter().map(|v| v.rule).collect();
600        assert!(
601            a1b_rules.contains(&"annotation-subtype"),
602            "A-1b must flag FileAttachment: {a1b_rules:?}"
603        );
604        assert!(
605            !a2b_rules.contains(&"annotation-subtype"),
606            "A-2b must not flag FileAttachment: {a2b_rules:?}"
607        );
608    }
609
610    #[test]
611    fn launch_action_annotation_is_flagged() {
612        let pdf = pdf_with_annots(&[
613            "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /Launch /F (x) >> >>",
614        ]);
615        let file = PdfFile::parse(pdf).unwrap();
616        let report = validate(&file, Profile::A1b);
617        let rules: Vec<&str> = report.violations.iter().map(|v| v.rule).collect();
618        assert!(
619            rules.contains(&"annotation-action"),
620            "Launch-action annotation must be flagged: {rules:?}"
621        );
622    }
623}