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

184 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `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.