Skip to main content

holger_front/
lib.rs

1//! **holger.rs's front page** — the whole public door, as one pure function.
2//!
3//! holger-server is a hand-rolled `hyper` dispatcher with no router, no
4//! template engine and no static-asset tree; until now `GET /` answered `404
5//! Unknown repository`. So this crate is not a framework and does not try to
6//! be one: it is [`route`], a function from a method and a path to a
7//! [`Reply`], and mounting it in `server/lib/src/exposed/http.rs` is four
8//! lines next to the `/-/search` arm.
9//!
10//! That shape is the point. Everything the page *is* — the bytes, the JSON,
11//! the cache headers, the refusals — is decided here, where it can be tested
12//! in a crate that builds in seconds, rather than inside a request handler
13//! that needs the whole server to compile.
14//!
15//! ## The two doors
16//!
17//! | path | what it answers |
18//! |---|---|
19//! | `GET /` | the page (one self-contained HTML file) |
20//! | `GET /holger.webp` | the picture on its left half |
21//! | `GET /-/front` | who this server is, and whether it has a browser login |
22//! | `GET /-/releases` | the catalogue: the public repositories, and the latest version of each package |
23//!
24//! ## ★ The one rule about version numbers
25//!
26//! **This crate never decides which version is newest.** [`latest_by`] groups
27//! and folds, but the comparison is a function the caller passes in, and the
28//! caller is `server/lib`, which passes `retention::cmp_version` — the
29//! comparator holger already uses to decide which artifact a retention sweep
30//! must not delete.
31//!
32//! That is not indirection for its own sake. A front page that ranked versions
33//! with its own comparator would be a SECOND source of truth for "the latest
34//! release", and the first time somebody published `1.10.0` beside `1.9.0` the
35//! page and `holger retention` would name different artifacts as the newest —
36//! with no way to tell which of them was wrong.
37
38use serde::{Deserialize, Serialize};
39use std::cmp::Ordering;
40use std::collections::BTreeMap;
41
42use holger_errcode as ec;
43
44/// The page, as served. One file: holger-server has nowhere to serve a second
45/// one from.
46pub const PAGE: &str = include_str!("../assets/front.html");
47
48/// The picture on the left half.
49///
50/// Produced from the repository's OWN `.nornir/assets/holger-znippy-logo.png`
51/// by `holger-ops logo` — the same file the readme shows, converted once, in
52/// Rust, to lossless WebP. Compiled in rather than read from disk, because a
53/// front page whose picture depends on a deploy having copied a file is a
54/// front page with a half-drawn state nobody tests.
55pub const PICTURE: &[u8] = include_bytes!("../assets/holger.webp");
56
57/// Where the picture is served, named once here and once in the page's
58/// `--backdrop-image`. A test asserts they agree.
59pub const PICTURE_PATH: &str = "/holger.webp";
60
61/// **The makers' marks, compiled in for the same reason the picture is** — and
62/// served from THIS origin rather than hotlinked. An avatar fetched off a third
63/// party's CDN would need a hole in this page's policy and would hand every
64/// visitor's address to that third party for two files under 4 kB. Copied from
65/// gunnar-ui, so the estate's two fronts credit their makers identically.
66pub const VETRA_MARK: &[u8] = include_bytes!("../assets/vetra.svg");
67/// Where the Vetra wordmark is served. A WORDMARK, so the page sizes it by
68/// height only (96x18 from its own 1380x260 viewBox); forcing it square would
69/// squash five letters.
70pub const VETRA_PATH: &str = "/vetra.svg";
71pub const IGNALINA_MARK: &[u8] = include_bytes!("../assets/ignalina.png");
72/// Where the Ignalina mark is served — a square avatar, 18x18.
73pub const IGNALINA_PATH: &str = "/ignalina.png";
74pub const FRONT_PATH: &str = "/-/front";
75pub const RELEASES_PATH: &str = "/-/releases";
76
77// ─────────────────────────────────────────────────────────────────────────────
78// The three columns
79// ─────────────────────────────────────────────────────────────────────────────
80
81/// **One row of the front page's table, and there is no fourth field.**
82///
83/// Size, content type, checksum, upload time and namespace all exist on
84/// [`holger_traits::ArtifactEntry`](https://codeberg.org/nordisk/holger) and
85/// none of them is here. The page a stranger reads first answers one question
86/// — what is in this server and how new is it — and every further column is
87/// noise around the answer. The console has the rest.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct Release {
90    /// The holger repository the artifact lives in (`crates-mirror`, `bundles`).
91    pub repository: String,
92    /// The artifact's name. A namespaced coordinate (maven `groupId`, npm
93    /// scope) is rendered `namespace/name` by [`artifact_name`] — one string,
94    /// because the column is one column.
95    pub artifact: String,
96    /// The version, verbatim. Never parsed, never normalised, never
97    /// re-rendered: whatever was published is what is shown.
98    pub version: String,
99}
100
101/// **One public repository, as a stranger may see it.**
102///
103/// ★ Why this exists beside [`Release`]: a repository with nothing in it
104/// produced NO rows, so a freshly installed holger — or one whose store is
105/// still empty, which is what holger.rs is today — answered `/-/releases` with
106/// `[]` and the front page said "Nothing published yet." and named nothing at
107/// all. That is a true sentence about the artifacts and a useless page about
108/// the server: the repositories are configured, they are public, and they are
109/// the first thing a visitor needs in order to point a package manager
110/// anywhere.
111///
112/// So the roster is listed whether or not anything has been published into it.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct Repository {
115    /// The repository's name, which is also its first path segment on this
116    /// server.
117    pub name: String,
118    /// The format it speaks — `cargo`, `maven`, `npm`, `generic`. Verbatim from
119    /// the server; never mapped to a prettier word here, because the word the
120    /// server uses is the word the operator configured.
121    pub format: String,
122    /// How many distinct packages the listing found in it. `0` is an answer and
123    /// is printed as one — an empty repository is a normal state.
124    pub packages: usize,
125}
126
127/// **What `/-/releases` answers: the roster and the rows, in one document.**
128///
129/// One fetch, because it is one question — *what is in this server* — and two
130/// fetches would be two chances for the page to render half an answer.
131///
132/// It is an object and not an array, and that is a wire change with a reason:
133/// the array could only ever carry artifacts, so a server with repositories and
134/// no artifacts had no way to say so.
135#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
136pub struct Catalogue {
137    /// Every repository a stranger may list. Already ordered by the caller.
138    pub repositories: Vec<Repository>,
139    /// The newest version of each package, already ranked and ordered by the
140    /// caller. See the module note on why this crate ranks nothing.
141    pub releases: Vec<Release>,
142}
143
144/// How a namespaced coordinate becomes the single string in the middle column.
145///
146/// `Some("org.apache.arrow") + "arrow-vector"` -> `org.apache.arrow/arrow-vector`;
147/// `None + "serde"` -> `serde`. An empty namespace is the same as none — some
148/// backends hand back `Some("")` and a leading `/` in the column would be a
149/// visible bug for an invisible cause.
150pub fn artifact_name(namespace: Option<&str>, name: &str) -> String {
151    match namespace {
152        Some(ns) if !ns.is_empty() => format!("{ns}/{name}"),
153        _ => name.to_string(),
154    }
155}
156
157/// Fold every `(repository, artifact, version)` down to the newest version of
158/// each `(repository, artifact)`, using `newer` to compare.
159///
160/// `newer(a, b)` must answer the ordering of `a` against `b` as VERSIONS. Pass
161/// `retention::cmp_version`; do not pass `str::cmp`, which puts `1.9.0` after
162/// `1.10.0` and is the whole reason this is a parameter.
163///
164/// The result is sorted by repository, then artifact — a stable order, so the
165/// page does not reshuffle between two loads of an unchanged server.
166pub fn latest_by<F>(rows: impl IntoIterator<Item = Release>, newer: F) -> Vec<Release>
167where
168    F: Fn(&str, &str) -> Ordering,
169{
170    // A BTreeMap, not a HashMap: the key order IS the output order, so the
171    // sort is free and cannot be forgotten.
172    let mut best: BTreeMap<(String, String), String> = BTreeMap::new();
173    for r in rows {
174        let key = (r.repository, r.artifact);
175        match best.get(&key) {
176            // `Greater` only — a tie keeps the FIRST one seen, so a repository
177            // that somehow lists one version twice does not flap between loads.
178            Some(have) if newer(&r.version, have) != Ordering::Greater => {}
179            _ => {
180                best.insert(key, r.version);
181            }
182        }
183    }
184    best.into_iter()
185        .map(|((repository, artifact), version)| Release { repository, artifact, version })
186        .collect()
187}
188
189// ─────────────────────────────────────────────────────────────────────────────
190// Who this server is
191// ─────────────────────────────────────────────────────────────────────────────
192
193/// ★ **WHICH OF THE THREE STATES the door is in — the third one is new.**
194///
195/// There were two: no login at all ([`Front::login`] is `None`), and a login
196/// somewhere else on this origin (a link the button follows). Neither of them
197/// is what holger.rs needs, because on holger.rs the console IS this origin:
198/// the link sent a visitor to `/login`, a second page with the same button on
199/// it, and the reader pressed the same thing twice to reach one ceremony.
200///
201/// So there is a third: the ceremony runs **on this page**. The page holds the
202/// WebAuthn call itself — `navigator.credentials.get` against the paths the
203/// server names — and there is no navigation until it has succeeded.
204///
205/// It is a field and not a separate type because the airgapped appliance
206/// serves this same crate with `/auth/*` absent entirely. The appliance
207/// answers [`Door::Link`] or `None` and the ceremony code on the page is never
208/// reached; nothing about the ceremony is compiled into the appliance's
209/// choices, it is markup and script that a server has to opt into by naming
210/// three paths.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
212#[serde(rename_all = "lowercase")]
213pub enum Door {
214    /// The button is a link: press it and the browser goes to `start`.
215    ///
216    /// The default, and it is the default on purpose: a `/-/front` document
217    /// written before this field existed deserialises as a link, which is what
218    /// it was.
219    #[default]
220    Link,
221    /// The ceremony is HERE. `start`, `finish` and `next` are all rooted paths
222    /// on this origin and the page never leaves until `finish` said yes.
223    Here,
224}
225
226/// The door a browser can actually walk through, if there is one.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct Login {
229    /// What the button says. **The server's word, not the page's.**
230    pub label: String,
231    /// Where the button goes. A same-site rooted path; the page refuses
232    /// anything else, so a misconfigured server cannot turn the button into an
233    /// open redirect.
234    ///
235    /// For [`Door::Here`] this is the ceremony's START path, fetched rather
236    /// than navigated to.
237    pub start: String,
238    /// Which of the two doors this is. Defaulted, so an older document still
239    /// reads.
240    #[serde(default)]
241    pub door: Door,
242    /// [`Door::Here`] only: where the signed assertion is POSTed. `None` for a
243    /// link, and the page treats a `Here` with no `finish` as a link, because
244    /// a ceremony with nowhere to finish is not a ceremony.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub finish: Option<String>,
247    /// [`Door::Here`] only: where the browser goes once the session cookie is
248    /// set. A same-site rooted path like the others; `None` means `/`.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub next: Option<String>,
251    /// [`Door::Here`] only: where the FIRST key is enrolled, when this server
252    /// offers enrolment on this page at all.
253    ///
254    /// ★ A ceremony with no way to enrol a key is one page short of useful.
255    /// MEASURED on holger.rs 2026-09-20: the button worked, called the door,
256    /// and the door answered "no passkey is registered yet" — true, and a dead
257    /// end, because nothing was enrolled and the only place to enrol was a
258    /// second page again.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub enrol: Option<Enrolment>,
261}
262
263/// **Where a passkey is ENROLLED, as three rooted paths.**
264///
265/// Separate from the login ceremony because they are separate acts with
266/// separate authority: logging in proves you already hold a key, enrolling
267/// mints one, and the second is gated by a break-glass admin token or a
268/// single-use invite. The page's panel is collapsed and only ever appears when
269/// a server hands over all three.
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct Enrolment {
272    /// `GET`, with `?user=` and optionally `?invite=`, plus an
273    /// `Authorization: Bearer <admin token>` when there is no invite.
274    pub start: String,
275    /// `POST` — the attestation.
276    pub finish: String,
277    /// `GET` — reports `bootstrap_spent`. ★ Once the first admin key exists the
278    /// break-glass token is spent, so the panel that names it is a lie to
279    /// everyone who reads it, and the page takes the panel away on this signal.
280    pub status: String,
281}
282
283/// What the button says when the door is the console.
284///
285/// Here rather than in the server for the reason [`Front::no_login_refusal`] is
286/// here: the page prints it verbatim, so it exists in one place and a test can
287/// hold the page to it.
288pub const CONSOLE_LABEL: &str = "Open the console";
289
290/// ★ **What the button says when the ceremony is on this page, and it is the
291/// estate's word.**
292///
293/// Byte-for-byte gunnar's button
294/// (`gunnar-ui/crates/gunnar-ui-server/src/auth/login.html`): the two fronts in
295/// one estate name the same act the same way. "Open the console" describes a
296/// navigation, and there is no navigation any more — the label has to say what
297/// pressing it actually does.
298pub const PASSKEY_LABEL: &str = "Log in with passkey";
299
300/// ★ **The console door, or `None` — and `None` is the answer for anything the
301/// PAGE would refuse to follow.**
302///
303/// The page's button follows only a same-site rooted path (see the `btn.onclick`
304/// guard in `assets/front.html`), which is the open-redirect defence and is not
305/// negotiable. So an operator who points this at `https://console.example.com`
306/// would get a button that is enabled, labelled, and does **nothing at all** on
307/// click — strictly worse than the honest dead button
308/// [`Front::no_login_refusal`] explains, because there is no sentence anywhere
309/// saying why.
310///
311/// This constructor therefore applies the page's own rule **server-side**, and
312/// answers `None` for every value the page would silently drop. The two rules
313/// are asserted equal by
314/// [`the_rust_rule_and_the_pages_rule_refuse_the_same_values`] — one rule, two
315/// languages, held together the way this crate holds its sentences together.
316///
317/// Note what this means for a deployment, because it is a real constraint and
318/// not a detail: **the console must be reachable at a path on THIS server's
319/// origin.** A console on its own host has no rooted path from here, and the
320/// honest answer for it is the one the page already gives.
321pub fn console_login(path: &str) -> Option<Login> {
322    if !same_site_rooted_path(path) {
323        return None;
324    }
325    Some(Login {
326        label: CONSOLE_LABEL.to_string(),
327        start: path.to_string(),
328        door: Door::Link,
329        finish: None,
330        next: None,
331        enrol: None,
332    })
333}
334
335/// ★ **The third state: the passkey ceremony, on this page.**
336///
337/// `start` and `finish` are the server's own ceremony doors and `next` is where
338/// a successful login lands. All three go through `same_site_rooted_path`,
339/// the same rule [`console_login`] applies, and **any one of them failing it
340/// answers `None`** — not a partly-wired ceremony. A page that fetched a
341/// ceremony from one origin and posted the assertion to another is not a door
342/// with a bug in it, it is a different thing entirely.
343///
344/// The label is [`PASSKEY_LABEL`] and the server does not get to choose it: the
345/// button's words and the button's behaviour are one decision, and a server
346/// that could say "Open the console" over a ceremony that never navigates would
347/// be lying to the reader in the one place the reader is looking.
348pub fn passkey_login_here(start: &str, finish: &str, next: &str) -> Option<Login> {
349    if !same_site_rooted_path(start)
350        || !same_site_rooted_path(finish)
351        || !same_site_rooted_path(next)
352    {
353        return None;
354    }
355    Some(Login {
356        label: PASSKEY_LABEL.to_string(),
357        start: start.to_string(),
358        door: Door::Here,
359        finish: Some(finish.to_string()),
360        next: Some(next.to_string()),
361        enrol: None,
362    })
363}
364
365/// ★ **Enrolment, on the same page — or `None`, which is a complete answer.**
366///
367/// Applies the one rule to all three paths and refuses the whole panel if any
368/// of them fails it, for the reason [`passkey_login_here`] refuses a half-wired
369/// ceremony: an enrolment that started here and posted its attestation
370/// elsewhere is not a door with a bug in it.
371///
372/// `None` is what the appliance gets and what any server without `/auth/*`
373/// gets: no field on the wire, no panel on the page, no form that cannot be
374/// served. It is also what a [`Door::Link`] login gets — a server whose login
375/// lives on another page enrols on that page too.
376pub fn with_enrolment(login: Login, start: &str, finish: &str, status: &str) -> Login {
377    if login.door != Door::Here
378        || !same_site_rooted_path(start)
379        || !same_site_rooted_path(finish)
380        || !same_site_rooted_path(status)
381    {
382        return login;
383    }
384    Login {
385        enrol: Some(Enrolment {
386            start: start.to_string(),
387            finish: finish.to_string(),
388            status: status.to_string(),
389        }),
390        ..login
391    }
392}
393
394/// The page's `btn.onclick` rule, in Rust.
395///
396/// One leading slash, no authority, and no character the URL parser strips
397/// before it decides what the value even is: `//host` and `/\host` are off-site
398/// in every engine, and a value carrying a space, a tab, a newline or a NUL is
399/// one whose meaning is settled after those are removed.
400fn same_site_rooted_path(path: &str) -> bool {
401    !path.is_empty()
402        && path.starts_with('/')
403        && !path.starts_with("//")
404        && !path.starts_with("/\\")
405        // The page's class is `[\x00-\x20\x7f]` — written in `assets/front.html`
406        // as literal control bytes, which is why that file reads as binary.
407        // `is_ascii_whitespace` is NOT this set (it omits NUL, the other C0
408        // controls and DEL), so the range is spelled out rather than borrowed.
409        && !path.chars().any(|c| (c as u32) <= 0x20 || c as u32 == 0x7f)
410}
411
412/// What `/-/front` answers: everything on the page that is a fact about THIS
413/// server rather than about holger.
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415pub struct Front {
416    /// The base URL a package manager is pointed at. `None` when the server
417    /// was not told its own public name — printed by the server or not at all,
418    /// because a page that guessed it from `location.origin` would print the
419    /// proxy's address on any deployment behind one.
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub base_url: Option<String>,
422    /// ★ **`None` is an ANSWER, and it is the honest one today.**
423    ///
424    /// holger-server authenticates with mTLS, OIDC and bearer tokens — doors
425    /// for `cargo`, `pip`, `docker` and the native console. There is no
426    /// browser session, so there is no button, and the page says
427    /// [`ec::FRONT_NO_BROWSER_LOGIN`] by name rather than offering one.
428    ///
429    /// It is serialised even when null (no `skip_serializing_if`) precisely so
430    /// the page can tell a server with no login from a server too old to have
431    /// the field.
432    pub login: Option<Login>,
433}
434
435impl Front {
436    /// A server that has told us nothing but its own address.
437    pub fn anonymous(base_url: Option<String>) -> Self {
438        Front { base_url, login: None }
439    }
440
441    /// The refusal the page prints when [`Front::login`] is `None`, by name
442    /// and with its code. Kept here rather than only in the page's JavaScript
443    /// so the words exist in one place and a test can hold them to it.
444    pub fn no_login_refusal() -> String {
445        format!(
446            "{}: this server offers no browser login. Its doors are mTLS, OIDC and bearer — \
447             for cargo, pip, docker and the console, not for a tab.",
448            ec::FRONT_NO_BROWSER_LOGIN.code
449        )
450    }
451}
452
453/// ★ **The three sentences the PAGE prints on its own.**
454///
455/// They are raised in JavaScript, where no Rust compiler can see them and the
456/// frozen registry cannot be consulted — which is exactly how a page ends up
457/// showing a code nobody allocated, or the same failure worded two ways in two
458/// places. So each one is built HERE, from the registry row, and the tests
459/// assert the page carries the string this produces. Change the sentence here
460/// and the test tells you the page has drifted; change it in the page and the
461/// test tells you the same thing.
462///
463/// This is also what makes the rows honest to the wiring guard: a code marked
464/// `wired` must be referenced from the file it claims, and these are the
465/// references.
466pub fn page_refusals() -> [(&'static ec::ErrCode, String); 3] {
467    [
468        (&ec::FRONT_NO_BROWSER_LOGIN, Front::no_login_refusal()),
469        (
470            &ec::FRONT_READS_GATED,
471            format!(
472                "{}: this server gates reads behind a credential, so its front page cannot name \
473                 itself. Configure `require_auth_for_reads: false`, or read the console instead.",
474                ec::FRONT_READS_GATED.code
475            ),
476        ),
477        (
478            &ec::FRONT_DOOR_ABSENT,
479            format!(
480                "{}: this build of holger-server has no `/-/front` door. The page is newer than \
481                 the server behind it.",
482                ec::FRONT_DOOR_ABSENT.code
483            ),
484        ),
485    ]
486}
487
488// ─────────────────────────────────────────────────────────────────────────────
489// The door
490// ─────────────────────────────────────────────────────────────────────────────
491
492/// One answer: a status, a content type, headers that matter, and the bytes.
493#[derive(Debug, Clone, PartialEq)]
494pub struct Reply {
495    pub status: u16,
496    pub content_type: &'static str,
497    /// `Cache-Control`. Named rather than implied: the page and the JSON must
498    /// NOT be cached (a stale release table is a lie about what is published)
499    /// and the picture must be, because it is 300 kB that never changes
500    /// without a redeploy.
501    pub cache_control: &'static str,
502    pub body: Vec<u8>,
503}
504
505impl Reply {
506    fn html(body: &str) -> Reply {
507        Reply {
508            status: 200,
509            content_type: "text/html; charset=utf-8",
510            cache_control: "no-store",
511            body: body.as_bytes().to_vec(),
512        }
513    }
514    fn json(body: String) -> Reply {
515        Reply {
516            status: 200,
517            content_type: "application/json",
518            cache_control: "no-store",
519            body: body.into_bytes(),
520        }
521    }
522    fn webp(bytes: &'static [u8]) -> Reply {
523        Reply {
524            status: 200,
525            content_type: "image/webp",
526            // A year, and immutable: the file's content is compiled into the
527            // binary, so it cannot change without a new binary, and a new
528            // binary is a new deploy.
529            cache_control: "public, max-age=31536000, immutable",
530            body: bytes.to_vec(),
531        }
532    }
533    /// A mark, cached like the picture: compiled into the binary, so it cannot
534    /// change without a new deploy.
535    fn mark(bytes: &'static [u8], content_type: &'static str) -> Reply {
536        Reply {
537            status: 200,
538            content_type,
539            cache_control: "public, max-age=31536000, immutable",
540            body: bytes.to_vec(),
541        }
542    }
543    fn refused(status: u16, code: &ec::ErrCode, detail: &str) -> Reply {
544        Reply {
545            status,
546            content_type: "application/json",
547            cache_control: "no-store",
548            body: serde_json::json!({ "code": code.code, "error": detail }).to_string().into_bytes(),
549        }
550    }
551}
552
553/// What the server must hand the door to answer it.
554///
555/// It is a struct and not four arguments so that adding a fact to the front
556/// page is a field here and a line in the caller, never a new function.
557pub struct Doors<'a> {
558    pub front: &'a Front,
559    /// The roster and the rows. Already the latest, already ordered. The door
560    /// does not rank — see the module note on why.
561    pub catalogue: &'a Catalogue,
562}
563
564/// ★ **The whole public surface, as a pure function.**
565///
566/// `None` means "not mine" — the caller falls through to its own routing, so
567/// mounting this cannot shadow a repository. Every path it DOES own is under
568/// `/` or the already-reserved `/-/` namespace, which can never route into a
569/// repository.
570pub fn route(method: &str, path: &str, doors: &Doors<'_>) -> Option<Reply> {
571    let owned = matches!(
572        path,
573        "/" | PICTURE_PATH | VETRA_PATH | IGNALINA_PATH | FRONT_PATH | RELEASES_PATH
574    );
575    if !owned {
576        return None;
577    }
578
579    // ★ Read-only, and the refusal is by name. A `POST /` that fell through to
580    // the repository router would be a write attempt against a repository
581    // called "" — a 404 that looks like a typo instead of a method that is not
582    // allowed here.
583    if method != "GET" && method != "HEAD" {
584        return Some(Reply::refused(
585            405,
586            &ec::FRONT_METHOD_NOT_ALLOWED,
587            "the front door answers GET and HEAD only",
588        ));
589    }
590
591    let mut reply = match path {
592        "/" => Reply::html(PAGE),
593        PICTURE_PATH => Reply::webp(PICTURE),
594        VETRA_PATH => Reply::mark(VETRA_MARK, "image/svg+xml"),
595        IGNALINA_PATH => Reply::mark(IGNALINA_MARK, "image/png"),
596        FRONT_PATH => Reply::json(
597            serde_json::to_string(doors.front).unwrap_or_else(|_| "{}".to_string()),
598        ),
599        RELEASES_PATH => Reply::json(
600            serde_json::to_string(doors.catalogue)
601                .unwrap_or_else(|_| r#"{"repositories":[],"releases":[]}"#.to_string()),
602        ),
603        _ => unreachable!("`owned` above is the same list"),
604    };
605    // HEAD is the same answer without the bytes. Written here rather than left
606    // to the caller, because a HEAD that returned the body would be the kind
607    // of thing nothing notices until a health check downloads the picture
608    // every thirty seconds.
609    if method == "HEAD" {
610        reply.body.clear();
611    }
612    Some(reply)
613}
614
615/// The refusal for a server whose reads are gated, so `/-/front` cannot be
616/// read at all. Produced by the CALLER, which is the half that knows about
617/// `require_auth_for_reads`; it lives here so the code and the words are in
618/// the same place as the page that prints them.
619pub fn reads_gated() -> Reply {
620    Reply::refused(
621        403,
622        &ec::FRONT_READS_GATED,
623        "this server gates reads behind a credential, so its front page cannot name itself",
624    )
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    /// A comparator that is WRONG on purpose — plain string order, which puts
632    /// `1.9.0` above `1.10.0`. Used to prove the fold asks the comparator it
633    /// was given and holds no opinion of its own.
634    fn lexicographic(a: &str, b: &str) -> Ordering {
635        a.cmp(b)
636    }
637
638    /// A toy numeric comparator standing in for `retention::cmp_version`, so
639    /// the grouping can be tested without depending on `server/lib`.
640    fn numeric(a: &str, b: &str) -> Ordering {
641        let parts = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
642        parts(a).cmp(&parts(b))
643    }
644
645    fn rel(repo: &str, art: &str, ver: &str) -> Release {
646        Release { repository: repo.into(), artifact: art.into(), version: ver.into() }
647    }
648
649    // ── The console door ─────────────────────────────────────────────────────
650
651    /// A path the page will follow becomes a button, labelled in the server's
652    /// own word.
653    #[test]
654    fn a_rooted_path_becomes_the_console_button() {
655        let login = console_login("/console").expect("a rooted path is a door");
656        assert_eq!(login.start, "/console");
657        assert_eq!(login.label, CONSOLE_LABEL);
658    }
659
660    /// ★ **The Rust rule and the PAGE's rule refuse exactly the same values.**
661    ///
662    /// This is the test the whole constructor exists for. The page's
663    /// `btn.onclick` drops a value it will not follow **silently** — no
664    /// sentence, no code, just a button that does nothing — so a server that
665    /// answered with one would produce the one failure state the front page's
666    /// design is otherwise free of. Rather than trust two prose descriptions of
667    /// one rule, the page's own class is read out of [`PAGE`] and applied here.
668    ///
669    /// It is spelled `\x00-\x20` plus `\x7f`, and since 2026-09-20 it is
670    /// written in the HTML as JS ESCAPES and not as literal control bytes.
671    /// That is not a style preference, it is the fix for a page that hung:
672    /// the literal NUL made `assets/front.html` read as binary to `file(1)`,
673    /// and in a browser the HTML tokenizer's script-data state replaces a
674    /// U+0000 with U+FFFD, so the engine parsed `/[\u{fffd}-\u{20}\u{7f}]/`,
675    /// refused it as "range out of order in character class", and threw away
676    /// the WHOLE inline script — the `/-/front` fetch with it. The page then
677    /// sat on its literal `reading the server …` with the button still
678    /// `disabled` for ever. [`the_page_carries_no_raw_control_bytes`] is the
679    /// guard that keeps a literal from coming back.
680    #[test]
681    fn the_rust_rule_and_the_pages_rule_refuse_the_same_values() {
682        // The page still carries the guard this mirrors. If the button's
683        // handler is rewritten, this test must be re-read, not re-blessed.
684        assert!(
685            PAGE.contains("if (!start.startsWith('/') || start.startsWith('//') || start.startsWith('/\\\\')) return false;"),
686            "the page's same-site guard has moved; console_login may no longer mirror it"
687        );
688        assert!(
689            PAGE.contains(r"if (/[\x00-\x20\x7f]/.test(start)) return false;"),
690            "the page's control-character class has changed; same_site_rooted_path is now a second opinion"
691        );
692
693        for refused in [
694            "",                            // the page returns early on empty
695            "https://console.example.com", // an off-site URL — the tempting mistake
696            "console",                     // a relative path is not rooted
697            "//evil.example.com",          // protocol-relative: off-site everywhere
698            "/\\evil.example.com",         // backslash authority: off-site everywhere
699            "/console\u{0}",               // NUL
700            "/console\u{9}",               // tab
701            "/console\u{a}",               // newline
702            "/con sole",                   // space
703            "/console\u{7f}",              // DEL — the one the first draft missed
704        ] {
705            assert!(
706                console_login(refused).is_none(),
707                "{refused:?} must not become a button: the page would drop it in silence"
708            );
709        }
710    }
711
712    /// An unconfigured console leaves the page exactly as it is today — the
713    /// named refusal, not a button to nowhere. A self-hoster who runs no
714    /// console must not be offered one.
715    #[test]
716    fn no_console_configured_is_still_the_named_refusal() {
717        let f = Front { base_url: None, login: None };
718        let body = serde_json::to_string(&f).unwrap();
719        assert!(body.contains("\"login\":null"), "{body}");
720        assert!(Front::no_login_refusal().starts_with(ec::FRONT_NO_BROWSER_LOGIN.code));
721    }
722
723    /// ★ **The fold has no opinion about versions.** The same input with two
724    /// comparators gives two different answers — which is the proof that the
725    /// ranking comes from the caller and not from this crate.
726    #[test]
727    fn the_comparator_decides_and_this_crate_does_not() {
728        let rows = vec![rel("crates", "serde", "1.9.0"), rel("crates", "serde", "1.10.0")];
729        assert_eq!(latest_by(rows.clone(), numeric)[0].version, "1.10.0");
730        assert_eq!(latest_by(rows, lexicographic)[0].version, "1.9.0");
731    }
732
733    /// One row per `(repository, artifact)`, and the same artifact name in two
734    /// repositories is two rows — a mirror and a local store legitimately hold
735    /// different versions of `serde`, and collapsing them would hide it.
736    #[test]
737    fn one_row_per_repository_and_artifact() {
738        let out = latest_by(
739            vec![
740                rel("crates", "serde", "1.0.1"),
741                rel("crates", "serde", "1.0.9"),
742                rel("mirror", "serde", "1.0.4"),
743                rel("crates", "tokio", "1.2.0"),
744            ],
745            numeric,
746        );
747        assert_eq!(out.len(), 3);
748        assert_eq!(
749            out.iter().map(|r| (r.repository.as_str(), r.artifact.as_str(), r.version.as_str())).collect::<Vec<_>>(),
750            vec![("crates", "serde", "1.0.9"), ("crates", "tokio", "1.2.0"), ("mirror", "serde", "1.0.4")]
751        );
752    }
753
754    /// The order is stable and does not depend on the order rows arrived in.
755    /// A page that reshuffled between two loads of an unchanged server would
756    /// read as a server that is changing.
757    #[test]
758    fn the_order_does_not_depend_on_the_input_order() {
759        let a = vec![rel("b", "y", "1"), rel("a", "z", "1"), rel("a", "y", "1")];
760        let mut b = a.clone();
761        b.reverse();
762        assert_eq!(latest_by(a, numeric), latest_by(b, numeric));
763    }
764
765    #[test]
766    fn a_namespace_joins_the_name_with_one_slash_and_an_empty_one_does_not() {
767        assert_eq!(artifact_name(Some("org.apache.arrow"), "arrow-vector"), "org.apache.arrow/arrow-vector");
768        assert_eq!(artifact_name(Some("@scope"), "pkg"), "@scope/pkg");
769        assert_eq!(artifact_name(None, "serde"), "serde");
770        assert_eq!(artifact_name(Some(""), "serde"), "serde", "an empty namespace put a slash on the front");
771    }
772
773    // ── the door ─────────────────────────────────────────────────────────────
774
775    fn doors() -> (Front, Catalogue) {
776        (
777            Front::anonymous(Some("https://holger.rs".into())),
778            Catalogue {
779                repositories: vec![Repository {
780                    name: "bundles".into(),
781                    format: "generic".into(),
782                    packages: 1,
783                }],
784                releases: vec![rel("bundles", "site-a", "3")],
785            },
786        )
787    }
788
789    #[test]
790    fn the_page_is_served_at_the_root_and_is_not_cached() {
791        let (f, r) = doors();
792        let d = Doors { front: &f, catalogue: &r };
793        let reply = route("GET", "/", &d).expect("the root is this door's");
794        assert_eq!(reply.status, 200);
795        assert_eq!(reply.content_type, "text/html; charset=utf-8");
796        assert_eq!(reply.cache_control, "no-store", "the page must not be cached");
797        assert_eq!(reply.body, PAGE.as_bytes());
798    }
799
800    /// The picture is cached hard, because it cannot change without a new
801    /// binary — and the JSON is not, because it changes whenever anybody
802    /// publishes.
803    #[test]
804    fn the_picture_is_cached_forever_and_the_json_never() {
805        let (f, r) = doors();
806        let d = Doors { front: &f, catalogue: &r };
807        let pic = route("GET", PICTURE_PATH, &d).unwrap();
808        assert_eq!(pic.content_type, "image/webp");
809        assert!(pic.cache_control.contains("immutable"), "{}", pic.cache_control);
810        for p in [FRONT_PATH, RELEASES_PATH] {
811            assert_eq!(route("GET", p, &d).unwrap().cache_control, "no-store", "{p} was cacheable");
812        }
813    }
814
815    /// ★ **The picture the page names is the picture the door serves.** The
816    /// path is written twice — once in CSS, once as a constant — and this is
817    /// what keeps the two from drifting into a broken left half that nobody
818    /// notices because the layout still works.
819    #[test]
820    fn the_page_asks_for_the_picture_this_door_serves() {
821        assert!(
822            PAGE.contains(&format!("url(\"{PICTURE_PATH}\")")),
823            "the page's --backdrop-image does not name {PICTURE_PATH}"
824        );
825        assert!(PAGE.contains(RELEASES_PATH), "the page does not fetch {RELEASES_PATH}");
826        assert!(PAGE.contains(FRONT_PATH), "the page does not fetch {FRONT_PATH}");
827    }
828
829    /// ★ **The page carries no raw control byte, because one of them silently
830    /// deleted the whole inline script.**
831    ///
832    /// MEASURED 2026-09-20 on the live holger.rs: `assets/front.html` held a
833    /// literal U+0000 inside the `btn.onclick` guard's regex. `curl` saw a
834    /// perfectly good 23 115-byte document and `/-/front` answered 200; a
835    /// browser did not. The HTML tokenizer's script-data state turns U+0000
836    /// into U+FFFD before the JS engine ever sees it, so the engine was handed
837    /// `/[\u{fffd}-\u{20}\u{7f}]/`, whose range runs backwards. That is an
838    /// early SyntaxError, which kills the ENTIRE `<script>` element — the
839    /// `/-/front` fetch, the button's label, the button's `disabled = false`,
840    /// all of it — and leaves the page showing its own literal placeholder
841    /// `reading the server …` next to a grey button that cannot be pressed.
842    ///
843    /// The class is therefore spelled with escapes now, and this test is the
844    /// reason a literal cannot come back. `\t`, `\n` and `\r` are the three
845    /// bytes a text document is allowed to contain.
846    #[test]
847    fn the_page_carries_no_raw_control_bytes() {
848        for (i, b) in PAGE.bytes().enumerate() {
849            let allowed = matches!(b, b'\t' | b'\n' | b'\r');
850            assert!(
851                allowed || !(b.is_ascii_control() || b == 0x7f),
852                "assets/front.html carries a raw control byte {b:#04x} at offset {i}; \
853                 a browser replaces U+0000 with U+FFFD in script data and throws the whole \
854                 inline script away. Write it as a JS escape."
855            );
856        }
857    }
858
859    /// The picture really is a WebP, and really is the one that was converted
860    /// — not a PNG somebody renamed.
861    #[test]
862    fn the_compiled_in_picture_is_a_webp() {
863        assert!(PICTURE.len() > 12, "the picture is empty — run `holger-ops logo`");
864        assert_eq!(&PICTURE[0..4], b"RIFF", "not a RIFF container");
865        assert_eq!(&PICTURE[8..12], b"WEBP", "not a WebP");
866    }
867
868    /// ★ **Nothing but this door's own four paths is claimed.** A door that
869    /// answered `/crates-mirror` would shadow a repository, and it would do it
870    /// silently — the repository would simply stop existing.
871    #[test]
872    fn a_repository_path_is_never_this_doors() {
873        let (f, r) = doors();
874        let d = Doors { front: &f, catalogue: &r };
875        for p in ["/crates-mirror", "/v2/alpine/manifests/latest", "/-/search", "/healthz", "/bundles/x", ""] {
876            assert!(route("GET", p, &d).is_none(), "{p} was claimed by the front door");
877        }
878    }
879
880    /// A write against the front door is refused BY NAME with a code, not
881    /// dropped into the repository router where it becomes a confusing 404.
882    #[test]
883    fn a_write_is_refused_by_name_with_its_code() {
884        let (f, r) = doors();
885        let d = Doors { front: &f, catalogue: &r };
886        for m in ["POST", "PUT", "DELETE", "PATCH"] {
887            let reply = route(m, "/", &d).unwrap();
888            assert_eq!(reply.status, 405, "{m}");
889            let body = String::from_utf8(reply.body).unwrap();
890            assert!(body.contains(ec::FRONT_METHOD_NOT_ALLOWED.code), "{m}: {body}");
891        }
892    }
893
894    #[test]
895    fn head_answers_the_same_thing_without_the_bytes() {
896        let (f, r) = doors();
897        let d = Doors { front: &f, catalogue: &r };
898        for p in ["/", PICTURE_PATH, FRONT_PATH, RELEASES_PATH] {
899            let get = route("GET", p, &d).unwrap();
900            let head = route("HEAD", p, &d).unwrap();
901            assert_eq!(head.status, get.status);
902            assert_eq!(head.content_type, get.content_type);
903            assert!(head.body.is_empty(), "HEAD {p} carried a body");
904        }
905    }
906
907    /// The three columns go over the wire under the names the page reads, and
908    /// there is no fourth.
909    #[test]
910    fn the_wire_carries_exactly_three_columns() {
911        let (f, r) = doors();
912        let d = Doors { front: &f, catalogue: &r };
913        let body = String::from_utf8(route("GET", RELEASES_PATH, &d).unwrap().body).unwrap();
914        let doc: serde_json::Value = serde_json::from_str(&body).unwrap();
915        let rows: Vec<serde_json::Map<String, serde_json::Value>> =
916            serde_json::from_value(doc["releases"].clone()).unwrap();
917        assert_eq!(rows.len(), 1);
918        // A SET, not a sequence: `serde_json::Map` is a BTreeMap without the
919        // `preserve_order` feature, so the wire order is alphabetical and
920        // asserting declaration order here would only be asserting serde's
921        // build configuration. The COLUMN order is the page's, and it is
922        // checked where it lives — in `the_page_is_holgers`, against the
923        // `<th>` headings a reader actually sees.
924        let mut keys: Vec<&str> = rows[0].keys().map(|k| k.as_str()).collect();
925        keys.sort_unstable();
926        assert_eq!(keys, vec!["artifact", "repository", "version"], "the row grew or lost a column");
927    }
928
929    /// ★ **`login: null` is sent, not omitted.** The page must be able to tell
930    /// a server that HAS no browser login from a server too old to have the
931    /// field — they need different sentences, and a skipped field makes them
932    /// the same byte sequence.
933    #[test]
934    fn a_server_with_no_login_says_so_rather_than_saying_nothing() {
935        let f = Front::anonymous(None);
936        let body = serde_json::to_string(&f).unwrap();
937        assert!(body.contains("\"login\":null"), "{body}");
938        assert!(!body.contains("base_url"), "an absent base URL should not be sent at all: {body}");
939    }
940
941    /// The refusal the page prints carries the registry's code, and the page
942    /// and the Rust say the same words — one sentence, two places, held
943    /// together by this.
944    #[test]
945    fn the_no_login_refusal_is_the_same_sentence_in_the_page_and_in_the_code() {
946        let r = Front::no_login_refusal();
947        assert!(r.starts_with(ec::FRONT_NO_BROWSER_LOGIN.code), "{r}");
948        assert!(PAGE.contains(&r), "the page's sentence has drifted from Front::no_login_refusal():\n{r}");
949    }
950
951    /// Every code this door and its page can show is a row in the FROZEN
952    /// registry. A refusal with a number nobody allocated is a refusal nobody
953    /// can look up.
954    #[test]
955    fn every_code_the_page_prints_is_in_the_registry() {
956        for code in [
957            ec::FRONT_METHOD_NOT_ALLOWED,
958            ec::FRONT_NO_BROWSER_LOGIN,
959            ec::FRONT_READS_GATED,
960            ec::FRONT_DOOR_ABSENT,
961        ] {
962            assert_eq!(code.subsystem, "front");
963            assert!(holger_errcode::ALL.iter().any(|c| c.code == code.code), "{} is not in ALL", code.code);
964        }
965    }
966
967    /// ★ **Every sentence the page prints is the sentence this crate builds.**
968    /// The page raises three refusals in JavaScript, where nothing checks
969    /// them; this is the check. A word changed on either side fails here,
970    /// naming which.
971    #[test]
972    fn the_pages_refusals_are_the_ones_this_crate_words() {
973        for (code, sentence) in page_refusals() {
974            assert!(sentence.starts_with(code.code), "{sentence}");
975            assert!(
976                PAGE.contains(&sentence),
977                "the page has drifted from the wording of {}:\n  expected: {sentence}",
978                code.code
979            );
980        }
981    }
982
983    /// ★ **No gunnar sentence survived the copy.** The page's shape is
984    /// gunnar's login page and its words must be holger's; a page that says
985    /// gunnar's sentences under holger's logo is worse than one that says
986    /// nothing. This is the test that catches a paste.
987    ///
988    /// `vetra` and `ignalina` were on this list until 2026-09-17 and are
989    /// DELIBERATELY off it now. They were never gunnar's words — they are the
990    /// two companies that make both products, and the credit line is now
991    /// gunnar's block verbatim BY INSTRUCTION, marks and all, so the estate's
992    /// two fronts credit their makers identically instead of one crediting
993    /// companies and the other listing two people and a repository URL. What
994    /// this test exists to catch is a gunnar SENTENCE arriving under holger's
995    /// logo; a shared maker is not that, and keeping them here would make the
996    /// guard fail on the one paste that was asked for.
997    #[test]
998    ///
999    /// `passkey` and `webauthn` were on this list until 2026-09-20 and are off
1000    /// it now for a reason that is not a relaxation: they were banned because
1001    /// holger-server HAD no browser session, so either word on this page could
1002    /// only have arrived by paste. The console's ceremony now runs ON this page
1003    /// ([`Door::Here`]), so "passkey" is holger's own word for holger's own
1004    /// door. `gunnar`, `badger` and `git server` stay: those describe a
1005    /// different product and can still only arrive by paste.
1006    #[test]
1007    fn the_page_says_nothing_about_gunnar() {
1008        let lower = PAGE.to_lowercase();
1009        for word in ["gunnar", "badger", "git server"] {
1010            assert!(!lower.contains(word), "the page still says `{word}`");
1011        }
1012    }
1013
1014    /// ★ **The credit names the makers and serves their marks from HERE.** The
1015    /// page carried "Rickard Lundin & Henrik Torp · codeberg.org/nordisk/holger"
1016    /// until 2026-09-17; it now carries gunnar's block. Both marks are compiled
1017    /// in and routed by this door, because a credit whose images 404 is worse
1018    /// than a credit in plain text.
1019    #[test]
1020    fn the_credit_names_both_makers_and_this_door_serves_their_marks() {
1021        assert!(PAGE.contains("Vetra AB"), "the credit does not name Vetra AB");
1022        assert!(PAGE.contains("Ignalina ApS"), "the credit does not name Ignalina ApS");
1023        assert!(!PAGE.contains("Rickard"), "the old people-and-repo credit is still here");
1024        assert!(!PAGE.contains("codeberg.org/nordisk/holger"), "the old repo link is still here");
1025        for path in [VETRA_PATH, IGNALINA_PATH] {
1026            assert!(PAGE.contains(path), "the page does not reference {path}");
1027            let (f, rel) = doors();
1028            let d = Doors { front: &f, catalogue: &rel };
1029            let r = route("GET", path, &d).unwrap_or_else(|| panic!("{path} is not served"));
1030            assert_eq!(r.status, 200, "{path} answered {}", r.status);
1031            assert!(!r.body.is_empty(), "{path} served an empty body");
1032        }
1033    }
1034
1035    /// ★ **No price, no currency, no amount.** The console's discipline,
1036    /// carried over: holger's front page states what is published, and every
1037    /// number on it came from the server.
1038    #[test]
1039    fn the_page_names_no_price_and_no_currency() {
1040        for token in ["€", "$", "£", "kr", "SEK", "EUR", "USD", "/month", "per month", "free tier", "pricing"] {
1041            assert!(!PAGE.contains(token), "the page carries `{token}`");
1042        }
1043    }
1044
1045    /// The page says what holger is, in the repository's own words, and names
1046    /// the product it is a front for.
1047    #[test]
1048    fn the_page_is_holgers() {
1049        assert!(PAGE.contains("<h1>Holger</h1>"), "the page does not name the product");
1050        assert!(PAGE.contains("Immutable artifact repository"), "the tagline is not the readme's");
1051        assert!(PAGE.contains("Latest releases"), "the list is not named");
1052        // ★ The three columns, in the order a reader sees them. The JSON is
1053        // alphabetical and says nothing about this; the markup is where the
1054        // order lives, so the markup is where it is checked.
1055        let mut at = 0usize;
1056        for col in ["Repository", "Artifact", "Version"] {
1057            let head = format!(">{col}</th>");
1058            let found = PAGE.find(&head).unwrap_or_else(|| panic!("the `{col}` column heading is missing"));
1059            assert!(found > at, "the columns are out of order at `{col}`");
1060            at = found;
1061        }
1062        // …and the script fills them in that same order, so the heading and
1063        // the cell under it are the same field.
1064        let script = PAGE.split("<script>").nth(1).unwrap_or("");
1065        assert!(
1066            script.contains("[['repository', ''], ['artifact', ''], ['version', 'version']]"),
1067            "the script no longer fills the columns in the order the headings promise"
1068        );
1069    }
1070
1071    /// The picture is on the LEFT: `.art` is the first child of `<body>` and
1072    /// takes the first half of a row. A `flex-direction: row-reverse` or a
1073    /// reordered body would move it, and it is one line either way — so it is
1074    /// asserted rather than trusted.
1075    #[test]
1076    fn the_picture_is_on_the_left() {
1077        let body = PAGE.split("<body>").nth(1).expect("the page has a body");
1078        let art = body.find("class=\"art\"").expect("the page has the picture half");
1079        let side = body.find("class=\"side\"").expect("the page has the column");
1080        assert!(art < side, "the picture is not the first half of the page");
1081        assert!(!PAGE.contains("row-reverse"), "something reversed the row and put the picture on the right");
1082        assert!(PAGE.contains(".art {\n    flex: 0 0 50%;"), "the picture no longer owns a half");
1083    }
1084
1085    /// ★ **The page computes no version ordering.** The one rule of this
1086    /// crate, checked against the page's own script: no sort, no compare, no
1087    /// localeCompare. The server ranks; the page prints.
1088    #[test]
1089    fn the_page_does_not_rank_anything() {
1090        let script = PAGE.split("<script>").nth(1).unwrap_or("");
1091        for banned in [".sort(", "localeCompare", "parseFloat", "parseInt"] {
1092            assert!(!script.contains(banned), "the page's script carries `{banned}` — it is deciding something");
1093        }
1094    }
1095
1096    /// Nothing is fetched from a third party. This server runs airgapped by
1097    /// design; a font or a script from a CDN would be a front page that is
1098    /// blank in exactly the deployment holger exists for.
1099    #[test]
1100    fn the_page_fetches_nothing_from_outside() {
1101        for scheme in ["http://", "//cdn", "googleapis", "cdnjs", "jsdelivr", "unpkg"] {
1102            assert!(!PAGE.contains(scheme), "the page reaches out to `{scheme}`");
1103        }
1104        // The one external link is the forge the source is published from, and
1105        // it is a link a reader clicks — not something the page loads.
1106        assert_eq!(PAGE.matches("https://").count(), 1, "the page names more than one external URL");
1107    }
1108
1109    // ── the third state ──────────────────────────────────────────────────────
1110
1111    /// ★★ **THE BUTTON SAYS "Log in with passkey" AND THE CEREMONY IS HERE.**
1112    ///
1113    /// The owner's words, twice: the same label as gunnar's, and no second page
1114    /// that shows the same thing again. Both halves are asserted, because
1115    /// either one alone is the bug that was shipped — a label over a
1116    /// navigation, or a ceremony behind a button that says "Open the console".
1117    #[test]
1118    fn the_ceremony_door_is_labelled_and_wired_to_this_page() {
1119        let login = passkey_login_here(
1120            "/auth/passkey/login/start",
1121            "/auth/passkey/login/finish",
1122            "/overview",
1123        )
1124        .expect("three rooted paths are a ceremony");
1125        assert_eq!(login.label, PASSKEY_LABEL);
1126        assert_eq!(login.label, "Log in with passkey");
1127        assert_eq!(login.door, Door::Here);
1128        assert_eq!(login.finish.as_deref(), Some("/auth/passkey/login/finish"));
1129        assert_eq!(login.next.as_deref(), Some("/overview"));
1130
1131        // …and it goes over the wire naming its own state, because the page
1132        // branches on that word and nothing else.
1133        let body = serde_json::to_string(&Front {
1134            base_url: None,
1135            login: Some(login),
1136        })
1137        .unwrap();
1138        assert!(body.contains(r#""door":"here""#), "{body}");
1139    }
1140
1141    /// The link door is unchanged and still says so on the wire. The appliance
1142    /// and any self-hoster with a console elsewhere on the origin keep exactly
1143    /// the page they had.
1144    #[test]
1145    fn a_link_door_still_says_link_and_carries_no_ceremony() {
1146        let login = console_login("/login?next=/overview").unwrap();
1147        assert_eq!(login.door, Door::Link);
1148        assert_eq!(login.label, CONSOLE_LABEL);
1149        assert!(login.finish.is_none() && login.next.is_none());
1150        let body = serde_json::to_string(&login).unwrap();
1151        assert!(body.contains(r#""door":"link""#), "{body}");
1152        assert!(!body.contains("finish"), "a link door sent a ceremony field: {body}");
1153    }
1154
1155    /// A `/-/front` document written before `door` existed still reads, and
1156    /// reads as the link it was. The field defaults rather than failing, which
1157    /// is the whole reason it is a field on a struct and not a tag on an enum.
1158    #[test]
1159    fn a_document_from_before_this_field_is_still_a_link() {
1160        let old = r#"{"label":"Open the console","start":"/login"}"#;
1161        let login: Login = serde_json::from_str(old).unwrap();
1162        assert_eq!(login.door, Door::Link);
1163    }
1164
1165    /// ★ **All three paths go through one rule, and one bad path is no door.**
1166    ///
1167    /// A half-wired ceremony — a start on this origin and a finish somewhere
1168    /// else — is not a door with a bug in it. It is the assertion being posted
1169    /// to a stranger.
1170    #[test]
1171    fn one_off_site_path_refuses_the_whole_ceremony() {
1172        let good = ("/auth/passkey/login/start", "/auth/passkey/login/finish", "/overview");
1173        assert!(passkey_login_here(good.0, good.1, good.2).is_some());
1174        for bad in ["https://evil.example.com/finish", "//evil.example.com", "/\\evil", "", "relative"] {
1175            assert!(passkey_login_here(bad, good.1, good.2).is_none(), "start {bad:?}");
1176            assert!(passkey_login_here(good.0, bad, good.2).is_none(), "finish {bad:?}");
1177            assert!(passkey_login_here(good.0, good.1, bad).is_none(), "next {bad:?}");
1178        }
1179    }
1180
1181    /// ★ **The page holds the ceremony itself.** The three calls a WebAuthn
1182    /// login is made of are in the document, so the button cannot be a
1183    /// navigation dressed up as one.
1184    #[test]
1185    fn the_page_carries_the_ceremony_and_not_a_second_page() {
1186        let script = PAGE.split("<script>").nth(1).unwrap_or("");
1187        assert!(
1188            script.contains("navigator.credentials.get"),
1189            "the page does not call the authenticator — the button is still a link"
1190        );
1191        assert!(script.contains("allowCredentials"), "the page does not decode the challenge list");
1192        assert!(script.contains("clientDataJSON"), "the page does not send the assertion");
1193        // The LABEL is deliberately not in the page. It is the server's word —
1194        // `/-/front` names it and the page prints what it is handed — which is
1195        // why the button could say "Open the console" over a ceremony if the
1196        // two halves were decided in two places. They are decided in one:
1197        // `PASSKEY_LABEL` is set by `passkey_login_here` and by nothing else,
1198        // and `the_ceremony_door_is_labelled_and_wired_to_this_page` is where
1199        // the words are held.
1200        assert!(
1201            !PAGE.contains("Open the console"),
1202            "the page hard-codes a label; the server names the button"
1203        );
1204    }
1205
1206    /// ★★ **THE FIRST KEY CAN BE ENROLLED ON THIS PAGE.**
1207    ///
1208    /// MEASURED on holger.rs 2026-09-20: the ceremony shipped, the owner
1209    /// pressed the button, and the door answered "no passkey is registered
1210    /// yet". True, and a dead end. The panel is the other half.
1211    #[test]
1212    fn the_enrolment_panel_is_offered_when_the_server_serves_it() {
1213        let login = passkey_login_here("/a/start", "/a/finish", "/overview").unwrap();
1214        assert!(login.enrol.is_none(), "enrolment is opt-in, never implied");
1215        let with = with_enrolment(login, "/r/start", "/r/finish", "/auth/status");
1216        let e = with.enrol.as_ref().expect("three rooted paths are a panel");
1217        assert_eq!(e.start, "/r/start");
1218        assert_eq!(e.finish, "/r/finish");
1219        assert_eq!(e.status, "/auth/status");
1220        let body = serde_json::to_string(&with).unwrap();
1221        assert!(body.contains(r#""enrol""#), "{body}");
1222    }
1223
1224    /// ★ **A LINK door never grows an enrolment form, and neither does a
1225    /// half-configured one.** The appliance serves this same page with no
1226    /// `/auth/*` at all; a panel there would be a form nothing can answer.
1227    #[test]
1228    fn a_link_door_and_a_bad_path_both_refuse_the_panel() {
1229        let link = console_login("/login").unwrap();
1230        let still = with_enrolment(link, "/r/start", "/r/finish", "/auth/status");
1231        assert!(still.enrol.is_none(), "a link door was given an enrolment panel");
1232        assert!(
1233            !serde_json::to_string(&still).unwrap().contains("enrol"),
1234            "a link door sent an enrolment field"
1235        );
1236
1237        let here = passkey_login_here("/a/start", "/a/finish", "/overview").unwrap();
1238        for bad in ["https://evil.example.com/r", "//evil", "/\\evil", "", "relative"] {
1239            assert!(with_enrolment(here.clone(), bad, "/r/finish", "/s").enrol.is_none(), "start {bad:?}");
1240            assert!(with_enrolment(here.clone(), "/r/start", bad, "/s").enrol.is_none(), "finish {bad:?}");
1241            assert!(with_enrolment(here.clone(), "/r/start", "/r/finish", bad).enrol.is_none(), "status {bad:?}");
1242        }
1243    }
1244
1245    /// The page carries the enrolment half of WebAuthn, the panel ships
1246    /// collapsed AND hidden, and the invite is taken out of the URL.
1247    #[test]
1248    fn the_page_carries_the_enrolment_and_hides_it_until_the_server_offers_it() {
1249        let script = PAGE.split("<script>").nth(1).unwrap_or("");
1250        assert!(script.contains("navigator.credentials.create"), "the page cannot mint a key");
1251        assert!(script.contains("attestationObject"), "the page does not send the attestation");
1252        assert!(script.contains("bootstrap_spent"), "the page never retires the break-glass door");
1253        assert!(script.contains("history.replaceState"), "the invite is left in the address bar");
1254        assert!(
1255            PAGE.contains(r#"<details class="reg" id="reg" hidden>"#),
1256            "the enrolment panel does not ship hidden, so a server without /auth/* would draw it"
1257        );
1258        assert!(
1259            !PAGE.contains("<details class=\"reg\" id=\"reg\" open"),
1260            "the enrolment panel ships open; it is the rarer act and belongs collapsed"
1261        );
1262    }
1263
1264    // ── the roster ───────────────────────────────────────────────────────────
1265
1266    /// ★ **A server with repositories and nothing published still has a
1267    /// catalogue.** The state holger.rs is in: an empty array said "nothing
1268    /// here" about a server that has repositories a stranger can use.
1269    #[test]
1270    fn an_empty_store_still_names_its_repositories() {
1271        let f = Front::anonymous(None);
1272        let c = Catalogue {
1273            repositories: vec![
1274                Repository { name: "crates-mirror".into(), format: "cargo".into(), packages: 0 },
1275            ],
1276            releases: vec![],
1277        };
1278        let d = Doors { front: &f, catalogue: &c };
1279        let body = String::from_utf8(route("GET", RELEASES_PATH, &d).unwrap().body).unwrap();
1280        let doc: serde_json::Value = serde_json::from_str(&body).unwrap();
1281        assert_eq!(doc["repositories"].as_array().unwrap().len(), 1);
1282        assert_eq!(doc["repositories"][0]["name"], "crates-mirror");
1283        assert_eq!(doc["repositories"][0]["format"], "cargo");
1284        assert_eq!(doc["repositories"][0]["packages"], 0);
1285        assert!(doc["releases"].as_array().unwrap().is_empty());
1286    }
1287
1288    /// The page reads the two lists by the names the wire uses, and names the
1289    /// roster for a reader.
1290    #[test]
1291    fn the_page_reads_both_halves_of_the_catalogue() {
1292        let script = PAGE.split("<script>").nth(1).unwrap_or("");
1293        assert!(script.contains("doc.repositories"), "the page ignores the roster");
1294        assert!(script.contains("doc.releases"), "the page ignores the rows");
1295        assert!(PAGE.contains("Public repositories"), "the roster has no heading");
1296    }
1297
1298    /// No `innerHTML` anywhere: a repository name, an artifact name and a
1299    /// version are operator-supplied text arriving over a socket.
1300    #[test]
1301    fn nothing_on_the_page_turns_text_into_markup() {
1302        assert!(!PAGE.contains("innerHTML"), "the page writes markup from data");
1303        assert!(!PAGE.contains("document.write"), "the page uses document.write");
1304    }
1305}