From 0e883c44a00815a40e00dcf2532c63ceee952e21 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:30:44 +0800 Subject: [PATCH] M6: values typed into Password fields are written to no file (#3926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A text field with the Password flag kept its typed value in /V, so Save, a full rewrite, Reduce File Size and the recovery snapshots all wrote it in plain text. ISO 32000-2 §12.7.5.3, Table 231 (note to the Password flag): PDF processors should never store the value in the PDF file. Each of those paths now writes a copy without the values; the open document keeps them in memory, and the masked appearance is kept. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 34 ++++++++++++++++++++++--- crates/engine/src/optimizer.rs | 2 ++ crates/engine/src/tests.rs | 46 ++++++++++++++++++++++++++++++++++ crates/forms/src/lib.rs | 24 ++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f257865d3..56a1a2409 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2905,6 +2905,19 @@ fn renderer_password(bytes: &[u8]) -> String { } } +/// The document as it may be written to a file (#3926): a copy without the values of text fields +/// with the Password flag, or `cos` itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231 +/// (a note to the Password flag): "it is imperative that PDF processors never store the value of +/// the text field in the PDF file if this flag is set". The open document keeps them in memory. +fn writable(cos: &pdfcraft_cos::Document) -> Result, EditError> { + if !pdfcraft_forms::has_password_values(cos) { + return Ok(std::borrow::Cow::Borrowed(cos)); + } + let mut copy = cos.clone(); + pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?; + Ok(std::borrow::Cow::Owned(copy)) +} + /// The last-resort guard (AGENTS.md §4): run `f`, turning a panic that escapes it into an error /// message, so one bad file or edit can't take the app and its other documents down. It is a /// safety net for bugs, not a substitute for returning errors. @@ -4270,12 +4283,13 @@ impl Session { // file so that revision stays in the chain. A full rewrite (redaction, new encryption, // a reconstructed file) still writes the current graph on its own. let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + let cos = writable(&editor.cos)?; guard(|| { if on_saved || rewrite { - write_incremental(&editor.cos, &opts) + write_incremental(&cos, &opts) } else { let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; - base.overlay_changes_from(&editor.cos)?; + base.overlay_changes_from(&cos)?; write_incremental(&base, &opts) } }) @@ -4292,7 +4306,8 @@ impl Session { } let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - guard(|| write_full(&editor.cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + let cos = writable(&editor.cos)?; + guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this @@ -4916,12 +4931,23 @@ impl Session { let mut out = Vec::new(); for d in &mut self.docs { if d.dirty && d.generation != d.snapshot_generation { + // The working bytes hold typed Password-field values (#3926): a recovery file is + // written from a copy without them, and none is written if that copy fails. + let bytes = match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos)) { + Some(e) => match writable(&e.cos) + .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) + { + Ok(b) => Arc::new(b), + Err(_) => continue, + }, + None => d.bytes.clone(), + }; d.snapshot_generation = d.generation; out.push(RecoverySnapshot { doc: d.id, name: d.name.clone(), path: d.path.clone(), - bytes: d.bytes.clone(), + bytes, encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), }); } diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index ee8c957f9..444c428ca 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -83,6 +83,8 @@ impl OptimizeJob { fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result { let OptimizeJob { mut cos, settings, discard, opts } = self; + // Like every file written, without the values of Password-flag fields (#3926). + pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) }; step(OptimizeStage::Discarding)?; let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index afe6fd06c..c676510e8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2025,6 +2025,52 @@ fn form_fixture() -> Vec { out } +/// Whether `needle` appears in `bytes`, or in any stream of the PDF they hold once decoded (so +/// compressed object streams are searched too), as literal or hex string bytes. +fn appears_in_pdf(bytes: &Arc>, needle: &str) -> bool { + let hex: String = needle.bytes().map(|b| format!("{b:02x}")).collect(); + let forms = [needle.as_bytes().to_vec(), hex.clone().into_bytes(), hex.to_uppercase().into_bytes()]; + let found = |hay: &[u8]| forms.iter().any(|f| hay.windows(f.len()).any(|w| w == f.as_slice())); + if found(bytes) { + return true; + } + let doc = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + doc.object_numbers().into_iter().any(|num| match &*doc.get(pdfcraft_cos::ObjRef { num, generation: 0 }) { + pdfcraft_cos::Object::Stream(st) => st.decoded().is_ok_and(|d| found(&d)), + _ => false, + }) +} + +/// #3926: a value typed into a text field with the Password flag stays in the open document but +/// is written to no file: Save, a full rewrite, Reduce File Size or a recovery snapshot. ISO +/// 32000-2 §12.7.5.3, Table 231, note to the Password flag: PDF processors should never store +/// the value in the PDF file. +#[test] +fn password_field_values_are_kept_in_memory_but_written_to_no_file() { + let fixture = String::from_utf8(form_fixture()).unwrap().replace("/T (name)", "/T (name) /Ff 8192"); + // Offsets shift by the inserted text; the parser rebuilds the cross-reference table. + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(fixture.into_bytes()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + let field = s.get(id).unwrap().form.iter().find(|f| f.name == "name").cloned().unwrap(); + assert!(field.has(pdfcraft_forms::flags::PASSWORD)); + assert_eq!(field.value, vec!["hunter2".to_string()], "the open document keeps the value"); + assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes do hold it (the test can see it)"); + + let saved = s.save_bytes(id).unwrap(); + assert!(!appears_in_pdf(&saved, "hunter2"), "Save"); + assert!(appears_in_pdf(&saved, "*******"), "the masked appearance is kept"); + assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), "hunter2"), "a full rewrite"); + assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, "hunter2"), "Reduce File Size"); + let snaps = s.autosave_snapshots(); + assert_eq!(snaps.len(), 1); + assert!(!appears_in_pdf(&snaps[0].bytes, "hunter2"), "the recovery snapshot"); + // Reopened, the field is empty, as in any reader. + let mut t = Session::new(); + let reopened = t.open("saved.pdf", None, saved, None).unwrap(); + assert!(t.get(reopened).unwrap().form.iter().find(|f| f.name == "name").unwrap().value.iter().all(|v| v.is_empty())); +} + /// A value its field's font can't draw is kept, and the edit that drew it reports it in /// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a /// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 82c1c8e74..fe04f0fba 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1293,6 +1293,30 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> Ok(()) } +/// Whether any text field with the Password flag holds a value (`/V`) in `doc`. +pub fn has_password_values(doc: &Document) -> bool { + fields(doc).iter().any(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && doc.get(f.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())) +} + +/// Remove the value (`/V`) of every text field with the Password flag, for a copy of the +/// document about to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password +/// flag, note: "it is imperative that PDF processors never store the value of the text field in +/// the PDF file if this flag is set". The appearance keeps its masking characters. Returns how +/// many values were removed. +pub fn clear_password_values(doc: &mut Document) -> Result { + let holders: Vec = fields(doc) + .into_iter() + .filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && doc.get(f.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())) + .map(|f| f.obj) + .collect(); + for r in &holders { + doc.update_dict(*r, |d| { + d.remove(b"V"); + })?; + } + Ok(holders.len()) +} + /// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). pub fn need_appearances(doc: &Document) -> bool { acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))))