From 9f1cdef8963c550a8914e4c0979eeca9cc15e2bb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 11:44:31 +0000 Subject: [PATCH] fix(engine): require modify for console field-property changes and optimizing, copy for structure bookmarks, refuse read-only image fields --- crates/engine/src/edit/run.rs | 7 +++++-- crates/engine/src/js.rs | 6 ++++++ crates/engine/src/optimizer.rs | 4 ++++ crates/engine/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 5ed70aed7a..0f1ae34341 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -27,7 +27,6 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::SetBookmarkPage { .. } | Edit::SetBookmarkColor { .. } | Edit::SetBookmarkStyle { .. } - | Edit::BookmarksFromStructure | Edit::NumberPages { .. } => { if p.assemble() { Ok(()) @@ -37,7 +36,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re } // Creates bookmarks (assembly), and their titles are the document's text, copied out of it // (Table 22, bit 5), as an export of it would be. - Edit::BookmarksFromTextStyles { .. } => { + Edit::BookmarksFromTextStyles { .. } | Edit::BookmarksFromStructure => { if !p.assemble() { Err(EditError::NotPermitted("page changes")) } else if !p.copy() { @@ -594,6 +593,10 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E ran.map_err(EditError::Invalid)?; } Edit::SetFieldImage { name, image } => { + // As filling a value does: a read-only field keeps its picture. + if pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.read_only()) { + return Err(pdfcraft_forms::FormError::ReadOnly(name.clone()).into()); + } let (img, _) = embed_image(doc, name, image)?; let px = match &*doc.get(img) { pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index d1ee5c15b0..c1c6161dd9 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -298,6 +298,12 @@ impl Session { }; let mut o = pdfcraft_js::run(script, &event, &runner.doc, &states, &runner.doc_scripts, Limits::default()); let changes = changes(&states, &o.changed); + // Console input is the user's, not the document's: making a field read-only, required, + // hidden or shown is a change to the form (Table 22, bit 4), which filling it in is not. + let properties = |c: &FieldChange| c.read_only.is_some() || c.required.is_some() || c.display.is_some(); + if target.is_none() && changes.iter().any(properties) && !doc.allows_modification() { + return Err(EditError::NotPermitted("changes to the document")); + } let resets: Vec> = o .requests .iter() diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index 30a14c5695..a4884dfeea 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -64,6 +64,10 @@ impl Session { return Err(EditError::Signed); } let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + // A rewrite that takes data out of the document, as Remove Hidden Information does. + if !doc.allows_modification() { + return Err(EditError::NotPermitted("changes to the document")); + } Ok(OptimizeJob { cos: editor.cos.clone(), settings: settings.clone(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d60ed04d66..01f7c4627d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10251,3 +10251,39 @@ fn image_transform_engine_refuses_singular_matrices_without_mutation() { assert_eq!(s.get(id).unwrap().bytes, before); } } + +/// Table 22, bit 4 (modify) withheld while forms may be filled in: the console's script may +/// set values, but not make a field read-only, required or hidden, which the property editor +/// refuses too; optimizing (a rewrite that removes data) is refused; bookmarks from the +/// structure copy the headings' text out, so they need copying (bit 5) as the text-style +/// bookmarks do. +#[test] +fn fill_only_documents_refuse_property_changes_from_the_console_and_optimizing() { + let encrypt = |bytes: Vec, permissions: i32| { + let mut doc = pdfcraft_cos::Document::open(Arc::new(bytes)).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) + }; + let refused = Err(EditError::NotPermitted("changes to the document")); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, encrypt(scripted_form(), -1 ^ 0b1000), None).unwrap(); + assert!(s.get(id).unwrap().allows_form_filling() && !s.get(id).unwrap().allows_modification()); + assert_eq!(s.run_javascript(id, "getField('total').readonly = true;", None).map(|_| ()), refused); + assert_eq!(s.run_javascript(id, "getField('total').display = display.hidden;", None).map(|_| ()), refused); + assert!(!s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only(), "nothing changed"); + s.run_javascript(id, "getField('price').value = 3;", None).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().value, ["3"], "values may be filled in"); + assert_eq!(s.optimize_job(id, &optimize::Settings::default(), &[]).map(|_| ()), refused); + + let id = s.open("tagged.pdf", None, encrypt(tagged_headings_fixture(), -1 ^ 0b1_0000), None).unwrap(); + assert_eq!(s.apply(id, Edit::BookmarksFromStructure), Err(EditError::NotPermitted("copying text and images"))); + assert!(s.get(id).unwrap().info.outline.is_empty()); +}