M6: values typed into Password fields are written to no file (#3926)

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 <noreply@anthropic.com>
This commit is contained in:
mtlcl410
2026-10-23 14:38:16 +08:00
co-authored by Claude Opus 5.5
parent 0dc77fe4ed
commit 0e883c44a0
4 changed files with 102 additions and 4 deletions
+30 -4
View File
@@ -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<std::borrow::Cow<'_, pdfcraft_cos::Document>, 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()),
});
}
+2
View File
@@ -83,6 +83,8 @@ impl OptimizeJob {
fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result<Optimized, EditError> {
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)? };
+46
View File
@@ -2025,6 +2025,52 @@ fn form_fixture() -> Vec<u8> {
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<Vec<u8>>, 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
+24
View File
@@ -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<usize, FormError> {
let holders: Vec<ObjRef> = 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))))