Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/envfile.rs

//! **`/etc/gunnar-ui.env`, declared in Rust.**
//!
//! Until 2026-09-13 this file existed only as a heredoc in `deploy/README.md`
//! and as one `grep` in `deploy/deploy.sh`. That is why a deploy to the new
//! front stopped at `deploy.sh` line 111 with `/etc/gunnar-ui.env is missing;
//! see deploy/README.md`: nothing in the repository could create it, and a
//! README is not a thing a machine can apply.
//!
//! The declaration below is the source. [`template`] renders it for an operator
//! to fill in, [`parse`] reads a filled-in one back, and
//! [`crate::lifecycle::install`] refuses an install whose file does not satisfy
//! it — the same refusal `deploy.sh` made, with the reason typed instead of
//! grepped.
//!
//! **No secret is ever a value here.** `GUNNAR_UI_ADMIN_TOKEN` is this console's
//! admin until the first passkey exists, so a repository that carried one would
//! be a repository that hands out the console. Secrets are [`Fill::Placeholder`]
//! and nothing else; an install that finds the placeholder still in the file
//! refuses, because a placeholder is a value an attacker can read here.
//!
//! The keys themselves are not a guess:
//! `every_gunnar_ui_key_the_server_names_is_declared` in
//! `tests/env_declaration.rs` scans this crate's own source for every
//! `GUNNAR_UI_*` it names and fails when one of them is not below.

use std::fmt::Write as _;

/// Does this console work without the key?
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Need {
    /// The console starts and is WRONG — or refuses to start — without it. Its
    /// compiled default, when it has one, is a development default that does not
    /// describe the appliance.
    Required,
    /// Absence is a supported state and means one named thing is switched off.
    Optional,
}

/// What [`template`] writes to the right of the `=`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fill {
    /// Not a secret, and known — every one of these was read off the console
    /// running on the old front on 2026-09-13, not invented here.
    Known(&'static str),
    /// A SECRET, or the path to one. The template writes this placeholder and
    /// never a value, and [`unfilled`] treats the placeholder as "not set yet".
    Placeholder(&'static str),
    /// Nothing this program can know (it is a deployment's own decision). The
    /// line is written commented out, so the key is visible without being set.
    Blank,
}

/// One environment variable the console reads.
#[derive(Debug, Clone, Copy)]
pub struct Var {
    pub key: &'static str,
    pub need: Need,
    pub fill: Fill,
    /// WHY this value, in the deployment this file describes. Rendered into the
    /// template as a comment, so the operator reading the file on the box gets
    /// the same reason as the reader of this source.
    pub why: &'static str,
}

/// **The declaration.** Order is the order [`template`] writes them, which is
/// the order they are read in: what the process listens on, who it talks to,
/// where its own things are, who may sign in, and then the switched-off halves.
pub const VARS: &[Var] = &[
    Var {
        key: "GUNNAR_UI_BIND",
        need: Need::Required,
        fill: Fill::Known("127.0.0.1:3210"),
        why: "loopback only: the console is reached through the TLS front, never directly. \
              Binding anything else is refused at start unless a passkey RP is configured.",
    },
    Var {
        key: "GUNNAR_UI_CONTROL",
        need: Need::Required,
        fill: Fill::Known("10.13.8.101:50051"),
        why: "gunnar's control plane, on the PRIVATE network the front shares with the \
              appliance. The console running on 2026-09-13 still said 10.13.6.152:50051 — the \
              old appliance, on the UpCloud account being retired. Copy that line onto the new \
              front and every control-plane call answers Unauthenticated or nothing at all, \
              because the box at the old address either does not answer or does not know this \
              console's key. The compiled default (127.0.0.1:50051) is wronger still: on the \
              front there is no gunnar on loopback.",
    },
    Var {
        key: "GUNNAR_UI_IDENTITY",
        need: Need::Required,
        fill: Fill::Known("/opt/gunnar-ui/console_key"),
        why: "the console's own ed25519 key, enrolled with gunnar as an admin principal. \
              Deliberately outside releases/, so a release swap never moves it.",
    },
    Var {
        key: "GUNNAR_UI_TENANT_SEEDS",
        need: Need::Optional,
        fill: Fill::Known("/opt/gunnar-ui/tenant_seeds"),
        why: "a directory of <principal_id> ed25519 private keys, one per tenant, laid by \
              `cargo xtask estate rebuild`'s pair rung and attested with the operator's own \
              key. The console signs a tenant's own control-plane reads with theirs, so a \
              tenant sees their repositories rather than the console's view of them. Unset \
              means no seeds: every read falls back to the console's identity, which is a \
              working deployment and a narrower one. Outside releases/ like the key and the \
              store, so a release swap never takes the tenants' identities with it.",
    },
    Var {
        key: "GUNNAR_UI_STATE",
        need: Need::Required,
        fill: Fill::Known("/opt/gunnar-ui/state.redb"),
        why: "the passkey and session store. Outside releases/ for the same reason as the key: \
              the swap must not be able to take the logins with it.",
    },
    Var {
        key: "GUNNAR_UI_DIR",
        need: Need::Required,
        fill: Fill::Known("/opt/gunnar-ui/current/dist"),
        why: "THE one value the release layout depends on, and it must go through `current`. \
              Point it at a release directory by name and the next swap serves the old bundle \
              forever. Point it anywhere else and the server starts, answers /health, and \
              serves an empty console — which is how the first run of deploy.sh failed. It is \
              set HERE and not as an Environment= line in the unit because systemd applies \
              EnvironmentFile= after Environment= whatever order they appear in, so the file \
              always wins; see crate::lifecycle.",
    },
    Var {
        key: "GUNNAR_UI_WEBAUTHN_RP_ID",
        need: Need::Required,
        fill: Fill::Known("gunnar.rs"),
        why: "the passkey relying party — the public hostname without a scheme or a port. \
              Unset means passkey login is OFF. A passkey is bound to the RP it was \
              registered against, so changing this later leaves a console that is up, \
              healthy, serving, and shut.",
    },
    Var {
        key: "GUNNAR_UI_WEBAUTHN_ORIGIN",
        need: Need::Required,
        fill: Fill::Known("https://www.gunnar.rs"),
        why: "the exact browser origin, scheme included (the site is www.: the relying party stays the bare name). It must match what the address bar \
              says character for character.",
    },
    Var {
        key: "GUNNAR_UI_ADMIN_TOKEN",
        need: Need::Required,
        fill: Fill::Placeholder("REPLACE-ME-WITH-32-RANDOM-CHARACTERS"),
        why: "the break-glass bearer that enrols the FIRST passkey, and this console's admin \
              until one exists. 24 random bytes in base64, or a pair of UUIDs — the server \
              refuses a guessable one at start rather than warning about it. Generate it on \
              the box; never let it come from a repository.",
    },
    Var {
        key: "GUNNAR_UI_WEBAUTHN_RP_NAME",
        need: Need::Optional,
        fill: Fill::Known("Gunnar"),
        why: "what the authenticator shows the person. Cosmetic.",
    },
    Var {
        key: "GUNNAR_UI_GUEST_IDENTITY",
        need: Need::Optional,
        fill: Fill::Known("/opt/gunnar-ui/guest_key"),
        why: "a SECOND key, enrolled with NO grants, used for visitors with no session. Unset \
              means nothing is served anonymously. It must not be the identity above: that one \
              is a gunnar admin.",
    },
    Var {
        key: "GUNNAR_UI_GIT_URL",
        need: Need::Optional,
        fill: Fill::Known("ssh://git.gunnar.rs:2222"),
        why: "what the console shows a person as the clone URL. Display only — this process \
              never dials it.",
    },
    Var {
        key: "GUNNAR_UI_FORGES",
        need: Need::Optional,
        fill: Fill::Known("off"),
        why: "a `forges` build only (AIRGAP): `on` lets a person connect GitHub or a GitLab and \
              import from it, which dials those forges. Anything else (the default) refuses every forge \
              door by name and dials nothing.",
    },
    Var {
        key: "GUNNAR_UI_PUBLIC_SITE",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "a `public-site` build only, and OFF unless set to `on`: gunnar.rs's public face \
              (the home, llms.txt, robots.txt's allow, the sitemap, the public pages, IndexNow). \
              Unset, the console answers as without the feature and robots.txt says Disallow: /. \
              Only gunnar.rs's own site turns it on (its front is fed `console_public_site = on`); \
              an administrator flips it at run time with PUT /api/public-site. This is the switch's \
              INITIAL position: a flip is kept as gunnar's generic setting `console.public-site` \
              and wins over it from then on, also across a restart.",
    },
    Var {
        key: "GUNNAR_UI_MCP",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "an `mcp` build only, and OFF unless set to `on`: the INITIAL position of the MCP \
              connector's switch. An administrator flips it at run time with PUT /api/mcp; the \
              position is kept as gunnar's generic setting `console.mcp` and wins over this one \
              from then on, also across a restart.",
    },
    Var {
        key: "GUNNAR_UI_PUBLIC_AI_CRAWLERS",
        need: Need::Optional,
        fill: Fill::Known("allow"),
        why: "a `public-site` build only: robots.txt's policy for the AI crawlers (GPTBot, ClaudeBot, \
              PerplexityBot, Google-Extended). `deny` tells them to stay away; anything else allows.",
    },
    Var {
        key: "GUNNAR_UI_PUBLIC_INDEXNOW_KEY",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "a `public-site` build only: the INITIAL IndexNow key (8 to 128 of a-z, A-Z, 0-9, -), \
              taken while Gunnar stores none; an administrator's key (PUT /api/public-site/indexnow, \
              gunnar's setting console.public-site.indexnow) wins once set. A key makes the public site \
              serve /<key>.txt and ping api.indexnow.org with the public URLs that changed, at most \
              every ten minutes. No key, stored or here, means no outbound call at all (the airgap \
              default).",
    },
    Var {
        key: "GUNNAR_UI_MONETIZE",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "the ONE monetize this console's shop runs on (`https://monetize.ignalina.dk`; \
              https:// off loopback). No default — no estate runs a monetize of its own (OM-10): \
              REQUIRED once a bearer is found below, else the console refuses to start. Alone it \
              turns nothing on.",
    },
    Var {
        key: "GUNNAR_UI_MONETIZE_TOKEN_FILE",
        need: Need::Optional,
        fill: Fill::Placeholder("/opt/gunnar-ui/monetize-token"),
        why: "where the monetize bearer is read from. A placeholder because the FILE is the \
              secret: create it on the box with mode 0600 and the token as its only line. \
              Absent means the monetize seam is off and its routes are not \
              registered at all.",
    },
    Var {
        key: "GUNNAR_UI_MONETIZE_TOKEN",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "the same bearer inline, which puts a long-lived credential in this file AND in \
              every `systemctl show-environment`-shaped readback of the unit. Prefer the file \
              above; this exists because monetize's own ladder has it.",
    },
    Var {
        key: "GUNNAR_UI_BUY_URL",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "where the console sends someone who wants more room. Unset hides the link.",
    },
    Var {
        key: "GUNNAR_UI_INSTANCE",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "the company whose PRIVATE Gunnar this is (`acme` for acme.gunnar.rs), as the \
              kit sets it for a laid private estate. The sign-up page says \"Grow acme's \
              Gunnar\" from it. Display only; unset on gunnar.rs and every other estate.",
    },
    Var {
        key: "GUNNAR_UI_PROVIDER",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "the site that runs this PRIVATE Gunnar for its company (`https://www.gunnar.rs`), \
              as the kit sets it for a laid private estate. Its \"New instance\" leads to that \
              site's sign-up page. Unset on gunnar.rs and every other estate.",
    },
    Var {
        key: "GUNNAR_UI_PRODUCT",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "the product id of this estate, as monetize knows it: `gunnar` on every estate \
              (gunnar.rs and test2.gunnar.rs alike) — the ONE monetize tells the sites apart by \
              the estate's own bearer. The kit sets it. REQUIRED once a monetize bearer \
              is found: unset, the console refuses to start (no default product).",
    },
    Var {
        key: "GUNNAR_UI_TRUSTED_PROXIES",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "addresses whose x-forwarded-for is believed, comma separated. Loopback is \
              ALWAYS believed, so this is needed only when the reverse proxy is a second \
              machine — which on this front it is not.",
    },
    Var {
        key: "GUNNAR_UI_TYPESETTERS",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "how many READMEs may be typeset at once. Unset means the box's parallelism.",
    },
    Var {
        key: "GUNNAR_UI_TWIN",
        need: Need::Optional,
        fill: Fill::Known("10.13.8.122:50051"),
        why: "the TWIN's control plane, on the same private network. The Twin panel does not \
              need it (gunnar reports its twins itself); the \"Restore from twin\" action does, \
              and unset hides that action.",
    },
    Var {
        key: "GUNNAR_UI_DATA",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "the fallback root the three paths above are derived from when they are unset. \
              Every one of them IS set here, so this changes nothing; it is declared because \
              the binary reads it and an undeclared key is a key nobody checked.",
    },
    // ── the invite mail (`auth::mail`) ──
    // All four are Optional TOGETHER: unset means the console does not send
    // mail and an invite link is delivered by hand, which is what every
    // deployment did before these existed. A PARTIAL set is refused by name at
    // startup rather than silently not sending.
    Var {
        key: "GUNNAR_UI_SMTP_FROM",
        need: Need::Optional,
        fill: Fill::Known("gunnar@gunnar.rs"),
        why: "the address invite mail is sent AS, and — Proton's rule — the submission \
              USERNAME too, so this one key carries both. gunnar.rs is a verified Proton \
              custom domain (MX, SPF, DKIM and the verification TXT are live).",
    },
    Var {
        key: "GUNNAR_UI_SMTP_TOKEN_FILE",
        need: Need::Optional,
        fill: Fill::Placeholder("/path/to/the-smtp-token"),
        why: "the Proton SMTP-submission token, as a PATH. Preferred over the inline twin for \
              the same reason GUNNAR_UI_MONETIZE_TOKEN_FILE is: a secret in a file has a mode, \
              a secret in the environment is in /proc/<pid>/environ and in every crash report. \
              Generated in Proton's panel against an ACTIVE custom-domain address. \
              The placeholder names NO real path on purpose: `unfilled` decides by comparing \
              the value against it, so a placeholder a deployment could plausibly have chosen \
              is one the check can never call unconfigured. The first version of this row said \
              /etc/gunnar-ui-smtp.token, which is exactly where the token was then installed, \
              and the deploy correctly refused a file it could not tell from an untouched \
              template.",
    },
    Var {
        key: "GUNNAR_UI_SMTP_TOKEN",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "the same token inline, read only when the _FILE key names nothing. Declared so \
              the binary's fallback is visible; prefer the file.",
    },
    Var {
        key: "GUNNAR_UI_SMTP_HOST",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "the submission relay. Unset means smtp.protonmail.ch, which is where this \
              domain's mail goes; port is GUNNAR_UI_SMTP_PORT and unset means 587 (STARTTLS), \
              because outbound 25 is blocked by most hosts and Proton offers submission only.",
    },
    Var {
        key: "GUNNAR_UI_SMTP_PORT",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "the relay's port. Unset means 587.",
    },
    Var {
        key: "GUNNAR_UI_IMPORT_CONCURRENT",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "how many imports or syncs one ordinary user may have queued or running at once \
              (api::mirror_gate). Unset means 2. An administrator is not held to it. gunnar runs \
              one sync at a time for the whole box, so a higher number is a longer queue, not a \
              faster one.",
    },
    Var {
        key: "GUNNAR_UI_SSO_ALLOW_LOOPBACK_HTTP",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "a TEST door, never on an appliance: `1` lets a company sign-in send the browser to \
              an identity provider on this box's loopback over plain http (gunnar-ui's uitest and \
              its fake IdP), as gunnar's GUNNAR_SSO_ALLOW_LOOPBACK_HTTP. Unset: https only.",
    },
    Var {
        key: "GUNNAR_UI_SYNCS_PER_HOUR",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "how many syncs one ordinary user may START in an hour, an import's first sync \
              included. Unset means 12. An administrator is not held to it.",
    },
    Var {
        key: "GUNNAR_UI_THROTTLE",
        need: Need::Optional,
        fill: Fill::Blank,
        why: "the one throttle every request passes (guests per address, a signed-in person per \
              account and address, a token per token, the whole box; renders lower). Unset is the \
              safe defaults; `tier=burst/per_second[/in_flight]`, comma-separated, changes one.",
    },
];

/// **The keys that say this host expects to take money**, and the reason each
/// one is in the list rather than merely near it.
///
/// * `GUNNAR_UI_MONETIZE_TOKEN` / `_TOKEN_FILE` — the bearer. `monetize_token()`
///   in `main.rs` is the ONE thing that turns the panel on, so either of these
///   with a value in it is an operator saying "sell".
/// * `GUNNAR_UI_BUY_URL` — the sharpest of the three, and the one that was
///   actually set on the front on 2026-09-12. It does not go through the
///   `metered` gate at all (`auth::routes` reads it either way), so a
///   **community** console with this set advertises a purchase, sends the person
///   to a payment vendor, takes their card — and then cannot receive them back,
///   because `/welcome` and `/auth/signup/status` were never compiled in. That
///   is the 401, and two refunded payments, exactly.
///
/// `GUNNAR_UI_MONETIZE` is deliberately NOT here. It is only ever dialled when a
/// bearer is found, so it turns nothing on by itself — including it would refuse
/// an install that names the ONE monetize ahead of its bearer.
///
/// Read by [`configured_to_sell`], and held to the declaration by
/// `every_selling_key_is_a_declared_key`.
pub const SELLING: &[&str] = &[
    "GUNNAR_UI_MONETIZE_TOKEN",
    "GUNNAR_UI_MONETIZE_TOKEN_FILE",
    "GUNNAR_UI_BUY_URL",
];

/// Which of [`SELLING`] this env file actually sets — empty for a host that
/// sells nothing.
///
/// A key still carrying its template placeholder does NOT count: a placeholder
/// is a value nobody chose, [`unfilled`] already refuses it by name, and
/// counting it here would report a stock template as a shop.
pub fn configured_to_sell(found: &[(String, String)]) -> Vec<&'static str> {
    SELLING
        .iter()
        .copied()
        .filter(|key| {
            let placeholder = VARS.iter().find(|v| v.key == *key).and_then(|v| match v.fill {
                Fill::Placeholder(p) => Some(p),
                _ => None,
            });
            found.iter().any(|(k, val)| {
                k == key && !val.trim().is_empty() && Some(val.as_str()) != placeholder
            })
        })
        .collect()
}

/// Key PREFIXES whose members cannot be listed one by one, because the server
/// builds the name at run time (`format!(\"GUNNAR_UI_FORGE_{prefix}_CLIENT_ID\")`,
/// once per forge). A key under one of these is declared by its family.
///
/// None of them is written into the template: a forge OAuth application is a
/// per-deployment registration with a client secret, and pasting a personal
/// access token needs none of them.
pub const FAMILIES: &[&str] = &["GUNNAR_UI_FORGE_"];

/// Keys that reach the binary from `build.rs` as `cargo:rustc-env` and are read
/// with `env!`, never `std::env::var` — so they belong in no environment file.
pub const BUILD_TIME: &[&str] = &["GUNNAR_UI_BUILD_REF", "GUNNAR_UI_BUILD_DATE"];

/// Keys only the test harness reads, never the server: `GUNNAR_UI_NEEDS_BROWSER=1` is set by
/// CI's browser step (`cargo xtask browser`, RA2-7) so the first-touch browser tests are red,
/// never a green NOT RUN, without a Chromium; `GUNNAR_UI_BUNDLE` names the console's built
/// bundle for the deep-link browser test (GUN-GUEST). They belong in no environment file.
pub const TEST_ONLY: &[&str] = &["GUNNAR_UI_NEEDS_BROWSER", "GUNNAR_UI_BUNDLE"];

/// Where the unit's `EnvironmentFile=` points. One constant, because the unit,
/// the install refusal and the template all have to name the same file.
pub const PATH: &str = "/etc/gunnar-ui.env";

/// Is this key accounted for — by name, by family, as a build-time stamp, or as the test harness's?
pub fn declared(key: &str) -> bool {
    VARS.iter().any(|v| v.key == key)
        || FAMILIES.iter().any(|f| key.starts_with(f))
        || BUILD_TIME.contains(&key)
        || TEST_ONLY.contains(&key)
}

/// The declaration as a file an operator fills in and `chmod 600`s.
///
/// It does not write itself anywhere. `deploy.sh` refused to create this file
/// and the reason it gave is still the reason: a deploy that can silently
/// rewrite the admin token is a deploy that can silently lock everyone out. So
/// this is a verb an operator runs, looks at, and redirects.
pub fn template() -> String {
    let mut s = String::new();
    s.push_str(
        "# /etc/gunnar-ui.env — the gunnar console's configuration.\n\
         #\n\
         # Generated by `gunnar-ui-server --env-template`. Every value below is\n\
         # declared in gunnar_ui_server::envfile; this file is a rendering of that\n\
         # declaration, not the other way round.\n\
         #\n\
         # Fill in every REPLACE-ME before installing. Then: chmod 600 this file.\n\
         # It holds the credential that is this console's admin until the first\n\
         # passkey exists.\n",
    );
    for v in VARS {
        s.push('\n');
        for line in wrap(v.why, 74) {
            let _ = writeln!(s, "# {line}");
        }
        match v.fill {
            Fill::Known(value) => {
                let _ = writeln!(s, "{}={}", v.key, value);
            }
            Fill::Placeholder(value) => {
                let _ = writeln!(s, "{}={}", v.key, value);
            }
            // Commented out, not empty: the server distinguishes "unset" from
            // "set to nothing" for several of these, and an empty assignment is
            // the second one.
            Fill::Blank => {
                let _ = writeln!(s, "#{}=", v.key);
            }
        }
    }
    s
}

/// Read a filled-in env file back. `KEY=VALUE` per line, `#` comments and blank
/// lines skipped, value taken verbatim after the FIRST `=` (a token may contain
/// one) and trimmed of surrounding whitespace only.
///
/// Deliberately not a shell parser: systemd's `EnvironmentFile=` is not a shell
/// either, and a parser that understood more than systemd does would accept a
/// file the service then reads differently.
pub fn parse(text: &str) -> Vec<(String, String)> {
    let mut out = Vec::new();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let Some((k, v)) = line.split_once('=') else {
            continue;
        };
        let k = k.trim();
        if k.is_empty() {
            continue;
        }
        out.push((k.to_string(), v.trim().to_string()));
    }
    out
}

/// Required keys the file does not set (missing, or set to nothing).
pub fn missing_required(found: &[(String, String)]) -> Vec<&'static str> {
    VARS.iter()
        .filter(|v| v.need == Need::Required)
        .filter(|v| {
            !found
                .iter()
                .any(|(k, val)| k == v.key && !val.trim().is_empty())
        })
        .map(|v| v.key)
        .collect()
}

/// Keys still carrying the template's placeholder. A placeholder that survived
/// into `/etc` is not a configured secret; it is a published one.
pub fn unfilled(found: &[(String, String)]) -> Vec<&'static str> {
    VARS.iter()
        .filter_map(|v| match v.fill {
            Fill::Placeholder(p) => Some((v.key, p)),
            _ => None,
        })
        .filter(|(key, p)| found.iter().any(|(k, val)| k == key && val == p))
        .map(|(key, _)| key)
        .collect()
}

/// Word-wrap for the template's comment blocks. Thirty lines of Rust rather
/// than a dependency, because this is the only place in the crate that wraps.
fn wrap(text: &str, width: usize) -> Vec<String> {
    let mut lines = Vec::new();
    let mut cur = String::new();
    for word in text.split_whitespace() {
        if !cur.is_empty() && cur.len() + 1 + word.len() > width {
            lines.push(std::mem::take(&mut cur));
        }
        if !cur.is_empty() {
            cur.push(' ');
        }
        cur.push_str(word);
    }
    if !cur.is_empty() {
        lines.push(cur);
    }
    lines
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_template_round_trips_through_the_parser() {
        let parsed = parse(&template());
        // Every Known and Placeholder key is set; the Blank ones are commented
        // out and must NOT come back, or an install would see them as "set to
        // nothing" and a `Fill::Blank` would stop meaning absent.
        for v in VARS {
            let got = parsed.iter().find(|(k, _)| k == v.key);
            match v.fill {
                Fill::Known(value) | Fill::Placeholder(value) => {
                    assert_eq!(
                        got.map(|(_, x)| x.as_str()),
                        Some(value),
                        "{} should be set to {value:?} by the template",
                        v.key
                    );
                }
                Fill::Blank => assert!(got.is_none(), "{} must be commented out", v.key),
            }
        }
        assert!(missing_required(&parsed).is_empty());
    }

    #[test]
    fn a_template_straight_out_of_the_box_is_refused_for_its_placeholder() {
        // The whole point of the placeholder: a file that still carries it is
        // NOT configured, and it must not be possible to install around that.
        let parsed = parse(&template());
        assert_eq!(
            unfilled(&parsed),
            vec![
                "GUNNAR_UI_ADMIN_TOKEN",
                "GUNNAR_UI_MONETIZE_TOKEN_FILE",
                // The invite mail's submission token (2026-09-16). Listed here
                // deliberately rather than by a wildcard: a new placeholder is
                // a new way for a box to ship unconfigured, and this roster is
                // where that gets acknowledged.
                "GUNNAR_UI_SMTP_TOKEN_FILE",
            ]
        );
        let filled = parse("GUNNAR_UI_ADMIN_TOKEN=6c1f0a9d5e3b47a28f0c9d1e5b7a3f4c\n");
        assert!(unfilled(&filled).is_empty());
    }

    #[test]
    fn no_secret_is_ever_a_value_in_this_repository() {
        for v in VARS {
            if let Fill::Known(value) = v.fill {
                assert!(
                    !v.key.ends_with("_TOKEN") && !v.key.ends_with("_SECRET"),
                    "{} carries a literal {value:?} and its name says it is a secret",
                    v.key
                );
            }
        }
    }

    #[test]
    fn the_control_plane_is_the_new_appliance_not_the_retired_one() {
        // 2026-09-13: the running console had 10.13.6.152:50051, the appliance on
        // the account being retired. This is the one value a copy-paste gets wrong
        // silently — every call answers Unauthenticated or empty, and the console
        // still serves pages.
        let control = VARS.iter().find(|v| v.key == "GUNNAR_UI_CONTROL").unwrap();
        assert_eq!(control.fill, Fill::Known("10.13.8.101:50051"));
        assert_eq!(control.need, Need::Required);
        // The old address IS named in the template — in the comment above the
        // value, because that is where the warning belongs and an operator
        // holding the old file needs to recognise it. What must never exist is
        // a LINE that sets it, which is what this asserts: the values, not the
        // prose. (Asserting on the raw text instead failed on this very
        // comment, which is the first thing this test caught.)
        let set: Vec<&str> = parse(&template())
            .iter()
            .map(|(_, v)| v.as_str())
            .filter(|v| v.contains("10.13.6.152"))
            .map(|_| "10.13.6.152")
            .collect();
        assert!(
            set.is_empty(),
            "the retired appliance is assigned in the template"
        );
        assert!(
            template().contains("10.13.6.152"),
            "the reason this value is what it is has to be in the file the operator reads"
        );
    }

    /// `SELLING` is a second list of key names, and a second list is a list that
    /// goes stale. This is what stops it: every name in it has to be a key the
    /// declaration above actually knows about, so renaming a var breaks here
    /// rather than quietly emptying the refusal that reads it.
    #[test]
    fn every_selling_key_is_a_declared_key() {
        for key in SELLING {
            assert!(
                VARS.iter().any(|v| v.key == *key),
                "{key} is in SELLING and in no Var; \
                 lifecycle::Refusal::ConfiguredToSellButCannot would never see it"
            );
        }
    }

    /// WWW-CANON (owner 2026-10-30: the site is www.gunnar.rs): the template's relying party
    /// stays the bare site (the passkeys made there keep working) and its origin is www.
    #[test]
    fn the_template_binds_passkeys_to_the_bare_site_at_the_www_origin() {
        let parsed = parse(&template());
        let get = |k: &str| parsed.iter().find(|(key, _)| key == k).map(|(_, v)| v.as_str());
        assert_eq!(get("GUNNAR_UI_WEBAUTHN_RP_ID"), Some("gunnar.rs"));
        assert_eq!(get("GUNNAR_UI_WEBAUTHN_ORIGIN"), Some("https://www.gunnar.rs"));
    }

    #[test]
    fn the_parser_takes_the_value_after_the_first_equals() {
        let p = parse("# a comment\n\nGUNNAR_UI_ADMIN_TOKEN=a=b=c\n  GUNNAR_UI_BIND = 0.0.0.0:1  \nnonsense\n=novalue\n");
        assert_eq!(
            p,
            vec![
                ("GUNNAR_UI_ADMIN_TOKEN".into(), "a=b=c".into()),
                ("GUNNAR_UI_BIND".into(), "0.0.0.0:1".into()),
            ]
        );
    }
}