fix(cos): an object stream framed by one clone's edited /Length is not shared

An unedited object stream whose indirect /Length is edited in one clone
was parsed with that clone's length and put in the object-stream map
the clones share, so the other clones read its members within the
edited length. A container load that read the clone's edits is now
kept to that clone, like an edited stream.

Test: an_edited_object_stream_length_leaves_other_clones_members_alone
fails without the fix (the other clone reads None) and passes with it.

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 17:18:30 -05:00
1 parent 84d4b8cab9
commit 8cc4d37847
2 files changed
+32 -5

No files matched your search

+4 -1
View File
@@ -589,6 +589,7 @@ impl Document {
// header, as a plain object with a wrong offset is. An edited or freed stream, in the
// overlay or in a saved revision's table, is what that says it is: the file's copy
// must not come back.
let edits_read = overlay_reads();
let loaded = match self.try_get(num) {
Ok(o) if matches!(*o, Object::Stream(_)) => o,
_ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => {
@@ -596,6 +597,8 @@ impl Document {
}
_ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?),
};
// A stream whose load read this document's edits (its `/Length`) is as much its own as an edited one.
let own = edited || overlay_reads() != edits_read;
let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) };
let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize;
let first = usize::try_from(s.dict.int(b"First").unwrap_or(0).max(0)).ok();
@@ -607,7 +610,7 @@ impl Document {
index.push(objstm_member(onum, off, first, data.len()));
}
let stm = Arc::new(ObjStm { index, data: Arc::new(data) });
if edited {
if own {
return Ok(stm);
}
// Same bound as the object cache: decoded object streams can be megabytes each.
+28 -4
View File
@@ -634,6 +634,20 @@ fn an_edited_object_stream_leaves_other_clones_members_alone() {
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone");
}
/// An object stream framed by one clone's edit of its indirect `/Length` is that clone's alone:
/// the others still read its members within the file's length.
#[test]
fn an_edited_object_stream_length_leaves_other_clones_members_alone() {
let file = Arc::new(object_stream_file_with("3 0 ", "<< /Hidden (cut endstream here) >>", 0, true));
let doc = Document::open(file).unwrap();
let mut edited = doc.clone();
// Ends the stream at the `endstream` inside the member's string.
let cut = "3 0 << /Hidden (cut".len();
edited.set(ObjRef::new(6, 0), Object::Int(cut as i64));
assert_ne!(text_of(&edited, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length was not used");
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length reached another clone");
}
/// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered
/// `1 n` instead of `0 n` (#2676): every row is one number off.
fn misnumbered_table(start: u32) -> Vec<u8> {
@@ -1367,8 +1381,14 @@ fn hybrid_reference_files_read_the_xref_stream_too() {
/// stream puts it at position `member` of object stream 4, which holds `header` (the number
/// and offset pairs) and then `members`.
fn object_stream_file(header: &str, members: &str, member: u8) -> Vec<u8> {
object_stream_file_with(header, members, member, false)
}
/// [`object_stream_file`], with the stream's `/Length` in object 6 when `indirect_length`.
fn object_stream_file_with(header: &str, members: &str, member: u8, indirect_length: bool) -> Vec<u8> {
let mut out = b"%PDF-1.5\n".to_vec();
let mut offs = [0usize; 6];
let size = if indirect_length { 7 } else { 6 };
let mut offs = vec![0usize; size];
let mut obj = |out: &mut Vec<u8>, n: usize, body: &[u8]| {
offs[n] = out.len();
out.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
@@ -1379,7 +1399,8 @@ fn object_stream_file(header: &str, members: &str, member: u8) -> Vec<u8> {
obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>");
let count = header.split_whitespace().count() / 2;
let inner = format!("{header}{members}");
let mut stm = format!("<< /Type /ObjStm /N {count} /First {} /Length {} >>\nstream\n", header.len(), inner.len()).into_bytes();
let length = if indirect_length { "6 0 R".to_string() } else { inner.len().to_string() };
let mut stm = format!("<< /Type /ObjStm /N {count} /First {} /Length {length} >>\nstream\n", header.len()).into_bytes();
stm.extend_from_slice(inner.as_bytes());
stm.extend_from_slice(b"\nendstream");
obj(&mut out, 4, &stm);
@@ -1388,13 +1409,16 @@ fn object_stream_file(header: &str, members: &str, member: u8) -> Vec<u8> {
xs.extend_from_slice(&data);
xs.extend_from_slice(b"\nendstream");
obj(&mut out, 5, &xs);
if indirect_length {
obj(&mut out, 6, inner.len().to_string().as_bytes());
}
let table = out.len();
out.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
for (n, off) in offs.iter().enumerate().skip(1) {
let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") };
out.extend_from_slice(line.as_bytes());
}
out.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes());
out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes());
out
}