render: read a link's JavaScript action as LinkTarget::JavaScript

Ported from #2081: link annotations whose action is JavaScript keep the script (up to 256 KiB
each, 4 MiB per document; larger ones are rejected and reported once).

Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com>
This commit is contained in:
FredApps
2026-11-11 12:32:31 +01:00
co-authored by LMatt08
parent 3a117c3c0e
commit 6d03a7acdf
3 changed files with 306 additions and 4 deletions
+194 -4
View File
@@ -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<ObjectId>,
scripts: &mut LinkScripts,
) -> Option<Link> {
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<String> {
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<LinkTarget> {
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<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
thread_local! { static JS_DECODE_COUNT: std::cell::Cell<usize> = 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<u8> {
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:?}");
}
}
}
+3
View File
@@ -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.
+109
View File
@@ -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);
}
}
}