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.
352 lines
12 KiB
Rust
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}"
|
|
);
|
|
}
|