nigig-org/REVIEWS/adr/0018-pdf-destinations.md
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

202 lines
9 KiB
Markdown

# ADR 0018: destinations — the module at 0% coverage was dead, and every internal link with it
- **Status:** Accepted
- **Date:** 2026-08-16
- **Review item:** the open item left by ADR 0017, *"`destinations.rs`
remains at 0% coverage and has no floor; it needs tests before it needs a
floor"*
- **Supersedes:** nothing
- **Related:** ADR 0017 (declared-versus-delivered; this is the same class of
defect, found by following its open item), ADR 0012 (refusing dangerous
actions rather than performing them), ADR 0004 / ADR 0006 (deep-resolve
destroying the references that identify things)
## Context
ADR 0017 recorded `destinations.rs` at 0% line coverage. The obvious reading
is "an untested module". The real one is worse.
**Nothing called it.** The module was `pub use`d from `lib.rs` and referenced
from nowhere else in the workspace:
```
$ grep -rn 'destinations::|GoToAction|DestinationKind' --include='*.rs' .
./pdf-document/src/lib.rs:23:pub use destinations::{Destination, GoToAction};
```
0% coverage was not a gap in the tests. It was the *symptom* of dead code,
and the thing it was supposed to do was not being done by anything else.
### Internal links did nothing
`PdfAnnotation::from_dict` read a link's target as:
```rust
dest: dict.get_name("Dest").map(|s| s.to_string()),
```
A **name** `/Dest`, and nothing else. Not `/Dest [4 0 R /Fit]` (an explicit
array), and not `/A << /S /GoTo /D ... >>` — which is how internal links are
written in practically every real document.
The corpus already contained one, in `annotations/links.pdf`, and had since
Phase 6:
```
11: << /Type /Annot /Subtype /Link /Rect [72 660 272 680]
/P 3 0 R /A << /S /GoTo /D [4 0 R /Fit] >> >>
```
Probing it before any change:
```
Link { uri: Some("https://example.org/docs"), dest: None } -> OpenUri(...)
Link { uri: None, dest: None } -> action=None
```
`action=None`. Clicking that link did nothing. No error, no warning — the
viewer received no action and correctly performed none. A link to nowhere
and a link the reader cannot parse are indistinguishable from the outside,
which is exactly the failure mode ADR 0017 is about, one layer up.
The viewer was *already wired* for this: `PdfAction::GoToPage` exists in
`interaction.rs`, is matched in `test_host.rs`, and was **never constructed
by anything**. A complete delivery path with nothing at the source end.
### Why no test caught it
The corpus had one internal link and no test asserted where it went.
`annotation_roundtrip.rs` and `corpus.rs` checked that link annotations
*parsed* and that their rectangles were right. Both passed. The assertion
that was missing is the one that names a page number.
## Decision
### Make the module real, and call it
`destinations.rs` now parses all three forms a destination is legally
written in (§12.3.2): an explicit array, a name, a byte string, and the
`/D`-wrapped dictionary a name tree leaf uses. `Destination::from_object`
accepts any of them.
`XYZ` carries `Option<f64>` per component, because **null is meaningful and
only meaningful there**: it means "leave this unchanged". Collapsing null to
`0.0` is not a harmless default — it scrolls to the origin, and for `zoom`
it requests 0% magnification. A zoom of literal `0` means the same as null
(Table 151) and is normalised to `None`, so a viewer cannot render at zero.
An unrecognised fit degrades to `/Fit` rather than failing: losing the
framing is recoverable, losing the page is not.
### Resolve names where the catalogue is in reach
`PdfDocument` gained `lookup_named_destination`, `resolve_destination` and
`named_destinations`. Both spellings are searched, because both are current:
the `/Names /Dests` **name tree** (§7.9.6, walked through `/Kids`) and the
pre-1.2 `/Root /Dests` **dictionary**. Supporting only the modern one loses
every link in an older file.
Lookup uses a deliberate `resolve_shallow`, not `resolve`. Deep-resolving a
destination array replaces `[4 0 R /Fit]` with the page *dictionary* and
destroys the only thing identifying the target — the same defect that once
emptied every AcroForm (ADR 0006) and every annotation reference (ADR 0004).
Mutation M8 below confirms this is load-bearing, not superstition.
Destinations are resolved in `page_annotations`, so annotations reach the
viewer already carrying a page index. An unresolvable one is **left
unresolved**, never defaulted to page 0: silently landing on the first page
is the worst outcome, because it looks like the link worked.
### Refuse by verb
`GoToAction::from_dict` requires `/S` to be `GoTo`. The previous version
ignored `/S` entirely and read `/D` from whatever it was given, so a
`/GoToR` (another *file*), a `/Launch` (a program) or a `/JavaScript`
carrying a `/D` was reported as an in-document page jump. Same policy as ADR
0012: refuse by name, do not silently perform.
## Verification
Every claim checked by running it.
**Before:** `action=None` for the corpus's only internal link.
**After:** `action=Some(GoToPage { page_index: 1 })`.
**Mutation testing — seven mutations, all killed:**
| # | Mutation | Result |
|---|---|---|
| M1 | `/S` verb check removed | **1 fails**`/Launch` became `GoToPage { page_index: 1 }` |
| M2 | null in `XYZ` read as `0.0` | **1 fails** |
| M3 | zoom `0` not normalised | **1 fails** |
| M4 | name tree `/Kids` not descended | **2 fail** |
| M5 | legacy `/Root /Dests` fallback removed | **1 fails** |
| M6 | unresolvable name defaults to page 0 | **1 fails** |
| M7 | `/Dest` on the annotation ignored | **4 fail** |
| M8 | `resolve_shallow` → deep `resolve` | **5 fail** |
**M1 was a false negative on the first attempt, and that is worth
recording.** The first run reported it surviving. It had not survived — the
Python replacement string omitted an interleaved comment, so the patch never
applied and I measured the *unmutated* build. Re-run with an `assert old in
s` guard, it failed as it should. A mutation harness that does not verify
the mutation was applied reports "test is weak" when the truth is "the test
never ran against changed code", and the conclusion — deleting a real
security check as untested — would have been actively harmful. Every
mutation here now asserts its own application.
**Coverage:** `destinations.rs` **0% → 98.65%**; total 83.42% → **83.86%**.
Floors added for `destinations.rs` (94) and `annotations.rs` (70), and the
new floor verified to fail when raised above measurement.
**Suite:** `TEST_TARGET=pdf ./tools/test-rust-clean.sh` → **724 passed, 0
failed** (was 695; +29).
## Merge criteria
- [x] `destinations.rs` is called by production code, not only exported
- [x] Explicit array, name, string and `/D`-wrapped forms all parse
- [x] All eight fit kinds parse to distinct values, asserted individually
- [x] `null` in `XYZ` reads as "unchanged", not `0.0`
- [x] Zoom `0` normalised to "unchanged"; coordinate `0` kept as `0.0`
- [x] Named destinations resolved through a nested `/Names /Dests` tree
- [x] Legacy `/Root /Dests` dictionary resolved
- [x] `/GoToR`, `/Launch`, `/JavaScript` refused by verb even when carrying
a `/D`
- [x] An unresolvable destination never becomes page 0
- [x] A cyclic name tree terminates
- [x] Corpus-wide invariant: no link resolves past the end of its document
- [x] Five `destinations/` fixtures generated by `generate.py`
- [x] Seven mutations, each verified to apply and each killed
- [x] Coverage floor added for `destinations.rs`, verified to fail when
breached
- [x] `TEST_TARGET=pdf ./tools/test-rust-clean.sh` green (724)
- [x] `cargo fmt --check` and `clippy -D warnings` clean
## Consequences
**Positive.** Internal navigation works: table-of-contents links,
cross-references and outline targets now reach the viewer as page indices.
The `GoToPage` path that existed end-to-end but was never fed is now fed.
A dead module is either alive or gone.
**Negative.** `AnnotationType::Link` changed shape — `dest: Option<String>`
became `destination: Option<Destination>` — and `AnnotationAction` gained
`GoToDestination`. Both are public. The old field could not express an
explicit destination, so keeping it would have meant keeping the bug.
`AnnotationAction` also lost its `Eq` derive, because a destination carries
`f64` coordinates. Deriving `Eq` would have forced those coordinates to be
dropped or rounded, which is the wrong trade for a comparison nothing needs.
**Risk.** `resolve_destination` is called for every link annotation on every
`page_annotations`, adding a name-tree walk per named link. The tree is
traversed linearly rather than binary-searched: correctness first, and these
trees are small. If a document with thousands of named links ever shows up
in a profile, the ordered `/Limits` make a binary search straightforward.
**Out of scope, deliberately:** `/GoToR` remote navigation (parsed and
refused, not followed — acting on it means opening another file, which is a
host decision under rule 5), outline `/Outlines` trees, `/OpenAction` on the
catalogue, and structure-destination `/SD` preference. Each is a separate
feature with its own risk, not a detail of this one.