nigig-org/REVIEWS/adr/0012-pdf-acroform-full.md
andodeki b720a166ec
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
feat(pdf): AcroForm actions and validation; JavaScript refused, not run
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.
2026-07-31 22:41:50 +00:00

8.4 KiB
Raw Permalink Blame History

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", 46 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 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 (/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

  • /AA parses into typed actions on both fields and widgets.
  • JavaScript actions are exposed with their source and never executed.
  • Attempting execution returns FormError::JavaScriptRefused naming the field.
  • /CO calculation order is exposed in document order.
  • Required-field validation is enforced and reports every empty required field.
  • /MaxLen and comb-field constraints are enforced.
  • A field with a validation or calculation script reports as unvalidated, not valid.
  • /SubmitForm is 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.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.