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 three columns: repository, artifact, version |
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/// How a namespaced coordinate becomes the single string in the middle column.
102///
103/// `Some("org.apache.arrow") + "arrow-vector"` -> `org.apache.arrow/arrow-vector`;
104/// `None + "serde"` -> `serde`. An empty namespace is the same as none — some
105/// backends hand back `Some("")` and a leading `/` in the column would be a
106/// visible bug for an invisible cause.
107pub fn artifact_name(namespace: Option<&str>, name: &str) -> String {
108    match namespace {
109        Some(ns) if !ns.is_empty() => format!("{ns}/{name}"),
110        _ => name.to_string(),
111    }
112}
113
114/// Fold every `(repository, artifact, version)` down to the newest version of
115/// each `(repository, artifact)`, using `newer` to compare.
116///
117/// `newer(a, b)` must answer the ordering of `a` against `b` as VERSIONS. Pass
118/// `retention::cmp_version`; do not pass `str::cmp`, which puts `1.9.0` after
119/// `1.10.0` and is the whole reason this is a parameter.
120///
121/// The result is sorted by repository, then artifact — a stable order, so the
122/// page does not reshuffle between two loads of an unchanged server.
123pub fn latest_by<F>(rows: impl IntoIterator<Item = Release>, newer: F) -> Vec<Release>
124where
125    F: Fn(&str, &str) -> Ordering,
126{
127    // A BTreeMap, not a HashMap: the key order IS the output order, so the
128    // sort is free and cannot be forgotten.
129    let mut best: BTreeMap<(String, String), String> = BTreeMap::new();
130    for r in rows {
131        let key = (r.repository, r.artifact);
132        match best.get(&key) {
133            // `Greater` only — a tie keeps the FIRST one seen, so a repository
134            // that somehow lists one version twice does not flap between loads.
135            Some(have) if newer(&r.version, have) != Ordering::Greater => {}
136            _ => {
137                best.insert(key, r.version);
138            }
139        }
140    }
141    best.into_iter()
142        .map(|((repository, artifact), version)| Release { repository, artifact, version })
143        .collect()
144}
145
146// ─────────────────────────────────────────────────────────────────────────────
147// Who this server is
148// ─────────────────────────────────────────────────────────────────────────────
149
150/// The door a browser can actually walk through, if there is one.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct Login {
153    /// What the button says. **The server's word, not the page's.**
154    pub label: String,
155    /// Where the button goes. A same-site rooted path; the page refuses
156    /// anything else, so a misconfigured server cannot turn the button into an
157    /// open redirect.
158    pub start: String,
159}
160
161/// What the button says when the door is the console.
162///
163/// Here rather than in the server for the reason [`Front::no_login_refusal`] is
164/// here: the page prints it verbatim, so it exists in one place and a test can
165/// hold the page to it.
166pub const CONSOLE_LABEL: &str = "Open the console";
167
168/// ★ **The console door, or `None` — and `None` is the answer for anything the
169/// PAGE would refuse to follow.**
170///
171/// The page's button follows only a same-site rooted path (see the `btn.onclick`
172/// guard in `assets/front.html`), which is the open-redirect defence and is not
173/// negotiable. So an operator who points this at `https://console.example.com`
174/// would get a button that is enabled, labelled, and does **nothing at all** on
175/// click — strictly worse than the honest dead button
176/// [`Front::no_login_refusal`] explains, because there is no sentence anywhere
177/// saying why.
178///
179/// This constructor therefore applies the page's own rule **server-side**, and
180/// answers `None` for every value the page would silently drop. The two rules
181/// are asserted equal by
182/// [`the_rust_rule_and_the_pages_rule_refuse_the_same_values`] — one rule, two
183/// languages, held together the way this crate holds its sentences together.
184///
185/// Note what this means for a deployment, because it is a real constraint and
186/// not a detail: **the console must be reachable at a path on THIS server's
187/// origin.** A console on its own host has no rooted path from here, and the
188/// honest answer for it is the one the page already gives.
189pub fn console_login(path: &str) -> Option<Login> {
190    if !same_site_rooted_path(path) {
191        return None;
192    }
193    Some(Login { label: CONSOLE_LABEL.to_string(), start: path.to_string() })
194}
195
196/// The page's `btn.onclick` rule, in Rust.
197///
198/// One leading slash, no authority, and no character the URL parser strips
199/// before it decides what the value even is: `//host` and `/\host` are off-site
200/// in every engine, and a value carrying a space, a tab, a newline or a NUL is
201/// one whose meaning is settled after those are removed.
202fn same_site_rooted_path(path: &str) -> bool {
203    !path.is_empty()
204        && path.starts_with('/')
205        && !path.starts_with("//")
206        && !path.starts_with("/\\")
207        // The page's class is `[\x00-\x20\x7f]` — written in `assets/front.html`
208        // as literal control bytes, which is why that file reads as binary.
209        // `is_ascii_whitespace` is NOT this set (it omits NUL, the other C0
210        // controls and DEL), so the range is spelled out rather than borrowed.
211        && !path.chars().any(|c| (c as u32) <= 0x20 || c as u32 == 0x7f)
212}
213
214/// What `/-/front` answers: everything on the page that is a fact about THIS
215/// server rather than about holger.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct Front {
218    /// The base URL a package manager is pointed at. `None` when the server
219    /// was not told its own public name — printed by the server or not at all,
220    /// because a page that guessed it from `location.origin` would print the
221    /// proxy's address on any deployment behind one.
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub base_url: Option<String>,
224    /// ★ **`None` is an ANSWER, and it is the honest one today.**
225    ///
226    /// holger-server authenticates with mTLS, OIDC and bearer tokens — doors
227    /// for `cargo`, `pip`, `docker` and the native console. There is no
228    /// browser session, so there is no button, and the page says
229    /// [`ec::FRONT_NO_BROWSER_LOGIN`] by name rather than offering one.
230    ///
231    /// It is serialised even when null (no `skip_serializing_if`) precisely so
232    /// the page can tell a server with no login from a server too old to have
233    /// the field.
234    pub login: Option<Login>,
235}
236
237impl Front {
238    /// A server that has told us nothing but its own address.
239    pub fn anonymous(base_url: Option<String>) -> Self {
240        Front { base_url, login: None }
241    }
242
243    /// The refusal the page prints when [`Front::login`] is `None`, by name
244    /// and with its code. Kept here rather than only in the page's JavaScript
245    /// so the words exist in one place and a test can hold them to it.
246    pub fn no_login_refusal() -> String {
247        format!(
248            "{}: this server offers no browser login. Its doors are mTLS, OIDC and bearer — \
249             for cargo, pip, docker and the console, not for a tab.",
250            ec::FRONT_NO_BROWSER_LOGIN.code
251        )
252    }
253}
254
255/// ★ **The three sentences the PAGE prints on its own.**
256///
257/// They are raised in JavaScript, where no Rust compiler can see them and the
258/// frozen registry cannot be consulted — which is exactly how a page ends up
259/// showing a code nobody allocated, or the same failure worded two ways in two
260/// places. So each one is built HERE, from the registry row, and the tests
261/// assert the page carries the string this produces. Change the sentence here
262/// and the test tells you the page has drifted; change it in the page and the
263/// test tells you the same thing.
264///
265/// This is also what makes the rows honest to the wiring guard: a code marked
266/// `wired` must be referenced from the file it claims, and these are the
267/// references.
268pub fn page_refusals() -> [(&'static ec::ErrCode, String); 3] {
269    [
270        (&ec::FRONT_NO_BROWSER_LOGIN, Front::no_login_refusal()),
271        (
272            &ec::FRONT_READS_GATED,
273            format!(
274                "{}: this server gates reads behind a credential, so its front page cannot name \
275                 itself. Configure `require_auth_for_reads: false`, or read the console instead.",
276                ec::FRONT_READS_GATED.code
277            ),
278        ),
279        (
280            &ec::FRONT_DOOR_ABSENT,
281            format!(
282                "{}: this build of holger-server has no `/-/front` door. The page is newer than \
283                 the server behind it.",
284                ec::FRONT_DOOR_ABSENT.code
285            ),
286        ),
287    ]
288}
289
290// ─────────────────────────────────────────────────────────────────────────────
291// The door
292// ─────────────────────────────────────────────────────────────────────────────
293
294/// One answer: a status, a content type, headers that matter, and the bytes.
295#[derive(Debug, Clone, PartialEq)]
296pub struct Reply {
297    pub status: u16,
298    pub content_type: &'static str,
299    /// `Cache-Control`. Named rather than implied: the page and the JSON must
300    /// NOT be cached (a stale release table is a lie about what is published)
301    /// and the picture must be, because it is 300 kB that never changes
302    /// without a redeploy.
303    pub cache_control: &'static str,
304    pub body: Vec<u8>,
305}
306
307impl Reply {
308    fn html(body: &str) -> Reply {
309        Reply {
310            status: 200,
311            content_type: "text/html; charset=utf-8",
312            cache_control: "no-store",
313            body: body.as_bytes().to_vec(),
314        }
315    }
316    fn json(body: String) -> Reply {
317        Reply {
318            status: 200,
319            content_type: "application/json",
320            cache_control: "no-store",
321            body: body.into_bytes(),
322        }
323    }
324    fn webp(bytes: &'static [u8]) -> Reply {
325        Reply {
326            status: 200,
327            content_type: "image/webp",
328            // A year, and immutable: the file's content is compiled into the
329            // binary, so it cannot change without a new binary, and a new
330            // binary is a new deploy.
331            cache_control: "public, max-age=31536000, immutable",
332            body: bytes.to_vec(),
333        }
334    }
335    /// A mark, cached like the picture: compiled into the binary, so it cannot
336    /// change without a new deploy.
337    fn mark(bytes: &'static [u8], content_type: &'static str) -> Reply {
338        Reply {
339            status: 200,
340            content_type,
341            cache_control: "public, max-age=31536000, immutable",
342            body: bytes.to_vec(),
343        }
344    }
345    fn refused(status: u16, code: &ec::ErrCode, detail: &str) -> Reply {
346        Reply {
347            status,
348            content_type: "application/json",
349            cache_control: "no-store",
350            body: serde_json::json!({ "code": code.code, "error": detail }).to_string().into_bytes(),
351        }
352    }
353}
354
355/// What the server must hand the door to answer it.
356///
357/// It is a struct and not four arguments so that adding a fact to the front
358/// page is a field here and a line in the caller, never a new function.
359pub struct Doors<'a> {
360    pub front: &'a Front,
361    /// Already the latest, already ordered. The door does not rank — see the
362    /// module note on why.
363    pub releases: &'a [Release],
364}
365
366/// ★ **The whole public surface, as a pure function.**
367///
368/// `None` means "not mine" — the caller falls through to its own routing, so
369/// mounting this cannot shadow a repository. Every path it DOES own is under
370/// `/` or the already-reserved `/-/` namespace, which can never route into a
371/// repository.
372pub fn route(method: &str, path: &str, doors: &Doors<'_>) -> Option<Reply> {
373    let owned = matches!(
374        path,
375        "/" | PICTURE_PATH | VETRA_PATH | IGNALINA_PATH | FRONT_PATH | RELEASES_PATH
376    );
377    if !owned {
378        return None;
379    }
380
381    // ★ Read-only, and the refusal is by name. A `POST /` that fell through to
382    // the repository router would be a write attempt against a repository
383    // called "" — a 404 that looks like a typo instead of a method that is not
384    // allowed here.
385    if method != "GET" && method != "HEAD" {
386        return Some(Reply::refused(
387            405,
388            &ec::FRONT_METHOD_NOT_ALLOWED,
389            "the front door answers GET and HEAD only",
390        ));
391    }
392
393    let mut reply = match path {
394        "/" => Reply::html(PAGE),
395        PICTURE_PATH => Reply::webp(PICTURE),
396        VETRA_PATH => Reply::mark(VETRA_MARK, "image/svg+xml"),
397        IGNALINA_PATH => Reply::mark(IGNALINA_MARK, "image/png"),
398        FRONT_PATH => Reply::json(
399            serde_json::to_string(doors.front).unwrap_or_else(|_| "{}".to_string()),
400        ),
401        RELEASES_PATH => Reply::json(
402            serde_json::to_string(doors.releases).unwrap_or_else(|_| "[]".to_string()),
403        ),
404        _ => unreachable!("`owned` above is the same list"),
405    };
406    // HEAD is the same answer without the bytes. Written here rather than left
407    // to the caller, because a HEAD that returned the body would be the kind
408    // of thing nothing notices until a health check downloads the picture
409    // every thirty seconds.
410    if method == "HEAD" {
411        reply.body.clear();
412    }
413    Some(reply)
414}
415
416/// The refusal for a server whose reads are gated, so `/-/front` cannot be
417/// read at all. Produced by the CALLER, which is the half that knows about
418/// `require_auth_for_reads`; it lives here so the code and the words are in
419/// the same place as the page that prints them.
420pub fn reads_gated() -> Reply {
421    Reply::refused(
422        403,
423        &ec::FRONT_READS_GATED,
424        "this server gates reads behind a credential, so its front page cannot name itself",
425    )
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    /// A comparator that is WRONG on purpose — plain string order, which puts
433    /// `1.9.0` above `1.10.0`. Used to prove the fold asks the comparator it
434    /// was given and holds no opinion of its own.
435    fn lexicographic(a: &str, b: &str) -> Ordering {
436        a.cmp(b)
437    }
438
439    /// A toy numeric comparator standing in for `retention::cmp_version`, so
440    /// the grouping can be tested without depending on `server/lib`.
441    fn numeric(a: &str, b: &str) -> Ordering {
442        let parts = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
443        parts(a).cmp(&parts(b))
444    }
445
446    fn rel(repo: &str, art: &str, ver: &str) -> Release {
447        Release { repository: repo.into(), artifact: art.into(), version: ver.into() }
448    }
449
450    // ── The console door ─────────────────────────────────────────────────────
451
452    /// A path the page will follow becomes a button, labelled in the server's
453    /// own word.
454    #[test]
455    fn a_rooted_path_becomes_the_console_button() {
456        let login = console_login("/console").expect("a rooted path is a door");
457        assert_eq!(login.start, "/console");
458        assert_eq!(login.label, CONSOLE_LABEL);
459    }
460
461    /// ★ **The Rust rule and the PAGE's rule refuse exactly the same values.**
462    ///
463    /// This is the test the whole constructor exists for. The page's
464    /// `btn.onclick` drops a value it will not follow **silently** — no
465    /// sentence, no code, just a button that does nothing — so a server that
466    /// answered with one would produce the one failure state the front page's
467    /// design is otherwise free of. Rather than trust two prose descriptions of
468    /// one rule, the page's own class is read out of [`PAGE`] and applied here.
469    ///
470    /// It is spelled `\x00-\x20` plus `\x7f`, and since 2026-09-20 it is
471    /// written in the HTML as JS ESCAPES and not as literal control bytes.
472    /// That is not a style preference, it is the fix for a page that hung:
473    /// the literal NUL made `assets/front.html` read as binary to `file(1)`,
474    /// and in a browser the HTML tokenizer's script-data state replaces a
475    /// U+0000 with U+FFFD, so the engine parsed `/[\u{fffd}-\u{20}\u{7f}]/`,
476    /// refused it as "range out of order in character class", and threw away
477    /// the WHOLE inline script — the `/-/front` fetch with it. The page then
478    /// sat on its literal `reading the server …` with the button still
479    /// `disabled` for ever. [`the_page_carries_no_raw_control_bytes`] is the
480    /// guard that keeps a literal from coming back.
481    #[test]
482    fn the_rust_rule_and_the_pages_rule_refuse_the_same_values() {
483        // The page still carries the guard this mirrors. If the button's
484        // handler is rewritten, this test must be re-read, not re-blessed.
485        assert!(
486            PAGE.contains("if (!start.startsWith('/') || start.startsWith('//') || start.startsWith('/\\\\')) return;"),
487            "the page's same-site guard has moved; console_login may no longer mirror it"
488        );
489        assert!(
490            PAGE.contains(r"if (/[\x00-\x20\x7f]/.test(start)) return;"),
491            "the page's control-character class has changed; same_site_rooted_path is now a second opinion"
492        );
493
494        for refused in [
495            "",                            // the page returns early on empty
496            "https://console.example.com", // an off-site URL — the tempting mistake
497            "console",                     // a relative path is not rooted
498            "//evil.example.com",          // protocol-relative: off-site everywhere
499            "/\\evil.example.com",         // backslash authority: off-site everywhere
500            "/console\u{0}",               // NUL
501            "/console\u{9}",               // tab
502            "/console\u{a}",               // newline
503            "/con sole",                   // space
504            "/console\u{7f}",              // DEL — the one the first draft missed
505        ] {
506            assert!(
507                console_login(refused).is_none(),
508                "{refused:?} must not become a button: the page would drop it in silence"
509            );
510        }
511    }
512
513    /// An unconfigured console leaves the page exactly as it is today — the
514    /// named refusal, not a button to nowhere. A self-hoster who runs no
515    /// console must not be offered one.
516    #[test]
517    fn no_console_configured_is_still_the_named_refusal() {
518        let f = Front { base_url: None, login: None };
519        let body = serde_json::to_string(&f).unwrap();
520        assert!(body.contains("\"login\":null"), "{body}");
521        assert!(Front::no_login_refusal().starts_with(ec::FRONT_NO_BROWSER_LOGIN.code));
522    }
523
524    /// ★ **The fold has no opinion about versions.** The same input with two
525    /// comparators gives two different answers — which is the proof that the
526    /// ranking comes from the caller and not from this crate.
527    #[test]
528    fn the_comparator_decides_and_this_crate_does_not() {
529        let rows = vec![rel("crates", "serde", "1.9.0"), rel("crates", "serde", "1.10.0")];
530        assert_eq!(latest_by(rows.clone(), numeric)[0].version, "1.10.0");
531        assert_eq!(latest_by(rows, lexicographic)[0].version, "1.9.0");
532    }
533
534    /// One row per `(repository, artifact)`, and the same artifact name in two
535    /// repositories is two rows — a mirror and a local store legitimately hold
536    /// different versions of `serde`, and collapsing them would hide it.
537    #[test]
538    fn one_row_per_repository_and_artifact() {
539        let out = latest_by(
540            vec![
541                rel("crates", "serde", "1.0.1"),
542                rel("crates", "serde", "1.0.9"),
543                rel("mirror", "serde", "1.0.4"),
544                rel("crates", "tokio", "1.2.0"),
545            ],
546            numeric,
547        );
548        assert_eq!(out.len(), 3);
549        assert_eq!(
550            out.iter().map(|r| (r.repository.as_str(), r.artifact.as_str(), r.version.as_str())).collect::<Vec<_>>(),
551            vec![("crates", "serde", "1.0.9"), ("crates", "tokio", "1.2.0"), ("mirror", "serde", "1.0.4")]
552        );
553    }
554
555    /// The order is stable and does not depend on the order rows arrived in.
556    /// A page that reshuffled between two loads of an unchanged server would
557    /// read as a server that is changing.
558    #[test]
559    fn the_order_does_not_depend_on_the_input_order() {
560        let a = vec![rel("b", "y", "1"), rel("a", "z", "1"), rel("a", "y", "1")];
561        let mut b = a.clone();
562        b.reverse();
563        assert_eq!(latest_by(a, numeric), latest_by(b, numeric));
564    }
565
566    #[test]
567    fn a_namespace_joins_the_name_with_one_slash_and_an_empty_one_does_not() {
568        assert_eq!(artifact_name(Some("org.apache.arrow"), "arrow-vector"), "org.apache.arrow/arrow-vector");
569        assert_eq!(artifact_name(Some("@scope"), "pkg"), "@scope/pkg");
570        assert_eq!(artifact_name(None, "serde"), "serde");
571        assert_eq!(artifact_name(Some(""), "serde"), "serde", "an empty namespace put a slash on the front");
572    }
573
574    // ── the door ─────────────────────────────────────────────────────────────
575
576    fn doors() -> (Front, Vec<Release>) {
577        (Front::anonymous(Some("https://holger.rs".into())), vec![rel("bundles", "site-a", "3")])
578    }
579
580    #[test]
581    fn the_page_is_served_at_the_root_and_is_not_cached() {
582        let (f, r) = doors();
583        let d = Doors { front: &f, releases: &r };
584        let reply = route("GET", "/", &d).expect("the root is this door's");
585        assert_eq!(reply.status, 200);
586        assert_eq!(reply.content_type, "text/html; charset=utf-8");
587        assert_eq!(reply.cache_control, "no-store", "the page must not be cached");
588        assert_eq!(reply.body, PAGE.as_bytes());
589    }
590
591    /// The picture is cached hard, because it cannot change without a new
592    /// binary — and the JSON is not, because it changes whenever anybody
593    /// publishes.
594    #[test]
595    fn the_picture_is_cached_forever_and_the_json_never() {
596        let (f, r) = doors();
597        let d = Doors { front: &f, releases: &r };
598        let pic = route("GET", PICTURE_PATH, &d).unwrap();
599        assert_eq!(pic.content_type, "image/webp");
600        assert!(pic.cache_control.contains("immutable"), "{}", pic.cache_control);
601        for p in [FRONT_PATH, RELEASES_PATH] {
602            assert_eq!(route("GET", p, &d).unwrap().cache_control, "no-store", "{p} was cacheable");
603        }
604    }
605
606    /// ★ **The picture the page names is the picture the door serves.** The
607    /// path is written twice — once in CSS, once as a constant — and this is
608    /// what keeps the two from drifting into a broken left half that nobody
609    /// notices because the layout still works.
610    #[test]
611    fn the_page_asks_for_the_picture_this_door_serves() {
612        assert!(
613            PAGE.contains(&format!("url(\"{PICTURE_PATH}\")")),
614            "the page's --backdrop-image does not name {PICTURE_PATH}"
615        );
616        assert!(PAGE.contains(RELEASES_PATH), "the page does not fetch {RELEASES_PATH}");
617        assert!(PAGE.contains(FRONT_PATH), "the page does not fetch {FRONT_PATH}");
618    }
619
620    /// ★ **The page carries no raw control byte, because one of them silently
621    /// deleted the whole inline script.**
622    ///
623    /// MEASURED 2026-09-20 on the live holger.rs: `assets/front.html` held a
624    /// literal U+0000 inside the `btn.onclick` guard's regex. `curl` saw a
625    /// perfectly good 23 115-byte document and `/-/front` answered 200; a
626    /// browser did not. The HTML tokenizer's script-data state turns U+0000
627    /// into U+FFFD before the JS engine ever sees it, so the engine was handed
628    /// `/[\u{fffd}-\u{20}\u{7f}]/`, whose range runs backwards. That is an
629    /// early SyntaxError, which kills the ENTIRE `<script>` element — the
630    /// `/-/front` fetch, the button's label, the button's `disabled = false`,
631    /// all of it — and leaves the page showing its own literal placeholder
632    /// `reading the server …` next to a grey button that cannot be pressed.
633    ///
634    /// The class is therefore spelled with escapes now, and this test is the
635    /// reason a literal cannot come back. `\t`, `\n` and `\r` are the three
636    /// bytes a text document is allowed to contain.
637    #[test]
638    fn the_page_carries_no_raw_control_bytes() {
639        for (i, b) in PAGE.bytes().enumerate() {
640            let allowed = matches!(b, b'\t' | b'\n' | b'\r');
641            assert!(
642                allowed || !(b.is_ascii_control() || b == 0x7f),
643                "assets/front.html carries a raw control byte {b:#04x} at offset {i}; \
644                 a browser replaces U+0000 with U+FFFD in script data and throws the whole \
645                 inline script away. Write it as a JS escape."
646            );
647        }
648    }
649
650    /// The picture really is a WebP, and really is the one that was converted
651    /// — not a PNG somebody renamed.
652    #[test]
653    fn the_compiled_in_picture_is_a_webp() {
654        assert!(PICTURE.len() > 12, "the picture is empty — run `holger-ops logo`");
655        assert_eq!(&PICTURE[0..4], b"RIFF", "not a RIFF container");
656        assert_eq!(&PICTURE[8..12], b"WEBP", "not a WebP");
657    }
658
659    /// ★ **Nothing but this door's own four paths is claimed.** A door that
660    /// answered `/crates-mirror` would shadow a repository, and it would do it
661    /// silently — the repository would simply stop existing.
662    #[test]
663    fn a_repository_path_is_never_this_doors() {
664        let (f, r) = doors();
665        let d = Doors { front: &f, releases: &r };
666        for p in ["/crates-mirror", "/v2/alpine/manifests/latest", "/-/search", "/healthz", "/bundles/x", ""] {
667            assert!(route("GET", p, &d).is_none(), "{p} was claimed by the front door");
668        }
669    }
670
671    /// A write against the front door is refused BY NAME with a code, not
672    /// dropped into the repository router where it becomes a confusing 404.
673    #[test]
674    fn a_write_is_refused_by_name_with_its_code() {
675        let (f, r) = doors();
676        let d = Doors { front: &f, releases: &r };
677        for m in ["POST", "PUT", "DELETE", "PATCH"] {
678            let reply = route(m, "/", &d).unwrap();
679            assert_eq!(reply.status, 405, "{m}");
680            let body = String::from_utf8(reply.body).unwrap();
681            assert!(body.contains(ec::FRONT_METHOD_NOT_ALLOWED.code), "{m}: {body}");
682        }
683    }
684
685    #[test]
686    fn head_answers_the_same_thing_without_the_bytes() {
687        let (f, r) = doors();
688        let d = Doors { front: &f, releases: &r };
689        for p in ["/", PICTURE_PATH, FRONT_PATH, RELEASES_PATH] {
690            let get = route("GET", p, &d).unwrap();
691            let head = route("HEAD", p, &d).unwrap();
692            assert_eq!(head.status, get.status);
693            assert_eq!(head.content_type, get.content_type);
694            assert!(head.body.is_empty(), "HEAD {p} carried a body");
695        }
696    }
697
698    /// The three columns go over the wire under the names the page reads, and
699    /// there is no fourth.
700    #[test]
701    fn the_wire_carries_exactly_three_columns() {
702        let (f, r) = doors();
703        let d = Doors { front: &f, releases: &r };
704        let body = String::from_utf8(route("GET", RELEASES_PATH, &d).unwrap().body).unwrap();
705        let rows: Vec<serde_json::Map<String, serde_json::Value>> = serde_json::from_str(&body).unwrap();
706        assert_eq!(rows.len(), 1);
707        // A SET, not a sequence: `serde_json::Map` is a BTreeMap without the
708        // `preserve_order` feature, so the wire order is alphabetical and
709        // asserting declaration order here would only be asserting serde's
710        // build configuration. The COLUMN order is the page's, and it is
711        // checked where it lives — in `the_page_is_holgers`, against the
712        // `<th>` headings a reader actually sees.
713        let mut keys: Vec<&str> = rows[0].keys().map(|k| k.as_str()).collect();
714        keys.sort_unstable();
715        assert_eq!(keys, vec!["artifact", "repository", "version"], "the row grew or lost a column");
716    }
717
718    /// ★ **`login: null` is sent, not omitted.** The page must be able to tell
719    /// a server that HAS no browser login from a server too old to have the
720    /// field — they need different sentences, and a skipped field makes them
721    /// the same byte sequence.
722    #[test]
723    fn a_server_with_no_login_says_so_rather_than_saying_nothing() {
724        let f = Front::anonymous(None);
725        let body = serde_json::to_string(&f).unwrap();
726        assert!(body.contains("\"login\":null"), "{body}");
727        assert!(!body.contains("base_url"), "an absent base URL should not be sent at all: {body}");
728    }
729
730    /// The refusal the page prints carries the registry's code, and the page
731    /// and the Rust say the same words — one sentence, two places, held
732    /// together by this.
733    #[test]
734    fn the_no_login_refusal_is_the_same_sentence_in_the_page_and_in_the_code() {
735        let r = Front::no_login_refusal();
736        assert!(r.starts_with(ec::FRONT_NO_BROWSER_LOGIN.code), "{r}");
737        assert!(PAGE.contains(&r), "the page's sentence has drifted from Front::no_login_refusal():\n{r}");
738    }
739
740    /// Every code this door and its page can show is a row in the FROZEN
741    /// registry. A refusal with a number nobody allocated is a refusal nobody
742    /// can look up.
743    #[test]
744    fn every_code_the_page_prints_is_in_the_registry() {
745        for code in [
746            ec::FRONT_METHOD_NOT_ALLOWED,
747            ec::FRONT_NO_BROWSER_LOGIN,
748            ec::FRONT_READS_GATED,
749            ec::FRONT_DOOR_ABSENT,
750        ] {
751            assert_eq!(code.subsystem, "front");
752            assert!(holger_errcode::ALL.iter().any(|c| c.code == code.code), "{} is not in ALL", code.code);
753        }
754    }
755
756    /// ★ **Every sentence the page prints is the sentence this crate builds.**
757    /// The page raises three refusals in JavaScript, where nothing checks
758    /// them; this is the check. A word changed on either side fails here,
759    /// naming which.
760    #[test]
761    fn the_pages_refusals_are_the_ones_this_crate_words() {
762        for (code, sentence) in page_refusals() {
763            assert!(sentence.starts_with(code.code), "{sentence}");
764            assert!(
765                PAGE.contains(&sentence),
766                "the page has drifted from the wording of {}:\n  expected: {sentence}",
767                code.code
768            );
769        }
770    }
771
772    /// ★ **No gunnar sentence survived the copy.** The page's shape is
773    /// gunnar's login page and its words must be holger's; a page that says
774    /// gunnar's sentences under holger's logo is worse than one that says
775    /// nothing. This is the test that catches a paste.
776    ///
777    /// `vetra` and `ignalina` were on this list until 2026-09-17 and are
778    /// DELIBERATELY off it now. They were never gunnar's words — they are the
779    /// two companies that make both products, and the credit line is now
780    /// gunnar's block verbatim BY INSTRUCTION, marks and all, so the estate's
781    /// two fronts credit their makers identically instead of one crediting
782    /// companies and the other listing two people and a repository URL. What
783    /// this test exists to catch is a gunnar SENTENCE arriving under holger's
784    /// logo; a shared maker is not that, and keeping them here would make the
785    /// guard fail on the one paste that was asked for.
786    #[test]
787    fn the_page_says_nothing_about_gunnar() {
788        let lower = PAGE.to_lowercase();
789        for word in ["gunnar", "badger", "git server", "passkey", "webauthn"] {
790            assert!(!lower.contains(word), "the page still says `{word}`");
791        }
792    }
793
794    /// ★ **The credit names the makers and serves their marks from HERE.** The
795    /// page carried "Rickard Lundin & Henrik Torp · codeberg.org/nordisk/holger"
796    /// until 2026-09-17; it now carries gunnar's block. Both marks are compiled
797    /// in and routed by this door, because a credit whose images 404 is worse
798    /// than a credit in plain text.
799    #[test]
800    fn the_credit_names_both_makers_and_this_door_serves_their_marks() {
801        assert!(PAGE.contains("Vetra AB"), "the credit does not name Vetra AB");
802        assert!(PAGE.contains("Ignalina ApS"), "the credit does not name Ignalina ApS");
803        assert!(!PAGE.contains("Rickard"), "the old people-and-repo credit is still here");
804        assert!(!PAGE.contains("codeberg.org/nordisk/holger"), "the old repo link is still here");
805        for path in [VETRA_PATH, IGNALINA_PATH] {
806            assert!(PAGE.contains(path), "the page does not reference {path}");
807            let (f, rel) = doors();
808            let d = Doors { front: &f, releases: &rel };
809            let r = route("GET", path, &d).unwrap_or_else(|| panic!("{path} is not served"));
810            assert_eq!(r.status, 200, "{path} answered {}", r.status);
811            assert!(!r.body.is_empty(), "{path} served an empty body");
812        }
813    }
814
815    /// ★ **No price, no currency, no amount.** The console's discipline,
816    /// carried over: holger's front page states what is published, and every
817    /// number on it came from the server.
818    #[test]
819    fn the_page_names_no_price_and_no_currency() {
820        for token in ["€", "$", "£", "kr", "SEK", "EUR", "USD", "/month", "per month", "free tier", "pricing"] {
821            assert!(!PAGE.contains(token), "the page carries `{token}`");
822        }
823    }
824
825    /// The page says what holger is, in the repository's own words, and names
826    /// the product it is a front for.
827    #[test]
828    fn the_page_is_holgers() {
829        assert!(PAGE.contains("<h1>Holger</h1>"), "the page does not name the product");
830        assert!(PAGE.contains("Immutable artifact repository"), "the tagline is not the readme's");
831        assert!(PAGE.contains("Latest releases"), "the list is not named");
832        // ★ The three columns, in the order a reader sees them. The JSON is
833        // alphabetical and says nothing about this; the markup is where the
834        // order lives, so the markup is where it is checked.
835        let mut at = 0usize;
836        for col in ["Repository", "Artifact", "Version"] {
837            let head = format!(">{col}</th>");
838            let found = PAGE.find(&head).unwrap_or_else(|| panic!("the `{col}` column heading is missing"));
839            assert!(found > at, "the columns are out of order at `{col}`");
840            at = found;
841        }
842        // …and the script fills them in that same order, so the heading and
843        // the cell under it are the same field.
844        let script = PAGE.split("<script>").nth(1).unwrap_or("");
845        assert!(
846            script.contains("[['repository', ''], ['artifact', ''], ['version', 'version']]"),
847            "the script no longer fills the columns in the order the headings promise"
848        );
849    }
850
851    /// The picture is on the LEFT: `.art` is the first child of `<body>` and
852    /// takes the first half of a row. A `flex-direction: row-reverse` or a
853    /// reordered body would move it, and it is one line either way — so it is
854    /// asserted rather than trusted.
855    #[test]
856    fn the_picture_is_on_the_left() {
857        let body = PAGE.split("<body>").nth(1).expect("the page has a body");
858        let art = body.find("class=\"art\"").expect("the page has the picture half");
859        let side = body.find("class=\"side\"").expect("the page has the column");
860        assert!(art < side, "the picture is not the first half of the page");
861        assert!(!PAGE.contains("row-reverse"), "something reversed the row and put the picture on the right");
862        assert!(PAGE.contains(".art {\n    flex: 0 0 50%;"), "the picture no longer owns a half");
863    }
864
865    /// ★ **The page computes no version ordering.** The one rule of this
866    /// crate, checked against the page's own script: no sort, no compare, no
867    /// localeCompare. The server ranks; the page prints.
868    #[test]
869    fn the_page_does_not_rank_anything() {
870        let script = PAGE.split("<script>").nth(1).unwrap_or("");
871        for banned in [".sort(", "localeCompare", "parseFloat", "parseInt"] {
872            assert!(!script.contains(banned), "the page's script carries `{banned}` — it is deciding something");
873        }
874    }
875
876    /// Nothing is fetched from a third party. This server runs airgapped by
877    /// design; a font or a script from a CDN would be a front page that is
878    /// blank in exactly the deployment holger exists for.
879    #[test]
880    fn the_page_fetches_nothing_from_outside() {
881        for scheme in ["http://", "//cdn", "googleapis", "cdnjs", "jsdelivr", "unpkg"] {
882            assert!(!PAGE.contains(scheme), "the page reaches out to `{scheme}`");
883        }
884        // The one external link is the forge the source is published from, and
885        // it is a link a reader clicks — not something the page loads.
886        assert_eq!(PAGE.matches("https://").count(), 1, "the page names more than one external URL");
887    }
888
889    /// No `innerHTML` anywhere: a repository name, an artifact name and a
890    /// version are operator-supplied text arriving over a socket.
891    #[test]
892    fn nothing_on_the_page_turns_text_into_markup() {
893        assert!(!PAGE.contains("innerHTML"), "the page writes markup from data");
894        assert!(!PAGE.contains("document.write"), "the page uses document.write");
895    }
896}