nigig-org/crates/apps/pdf/pdf-document/tests/destinations.rs
andodeki 82eb6b9c73
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
feat(pdf): internal links that actually go somewhere
ADR 0017 left destinations.rs at 0% coverage as an open item. The obvious
reading is "an untested module". The real one is worse: nothing called it.
It was pub use'd from lib.rs and referenced from nowhere else in the
workspace. 0% was not a gap in the tests, it was the symptom of dead code,
and nothing else was doing the job.

Meanwhile PdfAnnotation read a link's target as
dict.get_name("Dest") - a *name* /Dest and nothing else. Not
/Dest [4 0 R /Fit], and not /A << /S /GoTo /D ... >>, which is how internal
links are written in practically every real document.

The corpus has had one since Phase 6, in annotations/links.pdf, and no test
asserted where it went:

  Link { uri: None, dest: None }  ->  action=None

Clicking it did nothing. No error, no warning - the viewer got no action and
correctly performed none. A link to nowhere and a link the reader cannot
parse look identical from outside. The viewer was already wired for this:
PdfAction::GoToPage exists, is matched in test_host.rs, and was never
constructed by anything. A complete delivery path with nothing at the source.

Now: all three legal spellings parse, named destinations resolve through the
/Names /Dests tree *and* the pre-1.2 /Root /Dests dictionary, and resolution
happens in page_annotations where the catalogue is in reach.

XYZ keeps Option per component because null is meaningful there and only
there - it means "leave unchanged". Reading it as 0.0 scrolls to the origin
at 0% magnification. Zoom 0 means the same as null and is normalised.

Lookup uses a deliberate shallow resolve. Deep-resolving a destination array
replaces [4 0 R /Fit] with the page dictionary and destroys the only thing
identifying the target - the defect that once emptied every AcroForm
(ADR 0006) and every annotation reference (ADR 0004).

GoToAction now requires /S to be GoTo. The old code ignored /S and took /D
from whatever it was handed, so a /GoToR (another file), /Launch (a program)
or /JavaScript carrying a /D was reported as a local page jump. Refuse by
verb, same policy as ADR 0012. An unresolvable destination is left
unresolved, never defaulted to page 0: silently landing on page one is the
worst outcome because it looks like the link worked.

Seven mutations, all killed. M1 - removing the /S check - reported as
surviving on the first attempt. It had not survived: the patch string
omitted an interleaved comment so the mutation never applied and I measured
the unmutated build. A harness that does not verify its own mutation says
"weak test" when the truth is "never ran", and the conclusion would have
been to delete a real security check. Every mutation now asserts it applied.

destinations.rs 0% -> 98.65%; total 83.42% -> 83.86%. Floors added for
destinations.rs and annotations.rs, verified to fail when breached.

AnnotationType::Link changes shape (dest: Option<String> ->
destination: Option<Destination>) and AnnotationAction gains
GoToDestination; the old field could not express an explicit destination, so
keeping it meant keeping the bug. AnnotationAction loses Eq because a
destination carries f64 coordinates.

pdf: 724 passed (was 695). pdf-ui: 769 passed (was 725). ADR 0018.
2026-08-16 19:34:01 +00:00

352 lines
12 KiB
Rust

//! Internal navigation: links that go somewhere.
//!
//! `destinations.rs` sat at 0% coverage because nothing in the crate called
//! it — the module was exported and dead, while the annotation layer read
//! only a *name* `/Dest` and ignored `/A << /S /GoTo >>` entirely. The
//! corpus contained exactly one internal link and no test asserted where it
//! pointed, so a reader that resolved no destination at all passed: a link
//! that does nothing looks the same as a link to nowhere.
//!
//! These tests assert page indices, coordinates and refusals by value.
//!
//! See `REVIEWS/adr/0018-pdf-destinations.md`.
use std::path::PathBuf;
use nigig_pdf_document::{
AnnotationAction, AnnotationType, DestinationKind, DestinationTarget, PdfDocument,
};
fn corpus_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/corpus")
}
fn load(relative: &str) -> Vec<u8> {
let path = corpus_dir().join(relative);
std::fs::read(&path).unwrap_or_else(|e| panic!("missing fixture {}: {e}", path.display()))
}
/// The destination kinds of every link on page 0, in document order.
fn link_kinds(data: &[u8]) -> Vec<DestinationKind> {
let mut doc = PdfDocument::parse(data).expect("parses");
doc.page_annotations(0)
.expect("annotations")
.into_iter()
.filter_map(|a| match a.annot_type {
AnnotationType::Link { destination, .. } => destination.map(|d| d.kind),
_ => None,
})
.collect()
}
/// The action of every link on page 0.
fn link_actions(data: &[u8]) -> Vec<Option<AnnotationAction>> {
let mut doc = PdfDocument::parse(data).expect("parses");
doc.page_annotations(0)
.expect("annotations")
.into_iter()
.filter(|a| matches!(a.annot_type, AnnotationType::Link { .. }))
.map(|a| a.action())
.collect()
}
// ------------------------------------------------------ the original bug
#[test]
fn the_internal_link_in_the_links_fixture_actually_navigates() {
// This link has existed in the corpus since Phase 6 and did nothing.
// `/A << /S /GoTo /D [4 0 R /Fit] >>` where object 4 is page index 1.
let actions = link_actions(&load("annotations/links.pdf"));
assert_eq!(
actions,
vec![
Some(AnnotationAction::OpenUri(
"https://example.org/docs".to_string()
)),
Some(AnnotationAction::GoToPage { page_index: 1 }),
],
"the GoTo link must resolve to page 1, not to None"
);
}
// --------------------------------------------------------- explicit fits
#[test]
fn every_explicit_fit_variant_survives_the_document_layer() {
let kinds = link_kinds(&load("destinations/explicit.pdf"));
assert_eq!(kinds.len(), 11, "one link per fit, plus the /Dest form");
assert_eq!(kinds[0], DestinationKind::Fit);
assert_eq!(kinds[1], DestinationKind::FitB);
assert_eq!(kinds[2], DestinationKind::FitH { top: Some(700.0) });
assert_eq!(kinds[3], DestinationKind::FitV { left: Some(50.0) });
assert_eq!(kinds[4], DestinationKind::FitBH { top: Some(700.0) });
assert_eq!(kinds[5], DestinationKind::FitBV { left: Some(50.0) });
assert_eq!(
kinds[6],
DestinationKind::FitR {
left: 10.0,
bottom: 20.0,
right: 300.0,
top: 400.0
}
);
assert_eq!(
kinds[7],
DestinationKind::Xyz {
left: Some(72.0),
top: Some(720.0),
zoom: Some(1.5)
}
);
// A null coordinate means "leave it unchanged", not zero.
assert_eq!(
kinds[8],
DestinationKind::Xyz {
left: None,
top: Some(500.0),
zoom: None
}
);
// Zoom 0 means the same as null; the coordinates 0 are real.
assert_eq!(
kinds[9],
DestinationKind::Xyz {
left: Some(0.0),
top: Some(0.0),
zoom: None
}
);
// The last link writes /Dest on the annotation rather than an action.
assert_eq!(kinds[10], DestinationKind::Fit);
}
#[test]
fn every_explicit_link_resolves_to_the_same_target_page() {
let actions = link_actions(&load("destinations/explicit.pdf"));
assert_eq!(actions.len(), 11);
for (i, action) in actions.iter().enumerate() {
assert_eq!(
action,
&Some(AnnotationAction::GoToPage { page_index: 1 }),
"link {i} should point at page 1"
);
}
}
#[test]
fn a_destination_written_on_the_annotation_works_like_an_action() {
// `/Dest [4 0 R /Fit]` and `/A << /S /GoTo /D [4 0 R /Fit] >>` are two
// spellings of one thing; only the action form was ever read.
let actions = link_actions(&load("destinations/explicit.pdf"));
assert_eq!(
actions.last().unwrap(),
&Some(AnnotationAction::GoToPage { page_index: 1 })
);
}
// ------------------------------------------------------------ name trees
#[test]
fn a_named_destination_is_found_in_a_nested_name_tree() {
// The target sits in the *second* leaf under /Kids, so a reader that
// only checks the root node finds nothing.
let actions = link_actions(&load("destinations/name_tree.pdf"));
assert_eq!(
actions,
vec![
Some(AnnotationAction::GoToPage { page_index: 1 }),
Some(AnnotationAction::GoToPage { page_index: 1 }),
],
"both the action form and the bare /Dest name must resolve"
);
}
#[test]
fn a_name_tree_entry_may_wrap_its_destination_in_a_d_dictionary() {
let data = load("destinations/name_tree.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
let dest = doc
.lookup_named_destination("chapter2")
.expect("chapter2 is defined");
assert_eq!(
dest.kind,
DestinationKind::Xyz {
left: Some(72.0),
top: Some(500.0),
zoom: None
},
"the /D wrapper must be unwrapped, keeping the coordinates"
);
}
#[test]
fn the_legacy_dests_dictionary_is_read_too() {
// Pre-1.2 files put destinations in /Root /Dests rather than a name
// tree. Supporting only the modern spelling loses every link in them.
let actions = link_actions(&load("destinations/legacy_dests_dict.pdf"));
assert_eq!(
actions,
vec![Some(AnnotationAction::GoToPage { page_index: 1 })]
);
let data = load("destinations/legacy_dests_dict.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
let dest = doc.lookup_named_destination("summary").expect("defined");
assert_eq!(dest.kind, DestinationKind::FitH { top: Some(700.0) });
}
#[test]
fn named_destinations_are_enumerated_from_both_spellings() {
let data = load("destinations/name_tree.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
let names: Vec<String> = doc
.named_destinations()
.into_iter()
.map(|(n, _)| n)
.collect();
assert_eq!(names, vec!["appendix".to_string(), "chapter2".to_string()]);
let data = load("destinations/legacy_dests_dict.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
let names: Vec<String> = doc
.named_destinations()
.into_iter()
.map(|(n, _)| n)
.collect();
assert_eq!(names, vec!["summary".to_string()]);
}
// ------------------------------------------------------- refusal by verb
#[test]
fn a_remote_or_executable_action_is_never_a_local_page_jump() {
// /GoToR, /Launch and /JavaScript all carry a /D in this fixture. A
// reader that takes /D without checking /S navigates for all three,
// which is both wrong and a way to make a dangerous action look like
// an innocuous one.
let actions = link_actions(&load("destinations/dangerous_actions.pdf"));
assert_eq!(actions.len(), 4);
for (i, action) in actions.iter().take(3).enumerate() {
assert_eq!(
action, &None,
"annotation {i} carries a non-GoTo verb and must yield no action"
);
}
}
#[test]
fn a_link_to_an_undefined_name_resolves_to_nothing_not_to_page_zero() {
// Silently landing on page 0 is the worst outcome: it looks like the
// link worked.
let actions = link_actions(&load("destinations/dangerous_actions.pdf"));
assert_eq!(
actions[3],
Some(AnnotationAction::GoToNamed("nowhere".to_string())),
"an unresolvable name stays unresolved rather than becoming page 0"
);
}
#[test]
fn an_unknown_name_lookup_returns_none() {
let data = load("destinations/name_tree.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
assert!(doc.lookup_named_destination("does-not-exist").is_none());
}
// ---------------------------------------------------------- malformed
#[test]
fn malformed_destinations_do_not_panic_or_mislead() {
let data = load("destinations/malformed.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
let annots = doc.page_annotations(0).expect("annotations");
assert_eq!(annots.len(), 4);
// An empty /D array: no destination at all.
assert_eq!(annots[0].action(), None);
// A destination pointing at an object that is not a page: parsed, but
// not resolvable, so it must not claim a page.
match annots[1].action() {
None => {}
Some(AnnotationAction::GoToDestination(d)) => {
assert!(
!matches!(d.page, DestinationTarget::PageIndex(_)),
"a non-page target must not resolve to an index"
);
}
other => panic!("unexpected action for a non-page target: {other:?}"),
}
// An unknown fit keeps the page and degrades the framing.
assert_eq!(
annots[2].action(),
Some(AnnotationAction::GoToPage { page_index: 0 }),
"an unknown fit must not cost us the page"
);
// A GoTo with no /D.
assert_eq!(annots[3].action(), None);
}
#[test]
fn a_cyclic_name_tree_terminates() {
// /Kids points back at the root. The assertion is that this returns.
let data = load("destinations/malformed.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
assert!(doc.lookup_named_destination("anything").is_none());
assert!(doc.named_destinations().is_empty());
}
// ------------------------------------------------- corpus-wide invariant
#[test]
fn no_link_in_the_corpus_resolves_to_a_page_that_does_not_exist() {
// The failure this guards against is a destination resolving to an
// index past the end of the document, which panics a viewer that
// trusts it.
let mut stack = vec![corpus_dir()];
let mut checked = 0usize;
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().is_none_or(|e| e != "pdf") {
continue;
}
let Ok(data) = std::fs::read(&path) else {
continue;
};
let Ok(mut doc) = PdfDocument::parse(&data) else {
continue;
};
let page_count = doc.page_count();
for index in 0..page_count {
let Ok(annots) = doc.page_annotations(index) else {
continue;
};
for annot in annots {
if let Some(AnnotationAction::GoToPage { page_index }) = annot.action() {
assert!(
page_index < page_count,
"{}: a link resolves to page {page_index} of {page_count}",
path.display()
);
checked += 1;
}
}
}
}
}
assert!(
checked >= 14,
"expected resolved internal links across the corpus, found {checked}"
);
}