lacs-project / lacs-project/sysknife
Seven links in the published book point at pages mdBook never wrote
- Dominant language
- Rust
- Stars
- 12
- Forks
- 19
- Avg merge
- 18h 57m
- Merged PRs (30d)
- 116
Description
`mdbook build` rewrites every relative `.md` link to `.html`. A link that points
out of `docs/`, and a link to a page missing from `docs/SUMMARY.md`, both come
out pointing at a file the build never wrote. Seven links across four pages land
on nothing. The repo runs `markdown-link-check` in CI, and it cannot see any of
this: it resolves the Markdown source, where all seven targets exist on disk. It
would not read these four pages either, which is #372.
Reproduced at `7d19864` with mdBook 0.4.40, the version `.github/workflows/docs.yml`
pins and installs:
```
$ mdbook --version
mdbook v0.4.40
$ mdbook build -d /tmp/sk-book
2026-09-05 10:49:20 [INFO] (mdbook::book): Book building has started
2026-09-05 10:49:20 [INFO] (mdbook::book): Running the html backend
$ grep -o 'href="[^"]*user-stories[^"]*"' /tmp/sk-book/cli.html | sort -u
href="user-stories.html"
$ ls /tmp/sk-book/user-stories.*
ls: cannot access '/tmp/sk-book/user-stories.*': No such file or directory
```
Walking every `href` in the built tree, with `print.html` skipped because it
duplicates the rest:
```
$ cd /tmp/sk-book
$ python3 - <<'PY'
import re, pathlib, urllib.parse
root = pathlib.Path('.')
for html in sorted(root.rglob('*.html')):
if html.name == 'print.html':
continue
for href in re.findall(r'href="([^"]+)"', html.read_text(errors='replace')):
if href.startswith(('http://', 'https://', '#', 'mailto:', '//')):
continue
target = href.split('#')[0].split('?')[0]
if not target.endswith('.html'):
continue
if not (html.parent / urllib.parse.unquote(target)).resolve().exists():
print(f"{html}\t{href}")
PY
cli.html ../README.html
cli.html user-stories.html
contributing/CONTRIBUTING.html ../../SECURITY.html
contributing/testing.html ubuntu-vm-testing.html
contributing/testing.html ../testing/user-stories.html
contributing/testing.html ../../HACKING.html
the-audit-chain.html ../SECURITY.html#audit-anchoring-in-the-default-deployment
```
I did not check these against the published site. Verifying them there needs an
outbound request this account is not allowed to make, so the evidence above is a
local build of the same tree with the same mdBook version the workflow installs.
## Why it matters
`docs/cli.md:559` offers the reader the user stories, which is the document that
explains what SysKnife does end to end. `docs/contributing/testing.md:333` sends
a contributor to the VM recording procedure. Both are the moment somebody
decided to go deeper, and both give them a 404 on a site whose whole job is to
answer that.
All seven targets are present in the tree, which is why a source-level link
checker passes over them:
```
$ ls -l README.md SECURITY.md HACKING.md docs/user-stories.md docs/testing/user-stories.md docs/contributing/ubuntu-vm-testing.md 2>&1
-rw-rw-r-- 1 entropia entropia 18618 sep 4 07:01 docs/contributing/ubuntu-vm-testing.md
-rw-rw-r-- 1 entropia entropia 19617 sep 4 07:01 docs/testing/user-stories.md
-rw-rw-r-- 1 entropia entropia 15508 sep 4 07:01 docs/user-stories.md
-rw-rw-r-- 1 entropia entropia 34220 sep 4 07:01 HACKING.md
-rw-rw-r-- 1 entropia entropia 22548 sep 4 07:01 README.md
-rw-rw-r-- 1 entropia entropia 20463 sep 4 07:01 SECURITY.md
```
Fix the two classes differently.
**Four links point outside `docs/`.** `README.md`, `SECURITY.md` and `HACKING.md`
live at the repo root, so mdBook cannot include them at any depth of `../`, and
the `.md` to `.html` rewrite happens anyway. One file already
does it both ways: `docs/the-audit-chain.md:267` links `SECURITY.md` with a
relative path and breaks, and `docs/the-audit-chain.md:492` links the same file
as `https://github.com/lacs-project/sysknife/blob/main/SECURITY.md` and works.
**Three links point at documents that exist and are unlisted.**
`docs/user-stories.md`, `docs/testing/user-stories.md` and
`docs/contributing/ubuntu-vm-testing.md` are all in the tree. mdBook renders only
what `SUMMARY.md` names, so it copied them across as raw Markdown and wrote no
HTML for any of them.
## Scope
- Repoint the four outward links at `https://github.com/lacs-project/sysknife/blob/main/...`,
copying the form already used at `docs/the-audit-chain.md:492`.
- Decide, per document, whether the three unlisted files belong in the book. Two
of them are named by `CLAIM_FILES` in `scripts/check_evidence_claims.py`, so the
project already treats them as load-bearing. Adding them to `SUMMARY.md` is the
smaller change; linking them by URL is defensible for anything meant to stay
out of the published book.
- The trap: `docs/user-stories.md` and `docs/testing/user-stories.md` are two
different documents that share a basename.
```
$ md5sum docs/user-stories.md docs/testing/user-stories.md
17cb3f780131c1c62baa75bc0b62ca30 docs/user-stories.md
7ff722164b0ec573ab89e09dc9a92609 docs/testing/user-stories.md
$ wc -l docs/user-stories.md docs/testing/user-stories.md
438 docs/user-stories.md
593 docs/testing/user-stories.md
```
`docs/cli.md:559` wants one and `docs/contributing/testing.md:388` wants the
other. Read both before you pick a target for either link.
## Tests first
A guard that builds the book and fails on any internal `.html` link with no file
behind it. The walk above is the whole implementation. Two things decide whether
it is worth having:
**Assert the walk found links at all.** A selector that matches nothing reports
zero broken links and passes, which is the failure this tracker keeps meeting;
#362 is the current example, and it cites #315 as the same defect one job over.
Count the hrefs examined and fail when that count is zero.
**Prove it bites.** Delete one line from `docs/SUMMARY.md`, rebuild, and watch the
guard name the page that lost its target. Then put it back and watch it go green.
Where it runs is a real choice. `.github/workflows/docs.yml` already installs
mdBook and builds the book, so a step there costs nothing and catches the break
on the commit that ships it. A `tests/release/*.test.sh` reaches `ci-local.sh`
and every contributor, and has to handle a machine with no mdBook: skip loudly,
naming the check that did not run, rather than passing quietly. #346 covers why
a silent local skip is its own defect.
## Difficulty
`easy`. The seven fixes are one line each. The guard is a twenty-line walk over
files the docs job already produces, and deciding which of the three documents
join `SUMMARY.md` is the only judgement in it.
## Getting started
[CONTRIBUTING.md](https://github.com/lacs-project/sysknife/blob/main/CONTRIBUTING.md)
has the build and test commands. This one needs no VM, no daemon and no LLM key:
`cargo install mdbook --version 0.4.40` (or the release tarball the workflow
downloads) and `mdbook build` is the whole environment. No CLA and no copyright
waiver. The project is MIT.
Contributor guide
Assessment
This issue has not been assessed yet.