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.
315 lines
10 KiB
Rust
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();
|
|
}
|
|
}
|
|
}
|
|
}
|