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.
184 lines
8.4 KiB
Markdown
184 lines
8.4 KiB
Markdown
# 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.
|