mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 20:18:06 -06:00
* fix(engine): Save writes a repaired file in full even without edits (#1937) A damaged file is repaired on open (its cross-reference index rebuilt from object headers, as Document Properties reports), but Save wrote it back as it was: `Session::save_bytes` returned the bytes as opened whenever nothing was edited, and a repair is not an edit. The "repaired" file on disk was still the damaged one. `save_bytes` now writes a reconstructed document in full even with no edits. `Document::reconstructed()` in cos names the condition (no revision chain: rebuilt, or built from scratch), and `write_incremental` uses it too, so the writer and the engine share one definition. Tests: `engine::tests::a_repaired_file_is_rewritten_by_save_even_without_edits` (table offsets shifted; no table at all) and `automation::saving_a_repaired_file_puts_the_repair_on_disk` (doc_open, doc_info, doc_save over the same path, reopen). Both fail with the condition removed: the saved bytes equal the damaged file. parity: core.repair-broken-files cites them and notes that a repair still does not count as an unsaved change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz * fix(engine): a signed repaired file keeps its bytes on Save without edits (#1937) Review follow-up: writing a reconstructed file in full on Save puts the repair on disk, but for a signed file it would also invalidate its signatures without warning. Save now skips the early return only when the document is reconstructed and not signed. A regression test signs a document, appends a `startxref` that points nowhere (the signed bytes stay intact, the cross-reference data must be rebuilt) and checks that Save without edits returns those bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1952a88801
commit
ecffce69ee
@@ -4078,11 +4078,16 @@ impl Session {
|
||||
|
||||
/// The bytes to write for Save: an incremental update of the file as opened/last saved,
|
||||
/// with `/ModDate` stamped. Call `mark_saved` after writing them successfully.
|
||||
///
|
||||
/// A file whose cross-reference data had to be rebuilt is written in full even when nothing
|
||||
/// was edited: its bytes as opened are the damaged file, and this save is how the repair the
|
||||
/// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would
|
||||
/// break its signatures, so with no edits it keeps its bytes, damage and all.
|
||||
pub fn save_bytes(&self, id: DocId) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) };
|
||||
let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved);
|
||||
if !editor.cos.is_modified() && on_saved {
|
||||
if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) {
|
||||
return Ok(editor.saved.clone());
|
||||
}
|
||||
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
|
||||
@@ -519,6 +519,63 @@ fn save_is_incremental_stamps_mod_date_and_rebases() {
|
||||
assert_eq!(reopened.revisions().len(), 3);
|
||||
}
|
||||
|
||||
/// #1937: a file whose cross-reference data had to be rebuilt saved as its original bytes when
|
||||
/// nothing was edited, so the damage Document Properties reported as repaired was still in the
|
||||
/// file. Save writes the rebuilt file in full; reopened, it needs no repair.
|
||||
#[test]
|
||||
fn a_repaired_file_is_rewritten_by_save_even_without_edits() {
|
||||
let good = fixture(2);
|
||||
// Every offset in the table is off by the inserted line: the chain reads but leads nowhere.
|
||||
let mut shifted = b"%PDF-1.7\n%shifted\n".to_vec();
|
||||
shifted.extend_from_slice(&good[b"%PDF-1.7\n".len()..]);
|
||||
// No cross-reference table at all.
|
||||
let mut lost = good.clone();
|
||||
lost.truncate(good.windows(9).rposition(|w| w == b"startxref").unwrap());
|
||||
lost.extend_from_slice(b"startxref\n999999\n%%EOF\n");
|
||||
for (name, damaged) in [("shifted", shifted), ("lost", lost)] {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("broken.pdf", Some("/tmp/broken.pdf".into()), Arc::new(damaged.clone()), None).unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{name}: {:?}", doc.repair_log());
|
||||
assert_eq!(doc.info.pages.len(), 2, "{name}");
|
||||
assert!(!doc.dirty, "{name}: a repair is not an edit");
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert_ne!(&saved[..], &damaged[..], "{name}: the damaged bytes are not what Save writes");
|
||||
let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap();
|
||||
assert!(reopened.repair_log().is_empty(), "{name}: {:?}", reopened.repair_log());
|
||||
assert_eq!(reopened.revisions().len(), 1, "{name}");
|
||||
s.mark_saved(id, saved.clone(), None).unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
assert!(doc.repair_log().is_empty(), "{name}: {:?}", doc.repair_log());
|
||||
assert_eq!(doc.info.pages.len(), 2, "{name}");
|
||||
// Sound now: saving again without edits writes the same bytes.
|
||||
assert_eq!(s.save_bytes(id).unwrap(), saved, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A damaged file that is signed keeps its bytes on a Save without edits: rewriting it in full
|
||||
/// to put the repair on disk would invalidate its signatures without a word.
|
||||
#[test]
|
||||
fn a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits() {
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
|
||||
// After the test certificate's start date (2026-10-02).
|
||||
let mut s = Session::new().with_clock(|| 1_800_000_000);
|
||||
let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap();
|
||||
let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() };
|
||||
let signed = s.sign(id, &digital_id, opts).unwrap();
|
||||
// A trailing `startxref` that points nowhere: the signed revision's bytes are untouched, but
|
||||
// the file's cross-reference data has to be rebuilt.
|
||||
let mut damaged = signed.to_vec();
|
||||
damaged.extend_from_slice(b"\nstartxref\n999999\n%%EOF\n");
|
||||
let mut t = Session::new().with_clock(|| 1_800_000_000);
|
||||
let tid = t.open("damaged.pdf", Some("/tmp/damaged.pdf".into()), Arc::new(damaged.clone()), None).unwrap();
|
||||
let doc = t.get(tid).unwrap();
|
||||
assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{:?}", doc.repair_log());
|
||||
assert!(doc.is_signed());
|
||||
assert_eq!(&t.save_bytes(tid).unwrap()[..], &damaged[..], "a signed file is not rewritten without edits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_still_works_after_save() {
|
||||
let (mut s, id) = session_with(2);
|
||||
|
||||
Reference in New Issue
Block a user