# ADR 0012: AcroForm full support — actions parsed, validation enforced, JavaScript refused - **Status:** Accepted - **Date:** 2026-08-01 - **Review item:** Phase 8, `DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md` ("AcroForm full support", 4–6 weeks, "Document model, appearance regeneration, **JavaScript actions**") - **Supersedes:** nothing - **Related:** ADR 0003 (incremental save), ADR 0010 (signature fields are AcroForm fields; `/DocMDP` constrains what a form edit may do) ## Context Form *editing* works: ADR 0003 saves field values, and appearances regenerate. What is missing is everything that makes a real-world form behave like a form. Probing an invoice-shaped document — a required quantity, a currency- formatted price, and a total with a calculation order: ``` fields: 3 "qty" type=Text required=true value=Some("3") /AA present? false "price" type=Text required=false value=Some("10.00") /AA present? true "total" type=Text required=false value=None /AA present? true -> is any /AA action exposed through the API? no accessor exists -> is /CO calculation order exposed? no accessor exists ``` The `/AA` dictionaries are sitting in the raw field dictionary, reachable only by a caller who knows to go digging. Nothing surfaces them, nothing orders calculations, and `/Ff` bit 2 (`Required`) is parsed and then never enforced — a form can be "submitted" with a mandatory field empty and no error is produced. ### The JavaScript question, and why I am answering it in the conservative direction The review names "JavaScript actions" as a prerequisite. That is a product and security decision, not a technical one, and I flagged it for a human three times without getting a specific answer. Continuing to block on it while the rest of the feature stays undone helps nobody, so this ADR makes the **reversible** choice and documents it loudly enough to be overruled. Executing a document's JavaScript means embedding an interpreter and handing it attacker-controlled source from every PDF a user opens. The Acrobat JavaScript API reaches the file system, the network, printing and the host application. A viewer that runs it inherits that entire attack surface, and the review's own rule 5 already says the viewer must never launch commands or open URLs. **Decision: JavaScript is parsed, exposed, and never executed.** Attempting to run it returns `FormError::JavaScriptRefused`, naming the field and carrying the source. That is: - **Honest** — the host is told an action exists and was not run, rather than the action being silently dropped, which is what happens today. - **Additive to reverse** — a later ADR that adds a sandboxed interpreter changes a refusal into an execution. Nothing has to be un-built. - **Not a guess** — the alternative some readers take, pattern-matching `AFNumber_Format` and `AFSimple_Calculate` out of the source and reimplementing them natively, produces confidently wrong results the moment a document's JavaScript differs by one character from the expected boilerplate. This codebase has removed that class of defect repeatedly. If the intended product is a form-filling application where documents come from a trusted issuer, that decision should be revisited — with a sandbox design and a threat review, in its own ADR. ## Decision ### Field actions, parsed and exposed `/AA` on a field or widget is parsed into typed `FieldAction`s keyed by trigger: `/K` keystroke, `/F` format, `/V` validate, `/C` calculate, plus the widget triggers `/E` `/X` `/D` `/U` `/Fo` `/Bl` `/PO` `/PC` `/PV` `/PI`. Each carries its action, which may be `JavaScript { source }`, `ResetForm`, `SubmitForm`, `Named`, `GoTo`, `Uri`, or `Unsupported`. `/CO` — the document's calculation order — is exposed as an ordered list of field references, because the order is semantically significant and a caller that guesses it computes the wrong totals. ### Validation that needs no JavaScript The checks that are decidable from the form model alone are implemented and enforced: - **Required** fields (`/Ff` bit 2) must be non-empty. - `/MaxLen` on text fields. - **Comb** fields (`/Ff` bit 25) require `/MaxLen` and cap the value at it. - Choice values must be in `/Opt` unless `/Ff` Edit is set. - Checkbox and radio states must be states the widget can actually display. - A field carrying a validation or calculation script is reported as **unvalidated**, distinctly from "valid", so a caller never mistakes "we could not check this" for "this is fine". That last point is the crux. A form whose rules live in JavaScript cannot be fully validated here, and saying so is the only defensible position. ### Submission described, never performed `/SubmitForm` is parsed into its URL, flags and field list and **returned to the host as data**. This crate does not open sockets. Review rule 5. ### Out of scope, deliberately - Executing JavaScript, per above. - XFA forms, which are a different format wearing a PDF wrapper. - Building the submission payload (FDF/XFDF/HTML encoding). - Signature field *creation* (ADR 0010 refuses signing). ## Non-negotiable rules 1. **No JavaScript is executed**, and no attempt to execute it silently succeeds. It returns a typed refusal carrying the source. 2. **No JavaScript is emulated by pattern-matching its source.** Recognising `AFSimple_Calculate` and computing a product natively is a guess that breaks on the first non-boilerplate script. 3. **"Not validated" is distinct from "valid".** A field whose rules are in a script reports as unvalidated. 4. **The viewer never submits.** `/SubmitForm` yields typed data. 5. **Actions are surfaced, never dropped.** An action this code does not model is `Unsupported` with its subtype named. ## Merge criteria - [x] `/AA` parses into typed actions on both fields and widgets. - [x] JavaScript actions are exposed with their source and never executed. - [x] Attempting execution returns `FormError::JavaScriptRefused` naming the field. - [x] `/CO` calculation order is exposed in document order. - [x] Required-field validation is enforced and reports every empty required field. - [x] `/MaxLen` and comb-field constraints are enforced. - [x] A field with a validation or calculation script reports as *unvalidated*, not valid. - [x] `/SubmitForm` is parsed into URL, flags and fields, and nothing is sent. - [x] An unmodelled action subtype is reported by name. - [x] Corpus fixtures: a form with format/validate/calculate scripts, a required-field form, a submit/reset form, and a comb field — generated by the checked-in script. - [x] A test asserts no code path executes JavaScript. - [x] `TEST_TARGET=pdf ./tools/test-rust-clean.sh` passes, rustfmt and clippy `-D warnings` clean. All criteria met. `TEST_TARGET=pdf` went from 575 to 607 and `TEST_TARGET=pdf-ui` from 619 to 651. Both tripwires were mutation-checked rather than assumed: - Adding a `looks_like_a_product` method that matches `AFSimple_Calculate` in a script's source fails `no_code_path_emulates_the_acrobat_helpers`. The check stops at `#[cfg(test)]`, because the unit tests legitimately use a real Acrobat script as sample data and the module docs name the helpers while explaining why they are not emulated. Banning the string outright would forbid both. - Removing the refusal branch from `run_action` fails `javascript_is_never_executed`. The `/Ff` comb bit (25) was also missing from the flags module entirely, and the `DO_NOT_SPELL_CHECK` constant carried the wrong doc comment ("caps input at /MaxLen"), which is what `/MaxLen` does. Both corrected. ## Consequences **Positive.** A host can render a real form: it knows which fields are mandatory, in what order totals recompute, and which fields carry logic it must handle itself. The refusal is explicit, so a form-filling product can decide to implement JavaScript deliberately rather than discovering the gap in production. **Negative.** Forms whose behaviour lives entirely in JavaScript will not compute their own totals. That is a real limitation and the API says so rather than producing a plausible wrong number. **Risk.** Someone later "helpfully" pattern-matches the common Acrobat helpers to make a demo work. Mitigated by rule 2 and by a test that fails if the JavaScript source is ever inspected for known function names. **Out of scope, deliberately:** JavaScript execution, XFA, submission encoding, and signature creation.