nigig-org/crates/apps/pdf/pdf-document/tests/form_actions.rs
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

315 lines
10 KiB
Rust

//! AcroForm actions and validation acceptance tests.
//!
//! The merge criteria from `REVIEWS/adr/0012-pdf-acroform-full.md`.
//!
//! The load-bearing tests here are the negative ones:
//! [`javascript_is_never_executed`] and
//! [`no_code_path_emulates_the_acrobat_helpers`]. A form feature fails
//! dangerously when it *appears* to have run a document's logic — either by
//! executing it, or by recognising the common Acrobat boilerplate and
//! reimplementing it natively, which produces a confidently wrong number
//! the moment a script differs from the expected text.
use std::path::PathBuf;
use nigig_pdf_document::form::{DocumentFormEditor, FieldType, FormError};
use nigig_pdf_document::form_actions::{ActionTrigger, FieldAction, ValidationFailure};
use nigig_pdf_document::PdfDocument;
fn corpus(relative: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tests/corpus")
.join(relative);
std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display()))
}
fn form_of(relative: &str) -> nigig_pdf_document::form::AcroForm {
let bytes = corpus(relative);
let mut doc = PdfDocument::parse(&bytes).expect("parses");
doc.acroform().expect("acroform").expect("present")
}
#[test]
fn field_actions_are_exposed_rather_than_buried_in_the_raw_dictionary() {
let form = form_of("forms/actions.pdf");
let price = form.find_field("price").expect("price field");
assert!(
!price.actions.is_empty(),
"/AA was present in the raw dict but never surfaced"
);
assert!(matches!(
price.actions.get(ActionTrigger::Format),
Some(FieldAction::JavaScript { .. })
));
assert!(matches!(
price.actions.get(ActionTrigger::Keystroke),
Some(FieldAction::JavaScript { .. })
));
let total = form.find_field("total").expect("total field");
assert!(matches!(
total.actions.get(ActionTrigger::Calculate),
Some(FieldAction::JavaScript { .. })
));
}
#[test]
fn calculation_order_is_exposed_in_document_order() {
let form = form_of("forms/actions.pdf");
let co = form.calculation_order();
assert_eq!(co.fields.len(), 1, "the fixture declares one /CO entry");
let total = form.find_field("total").expect("total");
assert_eq!(
co.position_of(total.obj_ref),
Some(0),
"the calculated field must be locatable in the order"
);
}
/// ADR 0012 rule 1. There is no path that runs a script, and the refusal
/// carries the source so a host with its own sandbox can decide.
#[test]
fn javascript_is_never_executed() {
let bytes = corpus("forms/actions.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let mut form = doc.acroform().expect("acroform").expect("present");
let total_ref = form.find_field("total").expect("total").obj_ref;
let editor = DocumentFormEditor::new(&mut form);
let result = editor.run_action(total_ref, ActionTrigger::Calculate);
match result {
Err(FormError::JavaScriptRefused {
field,
trigger,
source,
}) => {
assert_eq!(field, "total");
assert_eq!(trigger, ActionTrigger::Calculate);
assert!(
source.contains("AFSimple_Calculate"),
"the refusal must carry the source: {source:?}"
);
}
other => panic!("a script must be refused, not run: {other:?}"),
}
}
#[test]
fn a_non_script_action_is_returned_as_data() {
let bytes = corpus("forms/submit_reset.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let mut form = doc.acroform().expect("acroform").expect("present");
let submit_ref = form.find_field("submit").expect("submit").obj_ref;
let editor = DocumentFormEditor::new(&mut form);
let action = editor
.run_action(submit_ref, ActionTrigger::MouseUp)
.expect("a submit action is data, not a refusal")
.expect("the field has a MouseUp action");
match action {
FieldAction::SubmitForm(target) => {
assert_eq!(target.url.as_deref(), Some("https://example.org/submit"));
assert!(target.submits_html());
assert_eq!(target.fields, vec!["qty".to_string(), "price".to_string()]);
}
other => panic!("expected SubmitForm, got {other:?}"),
}
}
#[test]
fn a_reset_action_lists_the_fields_it_would_clear() {
let bytes = corpus("forms/submit_reset.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let mut form = doc.acroform().expect("acroform").expect("present");
let reset_ref = form.find_field("reset").expect("reset").obj_ref;
let editor = DocumentFormEditor::new(&mut form);
let action = editor
.run_action(reset_ref, ActionTrigger::MouseUp)
.expect("data")
.expect("an action");
assert_eq!(
action,
FieldAction::ResetForm {
fields: vec!["qty".to_string()]
}
);
}
#[test]
fn asking_for_an_absent_trigger_is_not_an_error() {
let bytes = corpus("forms/actions.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let mut form = doc.acroform().expect("acroform").expect("present");
let qty_ref = form.find_field("qty").expect("qty").obj_ref;
let editor = DocumentFormEditor::new(&mut form);
assert_eq!(
editor.run_action(qty_ref, ActionTrigger::Calculate),
Ok(None),
"a field with no such action yields None, not a failure"
);
}
/// `/Ff` bit 2 was parsed and never enforced.
#[test]
fn a_required_but_empty_field_fails_validation() {
let form = form_of("forms/required.pdf");
let validation = form.validate();
assert!(
validation.failures.iter().any(|f| matches!(
f,
ValidationFailure::RequiredButEmpty { field } if field == "blank"
)),
"the empty required field was not reported: {:?}",
validation.failures
);
assert!(
!validation.failures.iter().any(|f| matches!(
f,
ValidationFailure::RequiredButEmpty { field } if field == "filled"
)),
"a filled required field must pass"
);
}
#[test]
fn a_comb_field_without_maxlen_is_reported() {
let form = form_of("forms/comb.pdf");
let validation = form.validate();
assert!(
validation.failures.iter().any(|f| matches!(
f,
ValidationFailure::CombWithoutMaxLen { field } if field == "broken"
)),
"{:?}",
validation.failures
);
// The well-formed comb field must not be reported.
assert!(
!validation.failures.iter().any(|f| matches!(
f,
ValidationFailure::CombWithoutMaxLen { field } if field == "postcode"
)),
"a comb field with /MaxLen is fine"
);
}
/// The crux of ADR 0012: a field whose rules live in a script is reported
/// as *unvalidated*, never as valid. Confusing the two is how a form
/// silently accepts a value its own rules would have rejected.
#[test]
fn script_controlled_fields_report_as_unvalidated_not_valid() {
let form = form_of("forms/actions.pdf");
let validation = form.validate();
assert!(
validation.no_failures(),
"nothing mechanically checkable fails here: {:?}",
validation.failures
);
assert!(
validation.has_unvalidated(),
"fields with calculate/format scripts must be reported as unchecked"
);
assert!(validation.unvalidated.contains(&"total".to_string()));
assert!(validation.unvalidated.contains(&"price".to_string()));
// And the field with no script is not swept into that bucket.
assert!(
!validation.unvalidated.contains(&"qty".to_string()),
"a script-free field must be genuinely validated"
);
}
#[test]
fn a_field_with_no_scripts_validates_normally() {
let form = form_of("forms/required.pdf");
let validation = form.validate();
assert!(
validation.unvalidated.is_empty(),
"no field here carries a script: {:?}",
validation.unvalidated
);
}
#[test]
fn a_pushbutton_holds_no_value_and_never_fails_validation() {
let form = form_of("forms/submit_reset.pdf");
for field in form.fields() {
assert_eq!(field.field_type, FieldType::Pushbutton);
}
assert!(form.validate().no_failures());
}
/// ADR 0012 rule 2. Recognising `AFSimple_Calculate` in the source and
/// computing a product natively works on boilerplate and breaks on the
/// first script that differs. This is a tripwire against a future
/// "helpful" shortcut.
#[test]
fn no_code_path_emulates_the_acrobat_helpers() {
let sources = [
include_str!("../src/form_actions.rs"),
include_str!("../src/form.rs"),
];
for source in sources {
// Only production code counts. Comments explain *why* the helpers
// are not emulated, and the unit tests legitimately use a real
// script as sample data; banning the string outright would forbid
// both, so the check stops at `#[cfg(test)]`.
let production = source
.split("#[cfg(test)]")
.next()
.expect("a source file has a first segment");
let code_only: String = production
.lines()
.filter(|l| {
let t = l.trim_start();
!t.starts_with("//") && !t.starts_with('*')
})
.collect::<Vec<_>>()
.join("\n");
for helper in [
"AFNumber_Format",
"AFSimple_Calculate",
"AFPercent_",
"AFDate_",
] {
assert!(
!code_only.contains(helper),
"code references the Acrobat helper `{helper}`; emulating \
JavaScript by pattern-matching its source is forbidden by \
ADR 0012 rule 2"
);
}
}
}
#[test]
fn every_form_fixture_parses_and_validates_without_panicking() {
for name in [
"all_types.pdf",
"inherited.pdf",
"actions.pdf",
"required.pdf",
"submit_reset.pdf",
"comb.pdf",
] {
let bytes = corpus(&format!("forms/{name}"));
let mut doc =
PdfDocument::parse(&bytes).unwrap_or_else(|e| panic!("forms/{name} should parse: {e}"));
if let Ok(Some(form)) = doc.acroform() {
let _ = form.validate();
let _ = form.calculation_order();
for field in form.fields() {
let _ = form.validate_field(field);
let _ = field.actions.javascript_sources();
}
}
}
}