fix(cos): a save after undo keeps objects packed in a dropped object stream

When the working snapshot is restored from an earlier revision (undo after a save), the
next save appends to the last saved file through overlay_changes_from. An object stream of
that file that the restored graph frees or reuses the number of took its unchanged members
with it: they stayed listed in the dropped stream, and the file reread with no pages. Such
members are now written again on their own.

Adding a PRC model now edits the catalog (the ADBE extension), which made the model_3d
automation test (save, undo, save) reach this case on CI.

The same change is part of #3305 (skylartaylor), which also covers security changes.

Test: cos a_restored_revision_keeps_what_the_saved_file_held_in_object_streams, failing
with the fix reverted (the reread catalog's /Pages resolves to nothing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76andClaude Opus 5.5 committed 2026-11-02 20:28:06 -05:00
1 parent 4c087336aa
commit eaf06f9cb9
2 files changed
+45 -2

No files matched your search

+19 -2
View File
@@ -682,8 +682,9 @@ impl Document {
///
/// Used when the working snapshot was restored from an earlier revision and the next
/// incremental save must still append to this file (the last saved revision). Objects
/// that already match are left untouched. Reads go through private readers so neither
/// document's editing cache is filled.
/// that already match are left untouched, except members of an object stream of this file
/// whose stream number `current` frees or reuses: those are written again on their own.
/// Reads go through private readers so neither document's editing cache is filled.
pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> {
let saved_nums = self.object_numbers();
let current_nums = current.object_numbers();
@@ -707,6 +708,22 @@ impl Document {
}
}
}
// An object stream of this file that is freed or replaced takes its members with it, so
// a member that is otherwise unchanged is written again on its own.
let gone = |n: u32| freed.binary_search(&n).is_ok() || changed.binary_search(&n).is_ok();
let orphaned: Vec<u32> = current_nums
.iter()
.copied()
.filter(|&num| changed.binary_search(&num).is_err())
.filter(|&num| {
let packed_in = match self.xref_entry(num) {
Some(XrefEntry::InStream { stream, .. }) => Some(stream),
_ => None,
};
packed_in.is_some_and(gone)
})
.collect();
changed.extend(orphaned);
for num in freed {
let generation = self.generation(num);
self.free(ObjRef::new(num, generation));
+26
View File
@@ -1701,3 +1701,29 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() {
});
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially");
}
#[test]
fn a_restored_revision_keeps_what_the_saved_file_held_in_object_streams() {
// The earlier revision: an empty document read back from a file whose catalog and pages
// sit in an object stream.
let earlier = Document::open(Arc::new(crate::write_full(&Document::new_empty(), &crate::SaveOptions::default()).unwrap())).unwrap();
// The saved revision adds an object the catalog links to, so a full save numbers its objects
// anew; an undo then returns to the earlier graph.
let mut later = earlier.clone();
let extra = later.add(Object::Int(7));
let root = later.root().unwrap();
later.update_dict(root, |c| c.set(b"Extra".to_vec(), Object::Ref(extra))).unwrap();
let saved = crate::write_full(&later, &crate::SaveOptions::default()).unwrap();
let mut base = Document::open(Arc::new(saved)).unwrap();
assert!(
base.object_numbers().iter().any(|&n| matches!(base.xref_entry(n), Some(XrefEntry::InStream { .. }))),
"the saved file holds objects in a stream"
);
base.overlay_changes_from(&earlier).unwrap();
let file = crate::write_incremental(&base, &crate::SaveOptions::default()).unwrap();
let reread = Document::open(Arc::new(file)).unwrap();
let catalog = reread.get(reread.root().unwrap()).as_dict().unwrap().clone();
assert!(catalog.get(b"Extra").is_none(), "the restored catalog");
let pages = reread.resolve(catalog.get(b"Pages").unwrap());
assert_eq!(pages.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Pages"[..]), "the pages survive: {pages:?}");
}