mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
M2.6: redaction reads content with its /DecodeParms given by reference
`DecodeBudget::decode_strict` read a stream's `/DecodeParms` only when direct, so content behind a predictor whose parameters are an indirect object decoded with the predictor's tag bytes left in. Redaction then found no glyph under a mark, left the text in the file and reported the mark applied. ISO 32000-2 lets those parameters be indirect. `decode_strict` takes the document and reads through `decoding_view`, as `decode_with_damage` does. Its other readers read the parameters too: Remove Hidden Information's hidden text and layers, the audit's covered-text check, and edit's backdrop scan. The accessibility_mark_tag_artifact description says that content which is, or may be, in a stream damaged partway is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
1 parent
cce6a37776
commit
76c1c1b05b
7 files changed
+65
-15
No files matched your search
@@ -283,7 +283,7 @@ impl<'a> Walker<'a> {
|
||||
return Err(AuditError::TooManyForms);
|
||||
}
|
||||
// A form stream that cannot be decoded fails the audit: it might hide a cover.
|
||||
let data = self.budget.decode_strict(stream)?;
|
||||
let data = self.budget.decode_strict(self.doc, stream)?;
|
||||
// The XObject matrix maps form space into the user space active at `Do`.
|
||||
let mut sub_gs = gs.clone();
|
||||
sub_gs.ctm = matrix.then(&gs.ctm);
|
||||
@@ -326,7 +326,7 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, Audi
|
||||
let mut streams = Vec::with_capacity(contents.len());
|
||||
for c in &contents {
|
||||
if let Object::Stream(s) = &*doc.resolve(c) {
|
||||
streams.push(budget.decode_strict(s)?);
|
||||
streams.push(budget.decode_strict(doc, s)?);
|
||||
}
|
||||
}
|
||||
let pieces = Pieces::join(&streams);
|
||||
|
||||
@@ -1285,7 +1285,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language", "summary", "scope"] },
|
||||
"value": { "type": "string" },
|
||||
}), &["doc", "object", "generation", "property"])),
|
||||
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, or content not found on its page are rejected. Undoable.")
|
||||
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that is, or may be, in a content stream damaged partway are rejected. Undoable.")
|
||||
.with(schema(json!({
|
||||
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
|
||||
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
|
||||
|
||||
@@ -286,10 +286,10 @@ impl DecodeBudget {
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
/// the allowance, with its `/DecodeParms` given by reference read in `doc`.
|
||||
pub fn decode_strict(&mut self, doc: &Document, stream: &Stream) -> Result<Vec<u8>, CosError> {
|
||||
let strict: Decoder = |s, max| s.decoded_strict_within_counted(max).map(|(data, spent)| (data, false, spent));
|
||||
self.charge(stream, usize::MAX, strict).map(|(data, _)| data)
|
||||
self.charge(&doc.decoding_view(stream), usize::MAX, strict).map(|(data, _)| data)
|
||||
}
|
||||
|
||||
/// Decodes `stream` within `max` and the allowance, charging what every filter stage
|
||||
@@ -788,7 +788,7 @@ mod tests {
|
||||
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!(budget.decode_strict(&doc, stream).is_err());
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
|
||||
let mut budget = DecodeBudget::new(10 << 20, 1);
|
||||
@@ -812,8 +812,9 @@ mod tests {
|
||||
let corrupt = Stream::from_raw(whole.dict.clone(), whole.raw.get(..cut).unwrap_or_default().to_vec());
|
||||
let partial = corrupt.decoded().map(|d| d.len()).unwrap_or(0);
|
||||
assert!(partial > 0, "a cut flate stream decodes in part");
|
||||
let doc = Document::new_empty();
|
||||
let mut budget = DecodeBudget::new(10 << 20, 1);
|
||||
assert!(budget.decode_strict(&corrupt).is_err());
|
||||
assert!(budget.decode_strict(&doc, &corrupt).is_err());
|
||||
assert_eq!(budget.remaining(), (10 << 20) - partial);
|
||||
let mut budget = DecodeBudget::new(10 << 20, 1);
|
||||
assert!(budget.decode_within(&corrupt, 1 << 30).is_ok());
|
||||
@@ -877,6 +878,7 @@ mod tests {
|
||||
dict.set(b"Other".to_vec(), Object::Array(vec![Object::Int(0); 1000]));
|
||||
let s = Stream::from_raw(dict, raw.to_vec());
|
||||
assert_eq!(DecodeBudget::page().decode_with_damage(&doc, &s, usize::MAX).ok(), Some((content.to_vec(), false)), "{parms:?}");
|
||||
assert_eq!(DecodeBudget::page().decode_strict(&doc, &s).ok(), Some(content.to_vec()), "{parms:?}");
|
||||
let r = doc.add(Object::Stream(s.clone()));
|
||||
let read = DecodeBudget::page().decode_contents_with_damage(&doc, &Object::Array(vec![Object::Ref(r)]));
|
||||
assert_eq!(read, [Ok((content.to_vec(), false))], "{parms:?}");
|
||||
@@ -935,7 +937,7 @@ mod tests {
|
||||
assert_eq!(budget.remaining(), (10 << 20) - charged);
|
||||
assert!(budget.decode_within(&chained, 2 << 20).is_ok());
|
||||
assert_eq!(budget.remaining(), (10 << 20) - 2 * charged);
|
||||
assert!(budget.decode_strict(&chained).is_ok());
|
||||
assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok());
|
||||
assert_eq!(budget.remaining(), (10 << 20) - 3 * charged);
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,7 @@ pub(crate) fn restore(doc: &mut Document, page: &pdfcraft_model::Page) -> Result
|
||||
let same = |w: &ObjRef| match &*doc.get(*w) {
|
||||
Object::Stream(s) => {
|
||||
[TAG, &b"PCMark"[..], b"Subtype"].iter().all(|k| s.dict.get(k).is_none())
|
||||
&& DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok().as_deref() == Some(bytes.as_slice())
|
||||
&& DecodeBudget::new(SCAN_BYTES, 0).decode_strict(doc, s).ok().as_deref() == Some(bytes.as_slice())
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
@@ -248,7 +248,7 @@ fn restored_copy(doc: &Document, page: &pdfcraft_model::Page, copy: ObjRef) -> O
|
||||
return None;
|
||||
}
|
||||
let hash = note.get(b"Before")?.as_string()?;
|
||||
let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok()?;
|
||||
let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(doc, s).ok()?;
|
||||
let parsed = pdfcraft_content::parse(head(&bytes));
|
||||
// The `n` standing in for the fill, after exactly the operators that came before it.
|
||||
let fill = parsed.ops.get(index)?;
|
||||
@@ -384,11 +384,11 @@ fn scan(doc: &Document, p: &pdfcraft_model::Page, swap: Option<(ObjRef, &[u8])>)
|
||||
if b.len() > SCAN_BYTES.saturating_sub(total) {
|
||||
return None;
|
||||
}
|
||||
budget.decode_strict(&Stream::from_raw(Dict::new(), b.to_vec())).ok()?
|
||||
budget.decode_strict(doc, &Stream::from_raw(Dict::new(), b.to_vec())).ok()?
|
||||
}
|
||||
// Tolerant decoding can return an empty or partial stream after corruption.
|
||||
// That cannot prove which paint preceded the candidate backdrop.
|
||||
_ => budget.decode_strict(s).ok()?,
|
||||
_ => budget.decode_strict(doc, s).ok()?,
|
||||
};
|
||||
if bytes.len() > SCAN_BYTES.saturating_sub(total).saturating_sub(1) {
|
||||
return None;
|
||||
|
||||
@@ -805,7 +805,7 @@ fn xobject(
|
||||
}
|
||||
// Strict, like page content: a Form read only in part counts as undecodable.
|
||||
let data = if scope.budget.enter_form() {
|
||||
scope.budget.decode_strict(s)
|
||||
scope.budget.decode_strict(doc, s)
|
||||
} else {
|
||||
Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects")))
|
||||
};
|
||||
|
||||
@@ -275,7 +275,7 @@ pub(crate) fn page_streams_within(
|
||||
match &*doc.resolve(o) {
|
||||
// Strict: a tolerant decode keeps what precedes corruption and drops the rest, which
|
||||
// still draws in viewers that read further; anything not read can't be cleaned.
|
||||
Object::Stream(s) => data.push(budget.decode_strict(s).map_err(|_| RedactError::Unreadable(index + 1))?),
|
||||
Object::Stream(s) => data.push(budget.decode_strict(doc, s).map_err(|_| RedactError::Unreadable(index + 1))?),
|
||||
Object::Null => data.push(Vec::new()),
|
||||
_ => return Err(RedactError::Unreadable(index + 1)),
|
||||
}
|
||||
|
||||
@@ -81,6 +81,54 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() {
|
||||
assert!(!content(&doc, 0).contains("1234"));
|
||||
}
|
||||
|
||||
/// Content whose `/DecodeParms` is given by reference is read through the reference, on the page
|
||||
/// and in a form: the text under a mark goes. Read with the predictor's tag bytes left in, no glyph
|
||||
/// was found under the mark, and the redaction left the text in the file and said it was done.
|
||||
#[test]
|
||||
fn content_with_parameters_given_by_reference_is_redacted() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
|
||||
// PNG Up, one column: each byte a row of its own, tagged 2.
|
||||
let predicted = |doc: &mut Document, mut dict: Dict| {
|
||||
let mut rows = Vec::new();
|
||||
let mut above = 0u8;
|
||||
for &b in b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET" {
|
||||
rows.extend([2, b.wrapping_sub(above)]);
|
||||
above = b;
|
||||
}
|
||||
let parms = doc.add(Object::Dict([(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect::<Dict>()));
|
||||
dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
|
||||
let mut s = Stream::flate(Dict::new(), &rows);
|
||||
for (k, v) in dict.iter() {
|
||||
s.dict.set(k.to_vec(), v.clone());
|
||||
}
|
||||
s
|
||||
};
|
||||
let marked = [[5.0, 90.0, 120.0, 115.0]];
|
||||
let mut page = one_page(b"", "", vec![]);
|
||||
let s = predicted(&mut page, Dict::new());
|
||||
page.set(ObjRef::new(4, 0), s);
|
||||
// The parameters take object 7 and the form 8.
|
||||
let mut form = one_page(b"/Fm Do", "/XObject << /Fm 8 0 R >>", vec![]);
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
dict.set(b"BBox".to_vec(), Object::Array([0, 0, 300, 300].into_iter().map(Object::Int).collect()));
|
||||
dict.set(
|
||||
b"Resources".to_vec(),
|
||||
Object::Dict(
|
||||
[(b"Font".to_vec(), Object::Dict([(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0)))].into_iter().collect()))].into_iter().collect(),
|
||||
),
|
||||
);
|
||||
let s = predicted(&mut form, dict);
|
||||
form.set(ObjRef::new(8, 0), s);
|
||||
for (mut doc, what) in [(page, "page"), (form, "form")] {
|
||||
assert_eq!(under(&mut doc, 0, &marked), 6, "{what}");
|
||||
mark(&mut doc, 0, &marked, "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!((r.marks, r.glyphs), (1, 6), "{what}");
|
||||
assert_eq!(under(&mut reopen(&doc), 0, &marked), 0, "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a
|
||||
/// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to
|
||||
/// lie past a mark that covers all of it on screen, and was kept.
|
||||
|
||||
Reference in new issue
Block a user