fix(cos): a strict decode charged to a DecodeBudget

The redaction audit treats any damaged stream as a failure, so it needs
the strict decoder under the same shared allowance.
This commit is contained in:
aiongg
2026-10-22 16:57:55 +00:00
parent d2e7ce72f0
commit ea4939cd08
2 changed files with 29 additions and 8 deletions
+15 -1
View File
@@ -53,10 +53,20 @@ impl DecodeBudget {
/// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without
/// decoding, once the allowance is spent.
pub fn decode(&mut self, stream: &Stream) -> Result<Vec<u8>, CosError> {
self.charge(stream, Stream::decoded_within_typed)
}
/// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to
/// the allowance.
pub fn decode_strict(&mut self, stream: &Stream) -> Result<Vec<u8>, CosError> {
self.charge(stream, Stream::decoded_strict_within_typed)
}
fn charge(&mut self, stream: &Stream, decode: fn(&Stream, usize) -> Result<Vec<u8>, FilterError>) -> Result<Vec<u8>, CosError> {
if self.bytes == 0 && !stream.raw.is_empty() {
return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
}
match stream.decoded_within_typed(self.bytes) {
match decode(stream, self.bytes) {
Ok(data) => {
self.bytes = self.bytes.saturating_sub(data.len());
Ok(data)
@@ -127,6 +137,10 @@ mod tests {
let mut budget = DecodeBudget::new((1 << 20) - 1, 1);
assert!(budget.decode_contents(&doc, &contents).iter().all(Result::is_err));
assert_eq!(budget.remaining(), 0);
let mut budget = DecodeBudget::new((1 << 20) - 1, 1);
let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") };
assert!(budget.decode_strict(stream).is_err());
assert_eq!(budget.remaining(), 0);
assert!(budget.enter_form());
assert!(!budget.enter_form());
+14 -7
View File
@@ -223,21 +223,24 @@ impl Stream {
let max = max.min(MAX_DECODED);
let chain = self.filters();
if chain.is_empty() {
if self.raw.len() > max {
return Err(pdfcraft_filters::FilterError::LimitExceeded(max));
}
return Ok(self.raw.to_vec());
return self.raw_within_typed(max);
}
pdfcraft_filters::decode_tolerant(&chain, &self.raw, max).map(|(v, _)| v)
}
/// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error.
pub fn decoded_strict(&self) -> Result<Vec<u8>, CosError> {
self.decoded_strict_within_typed(MAX_DECODED).map_err(|e| CosError::Filter(e.to_string()))
}
/// [`Stream::decoded_strict`] bounded by `max`, with the filter error kept typed.
pub(crate) fn decoded_strict_within_typed(&self, max: usize) -> Result<Vec<u8>, pdfcraft_filters::FilterError> {
let max = max.min(MAX_DECODED);
let chain = self.filters();
if chain.is_empty() {
return self.raw_within(MAX_DECODED);
return self.raw_within_typed(max);
}
pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string()))
pdfcraft_filters::decode(&chain, &self.raw, max)
}
/// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded.
@@ -252,11 +255,15 @@ impl Stream {
}
fn raw_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
self.raw_within_typed(max).map_err(|e| CosError::Filter(e.to_string()))
}
fn raw_within_typed(&self, max: usize) -> Result<Vec<u8>, pdfcraft_filters::FilterError> {
let max = max.min(MAX_DECODED);
// An empty decoder chain still produces an owned output buffer: check its limit
// before cloning, including streams whose /Crypt filter was applied on load.
if self.raw.len() > max {
return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string()));
return Err(pdfcraft_filters::FilterError::LimitExceeded(max));
}
Ok(self.raw.to_vec())
}