Phase 8 #8 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md ("AcroForm full support"). Design and merge criteria in REVIEWS/adr/0012-pdf-acroform-full.md. Form editing already worked. What was missing is everything that makes a real form behave like one. Probing an invoice-shaped document: "qty" required=true /AA present? false "price" required=false /AA present? true "total" required=false /AA present? true -> is any /AA action exposed? no accessor exists -> is /CO exposed? no accessor exists The /AA dictionaries sat in the raw field dictionary, reachable only by a caller who knew to go digging. Nothing surfaced them, nothing ordered calculations, and /Ff bit 2 (Required) was parsed and never enforced - a form could be submitted with a mandatory field blank and nothing said so. THE JAVASCRIPT DECISION The review names JavaScript actions as a prerequisite. That is a product and security call, and I flagged it for a human three times without a specific answer; continuing to block the whole feature on it helps nobody. This takes the reversible option and documents it loudly enough to overrule: JavaScript is parsed, exposed, and NEVER EXECUTED. Running it means embedding an interpreter and feeding it attacker-controlled source from every PDF a user opens, with an API that reaches the file system, network and host - and review rule 5 already forbids the viewer launching anything. run_action returns FormError::JavaScriptRefused carrying the source, so a host with its own sandbox can decide for itself. A later ADR turns a refusal into an execution; nothing has to be un-built. Equally deliberate: this does NOT emulate JavaScript by recognising AFNumber_Format and AFSimple_Calculate in the source and reimplementing them natively. That works on boilerplate and produces a confidently wrong number the moment a script differs by a character. WHAT IS IMPLEMENTED - /AA parsed into typed actions across all 14 triggers, on fields and widgets, with indirect action dictionaries resolved. - /CO calculation order exposed in document order. - Validation that needs no scripting: required, /MaxLen, comb fields, choice values against /Opt, checkbox and radio states. - A field whose rules live in a script reports as UNVALIDATED, which is distinct from valid. Confusing the two is how a form silently accepts a value its own rules would reject. - /SubmitForm parsed into URL, flags and fields and returned as data. This crate opens no sockets. Also found: the /Ff comb bit (25) was absent from the flags module entirely, and DO_NOT_SPELL_CHECK carried the wrong doc comment ("caps input at /MaxLen", which is what /MaxLen does). 4 corpus fixtures, 13 acceptance tests, 20 unit tests. Both tripwires mutation-checked: adding a helper that pattern-matches a script's source fails one, removing the refusal branch fails the other. TEST_TARGET=pdf 575 -> 607, TEST_TARGET=pdf-ui 619 -> 651. rustfmt and clippy -D warnings clean.
8.4 KiB
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;
/DocMDPconstrains 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_FormatandAFSimple_Calculateout 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 FieldActions 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 (
/Ffbit 2) must be non-empty. /MaxLenon text fields.- Comb fields (
/Ffbit 25) require/MaxLenand cap the value at it. - Choice values must be in
/Optunless/FfEdit 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
- No JavaScript is executed, and no attempt to execute it silently succeeds. It returns a typed refusal carrying the source.
- No JavaScript is emulated by pattern-matching its source. Recognising
AFSimple_Calculateand computing a product natively is a guess that breaks on the first non-boilerplate script. - "Not validated" is distinct from "valid". A field whose rules are in a script reports as unvalidated.
- The viewer never submits.
/SubmitFormyields typed data. - Actions are surfaced, never dropped. An action this code does not
model is
Unsupportedwith its subtype named.
Merge criteria
/AAparses into typed actions on both fields and widgets.- JavaScript actions are exposed with their source and never executed.
- Attempting execution returns
FormError::JavaScriptRefusednaming the field. /COcalculation order is exposed in document order.- Required-field validation is enforced and reports every empty required field.
/MaxLenand comb-field constraints are enforced.- A field with a validation or calculation script reports as unvalidated, not valid.
/SubmitFormis parsed into URL, flags and fields, and nothing is sent.- An unmodelled action subtype is reported by name.
- 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.
- A test asserts no code path executes JavaScript.
TEST_TARGET=pdf ./tools/test-rust-clean.shpasses, rustfmt and clippy-D warningsclean.
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_productmethod that matchesAFSimple_Calculatein a script's source failsno_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_actionfailsjavascript_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.