//! 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 { 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::>() .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(); } } } }