mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 20:18:06 -06:00
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:
co-authored by
Claude Opus 5.5
parent
0dc77fe4ed
commit
0e883c44a0
@@ -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()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)? };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))))
|
||||
|
||||
Reference in New Issue
Block a user