diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 7313716a2e..ade4969c0c 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -6,6 +6,56 @@ use super::model::{ use super::navigation::text_string; use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId}; +/// Negative cache of failed script-stream decompressions during one structure inspection. +/// Callers must supply stream addresses that stay stable for that inspection; these are +/// opaque identities, never dereferenced here. Do not reuse the cache across documents, +/// edits or inspections, where an address may identify a different stream. +/// FIFO eviction bounds it to 4096 identities and allows an evicted failure to be retried; +/// a cache hit does not refresh its position. This bounds retained failures, not the +/// total decoding work, and does not cache successful sources or spend their text budget. +#[derive(Default)] +pub(super) struct RejectedJavaScript { + identities: HashSet<*const lopdf::Stream>, + order: std::collections::VecDeque<*const lopdf::Stream>, +} + +impl RejectedJavaScript { + pub(super) const CAPACITY: usize = 4096; + + pub(super) fn contains(&self, identity: *const lopdf::Stream) -> bool { + self.identities.contains(&identity) + } + + pub(super) fn insert(&mut self, identity: *const lopdf::Stream) { + if self.identities.contains(&identity) { + return; + } + if self.order.len() >= Self::CAPACITY + && let Some(oldest) = self.order.pop_front() + { + self.identities.remove(&oldest); + } + self.identities.insert(identity); + self.order.push_back(identity); + } +} + +/// What link JavaScript an inspection may still keep: 4 MiB of source across the document, +/// and the streams that failed to decode. +pub(super) struct LinkScripts { + remaining: usize, + rejected: RejectedJavaScript, +} + +impl Default for LinkScripts { + fn default() -> Self { + Self { remaining: 4 << 20, rejected: RejectedJavaScript::default() } + } +} + +/// Most bytes of one link's script string or decompressed stream. +const MAX_LINK_SCRIPT: usize = 256 << 10; + /// At most this many `/State` entries of a set-layer-visibility action are read. const MAX_LAYER_STATE: usize = 1024; @@ -124,6 +174,9 @@ impl<'a> Inspector<'a> { // Once the comment budget is spent, comments are no longer built, but links and attachments // are still listed. let mut comments_full = false; + // Link JavaScript has its own source budget, apart from link text (#2081). + let mut scripts = LinkScripts::default(); + let mut javascript_limit_reported = false; for (page, index, a) in entries { let Some(d) = self.dict(a) else { continue }; // An annotation's `/Type`, when present, is `/Annot` (ISO 32000-2 §12.5.2, Table 166): @@ -144,10 +197,18 @@ impl<'a> Inspector<'a> { info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback)); } if subtype == "Link" { - if let Some(link) = self.link(page, index, d, info.uri_base.as_deref(), text_budget, &mut unfit_quads) { - if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { + if let Some(link) = self.link(page, index, d, info.uri_base.as_deref(), text_budget, &mut unfit_quads, &mut scripts) { + let refused = matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript"); + if refused || matches!(&link.target, LinkTarget::JavaScript(_)) { info.has_javascript = true; } + if refused && scripts.remaining == 0 && !javascript_limit_reported { + info.warnings.push( + "Link JavaScript sources reached the 4 MiB document inspection limit; remaining JavaScript actions are unavailable." + .into(), + ); + javascript_limit_reported = true; + } info.links.push(link); } continue; @@ -298,7 +359,9 @@ impl<'a> Inspector<'a> { /// goes, else what its action `/A` does ([`Self::action_target`]). `None` when it has no /// rectangle, or a destination or action that leads nowhere. `base` is the catalog's /// `/URI /Base`, which a relative URI action is resolved against. `unfit_quads` holds the - /// indirect `/QuadPoints` arrays already found too large for the budget. + /// indirect `/QuadPoints` arrays already found too large for the budget. A JavaScript + /// action's source is kept as data, never run here, within `scripts` ([`Self::link_script`]). + #[allow(clippy::too_many_arguments)] fn link( &self, page: usize, @@ -307,13 +370,17 @@ impl<'a> Inspector<'a> { base: Option<&str>, text_budget: &mut usize, unfit_quads: &mut HashSet, + scripts: &mut LinkScripts, ) -> Option { let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); + let action = d.get(b"A").ok().and_then(|a| self.dict(a)); let target = if let Ok(dest) = d.get(b"Dest") { let (page, view) = self.dest(dest, 0)?; LinkTarget::Page(page, view) + } else if let Some(a) = action.filter(|a| self.name(a, b"S").as_deref() == Some("JavaScript")) { + self.link_script(a, scripts).map_or_else(|| LinkTarget::Other("JavaScript".into()), LinkTarget::JavaScript) } else { - match self.action_target(d.get(b"A").ok().and_then(|a| self.dict(a))?, text_budget)? { + match self.action_target(action?, text_budget)? { LinkTarget::Uri(uri) => match crate::resolve_uri(base, &uri) { // The raw address is spent already. What the base adds is spent too, or the // link keeps the raw address. @@ -360,6 +427,45 @@ impl<'a> Inspector<'a> { /// this document; a remote go-to's names a page or destination of another file (ISO 32000-2 /// §12.6.4.3). `None` for an action without a type, or a go-to whose destination is not here. /// A web address is spent from `text_budget` (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]). + /// The source of a link's JavaScript action (`/JS`, a string or a stream), as data. A + /// string or decompressed stream over 256 KiB, a stream that fails to decode, or source + /// past what is left of the document's 4 MiB is `None` (the link stays + /// `Other("JavaScript")`); a failed stream is remembered so links sharing it don't decode it + /// again. An action's `/Next` chain is not followed. + fn link_script(&self, a: &Dictionary, scripts: &mut LinkScripts) -> Option { + if scripts.remaining == 0 { + return None; + } + let bytes = match self.resolve(a.get(b"JS").ok()?) { + Object::String(bytes, _) if bytes.len() <= MAX_LINK_SCRIPT => bytes.clone(), + Object::Stream(stream) => { + // The structure's objects stay put for the whole inspection, so the resolved + // stream's address identifies it, aliases included. + let identity = std::ptr::from_ref(stream); + if scripts.rejected.contains(identity) { + return None; + } + #[cfg(test)] + JS_DECODE_COUNT.with(|n| n.set(n.get() + 1)); + match stream.decompressed_content_with_limit(MAX_LINK_SCRIPT) { + Ok(bytes) => bytes, + Err(_) => { + scripts.rejected.insert(identity); + return None; + } + } + } + _ => return None, + }; + let source = pdfcraft_cos::PdfString::literal(bytes).to_text(); + let Some(remaining) = scripts.remaining.checked_sub(source.len()) else { + scripts.remaining = 0; + return None; + }; + scripts.remaining = remaining; + Some(source) + } + pub(super) fn action_target(&self, a: &Dictionary, text_budget: &mut usize) -> Option { Some(match self.name(a, b"S").as_deref()? { "GoTo" => { @@ -755,3 +861,87 @@ thread_local! { /// `/QuadPoints` arrays whose values were read. pub(super) static QUAD_ARRAYS_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +#[cfg(test)] +thread_local! { static JS_DECODE_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +#[cfg(test)] +mod remediation_probes { + use super::*; + use crate::inspect::{Arc, inspect}; + use lopdf::{Document, dictionary}; + #[test] + fn remediation_rejected_stream_cache_has_a_fixed_bound() { + let streams: Vec<_> = (0..8192).map(|_| lopdf::Stream::new(dictionary! {}, vec![])).collect(); + let mut cache = RejectedJavaScript::default(); + for stream in &streams { + cache.insert(std::ptr::from_ref(stream)); + assert!(cache.identities.len() <= 4096); + assert!(cache.order.len() <= 4096); + } + assert!(!cache.contains(std::ptr::from_ref(streams.first().unwrap()))); + assert!(cache.contains(std::ptr::from_ref(streams.last().unwrap()))); + } + fn shared_bad_stream(n: usize, repeated_annotation: bool) -> Vec { + let mut d = Document::with_version("1.7"); + let pages = d.new_object_id(); + let mut stream = lopdf::Stream::new(dictionary! {}, vec![b'x'; 300 << 10]); + stream.compress().unwrap(); + let stream_id = d.add_object(stream); + let action = d.add_object(dictionary! {"S"=>"JavaScript","JS"=>stream_id}); + let mut annots = Vec::new(); + let mut first = None; + for _ in 0..n { + let annotation_action = if n == 33 { + let alias = d.add_object(Object::Reference(stream_id)); + d.add_object(dictionary! {"S"=>"JavaScript","JS"=>alias}) + } else { + action + }; + let id = if let Some(id) = first.filter(|_| repeated_annotation) { + id + } else { + let id = d.add_object( + dictionary! {"Type"=>"Annot","Subtype"=>"Link","Rect"=>vec![0.into(),0.into(),50.into(),50.into()],"A"=>annotation_action}, + ); + first = Some(id); + id + }; + annots.push(Object::Reference(id)); + } + let good=d.add_object(dictionary!{"Subtype"=>"Link","Rect"=>vec![0.into(),0.into(),50.into(),50.into()],"A"=>dictionary!{"S"=>"JavaScript","JS"=>Object::string_literal("console.println('valid');")}}); + annots.push(good.into()); + let page = d.add_object(dictionary! {"Type"=>"Page","Parent"=>pages,"Annots"=>annots}); + d.objects.insert( + pages, + dictionary! {"Type"=>"Pages","Kids"=>vec![page.into()],"Count"=>1,"MediaBox"=>vec![0.into(),0.into(),200.into(),200.into()]}.into(), + ); + let root = d.add_object(dictionary! {"Type"=>"Catalog","Pages"=>pages}); + d.trailer.set("Root", root); + let mut bytes = Vec::new(); + d.save_to(&mut bytes).unwrap(); + bytes + } + #[test] + fn remediation_rejected_stream_decode_is_cached_and_preserves_budget() { + for (n, repeated) in [(1, false), (33, false), (256, false), (1024, false), (1024, true)] { + let bytes = shared_bad_stream(n, repeated); + let len = bytes.len(); + + let mut times = Vec::new(); + let mut calls = 0; + for _ in 0..3 { + JS_DECODE_COUNT.with(|n| n.set(0)); + let now = std::time::Instant::now(); + let info = inspect(Arc::new(bytes.clone()), None).unwrap(); + times.push(now.elapsed().as_micros()); + calls = JS_DECODE_COUNT.with(|n| n.get()); + assert_eq!(calls, 1); + assert_eq!(info.links.len(), if repeated { 2 } else { n + 1 }); + assert!(matches!(&info.links.last().unwrap().target,LinkTarget::JavaScript(s) if s=="console.println('valid');")); + assert!(!info.warnings.iter().any(|w| w.contains("4 MiB"))); + } + println!("REJECT n={n} repeated_annotation={repeated} bytes={len} decode_calls={calls} micros={times:?}"); + } + } +} diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 18b7533f2c..00f967153a 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -230,6 +230,9 @@ pub enum LinkTarget { /// reference ([`crate::resolve_uri`]); not yet checked, as the opener must /// (`pdfcraft_engine::links::document_url`). Uri(String), + /// Source of a JavaScript action, decoded with a bounded stream reader (never run while + /// inspecting). + JavaScript(String), /// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in /// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned /// on turns off the other layers of its radio-button groups. diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 68486d8097..76bcc2cf3d 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -2921,3 +2921,112 @@ fn efopen_filters_named_by_the_file_open_and_older_handlers_do_not() { let legacy = Arc::new(efopen_pdf_with("EFOpen", false, "/V 2 /R 3 /Length 128")); assert!(matches!(check_pages(legacy, None, 1), Err(OpenError::NeedsPassword))); } + +#[cfg(test)] +mod javascript_links_tests { + use super::*; + use lopdf::dictionary; + + #[test] + fn shared_javascript_actions_have_a_document_wide_source_budget() { + let mut d = Document::with_version("1.7"); + let pages = d.new_object_id(); + let page = d.new_object_id(); + let action = d.add_object(dictionary! { "S" => "JavaScript", "JS" => Object::string_literal(vec![b'x'; 256 << 10]) }); + // Distinct annotations share the action: repeated references to the same annotation + // are intentionally deduplicated by inspection before the source budget is charged. + let mut annots: Vec<_> = (0..33) + .map(|_| { + Object::Reference(d.add_object( + dictionary! { "Type" => "Annot", "Subtype" => "Link", "Rect" => vec![0.into(),0.into(),50.into(),50.into()], "A" => action }, + )) + }) + .collect(); + let ordinary = d.add_object(dictionary! { "Subtype" => "Link", "Rect" => vec![0.into(),0.into(),50.into(),50.into()], "Dest" => vec![Object::Reference(page), Object::Name(b"Fit".to_vec())] }); + annots.push(Object::Reference(ordinary)); + d.objects.insert(page, dictionary! { "Type" => "Page", "Parent" => pages, "Annots" => annots }.into()); + d.objects.insert( + pages, + dictionary! { "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1, "MediaBox" => vec![0.into(),0.into(),200.into(),200.into()] } + .into(), + ); + let root = d.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + d.trailer.set("Root", root); + let mut bytes = Vec::new(); + d.save_to(&mut bytes).unwrap(); + let info = inspect(Arc::new(bytes), None).unwrap(); + assert_eq!(info.links.len(), 34, "the resource limit must not drop annotations"); + assert!(info.has_javascript); + let total: usize = info + .links + .iter() + .map(|link| match &link.target { + LinkTarget::JavaScript(source) => source.len(), + _ => 0, + }) + .sum(); + assert!(total <= 4 << 20, "shared action sources expanded to {total} bytes"); + assert!(matches!(&info.links[0].target, LinkTarget::JavaScript(_))); + assert!(matches!(&info.links[32].target, LinkTarget::Other(kind) if kind == "JavaScript")); + assert!(matches!(&info.links[33].target, LinkTarget::Page(0, _)), "ordinary links after the limit must still resolve"); + assert_eq!(info.warnings.iter().filter(|warning| warning.contains("4 MiB")).count(), 1); + } + + #[test] + fn javascript_link_sources_direct_indirect_stream_and_utf16() { + for storage in 0..7 { + let mut d = Document::with_version("1.7"); + let pages = d.new_object_id(); + let source = "app.alert('é');"; + let string = if storage == 3 { + { + let mut bytes = vec![0xfe, 0xff]; + for unit in source.encode_utf16() { + bytes.extend_from_slice(&unit.to_be_bytes()); + } + Object::String(bytes, lopdf::StringFormat::Hexadecimal) + } + } else { + Object::string_literal(source) + }; + let js = match storage { + 1 => Object::Reference(d.add_object(string)), + 2 => { + let mut stream = lopdf::Stream::new(dictionary! {}, source.as_bytes().to_vec()); + stream.compress().unwrap(); + Object::Reference(d.add_object(stream)) + } + 4 => { + let cycle = d.new_object_id(); + d.objects.insert(cycle, Object::Reference(cycle)); + Object::Reference(cycle) + } + 5 => { + let mut stream = lopdf::Stream::new(dictionary! {}, vec![b'x'; 300 << 10]); + stream.compress().unwrap(); + Object::Reference(d.add_object(stream)) + } + 6 => Object::Integer(42), + _ => string, + }; + let action = d.add_object(dictionary! { "S" => "JavaScript", "JS" => js }); + let annotation = d.add_object( + dictionary! { "Type" => "Annot", "Subtype" => "Link", "Rect" => vec![0.into(),0.into(),50.into(),50.into()], "A" => action }, + ); + let page = d.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "Annots" => vec![annotation.into()] }); + d.objects.insert(pages, dictionary!{ "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1, "MediaBox" => vec![0.into(),0.into(),200.into(),200.into()] }.into()); + let root = d.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + d.trailer.set("Root", root); + let mut bytes = Vec::new(); + d.save_to(&mut bytes).unwrap(); + let info = inspect(Arc::new(bytes), None).unwrap(); + assert!(info.has_javascript); + if storage >= 4 { + assert!(matches!(&info.links[0].target, LinkTarget::Other(kind) if kind == "JavaScript")); + continue; + } + let LinkTarget::JavaScript(script) = &info.links[0].target else { panic!("missing source") }; + assert_eq!(script, source); + } + } +}