fix(optimize): bound each unused-resource decode by the remaining shared budget

This commit is contained in:
Dylan M. Taylor
2026-11-03 21:18:58 +00:00
parent ecdd1e658c
commit 3c33219ae8
2 changed files with 18 additions and 3 deletions
+13
View File
@@ -341,6 +341,12 @@ impl Stream {
self.decode_chain(MAX_DECODED, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
}
/// [`Stream::decoded_strict`] for a caller with less than [`MAX_DECODED`] left to spend:
/// decoding past `max` bytes (in any filter stage) is an error.
pub fn decoded_strict_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
self.decoded_strict_within_counted(max).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
}
/// [`Stream::decoded_strict`] bounded by `max` and counted like
/// [`Stream::decoded_within_counted`].
pub(crate) fn decoded_strict_within_counted(&self, max: usize) -> Result<(Vec<u8>, usize), (pdfcraft_filters::FilterError, usize)> {
@@ -607,6 +613,13 @@ mod tests {
}
}
#[test]
fn strict_decoding_is_bounded_by_the_callers_remaining_allowance() {
let stream = Stream::flate(Dict::new(), &vec![0u8; 1 << 20]);
assert!(matches!(stream.decoded_strict_within(1000), Err(CosError::Filter(_))));
assert_eq!(stream.decoded_strict_within(1 << 20).map(|v| v.len()).ok(), Some(1 << 20));
}
#[test]
fn empty_filter_arrays_refuse_decoding_past_a_caller_limit() {
let mut dict = Dict::new();
+5 -3
View File
@@ -168,7 +168,9 @@ fn plain_metadata(doc: &Document) -> bool {
.is_some_and(|e| matches!(e.as_dict().and_then(|e| e.get(b"EncryptMetadata")), Some(Object::Bool(false))))
}
fn data(s: &Stream, num: u32, g: &Graph, encrypted: bool, plain_metadata: bool) -> Data {
/// `left` is what the caller may still decode: a stream that would decode past it (in any filter
/// stage) is `Unknown`, so no stage is allowed to grow beyond the shared budget first.
fn data(s: &Stream, num: u32, g: &Graph, encrypted: bool, plain_metadata: bool, left: usize) -> Data {
let d = &s.dict;
// Streams the security handler leaves as stored, which a viewer may still decrypt.
let ty = d.name(b"Type");
@@ -219,7 +221,7 @@ fn data(s: &Stream, num: u32, g: &Graph, encrypted: bool, plain_metadata: bool)
if !parms_line_up {
return Data::Unknown;
}
s.decoded_strict().map_or(Data::Unknown, Data::Decoded)
s.decoded_strict_within(left).map_or(Data::Unknown, Data::Decoded)
}
/// Drop the image and form XObjects that `pages` list in their own resources but no stream
@@ -254,7 +256,7 @@ pub(crate) fn remove_unused(doc: &mut Document, pages: &[ObjRef], progress: &mut
}
let obj = doc.get(ObjRef::new(*num, doc.generation(*num)));
let Object::Stream(s) = &*obj else { continue };
match data(s, *num, &g, encrypted, plain_metadata) {
match data(s, *num, &g, encrypted, plain_metadata, DECODE_BUDGET.saturating_sub(decoded)) {
Data::Picture => {}
Data::Decoded(bytes) => {
decoded = decoded.saturating_add(bytes.len());