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-web/src/cli.rs

//! **Get the `gunnar` CLI** (E4, owner 2026-10-11) — on the host side of the
//! wasm boundary, so a test can state what the page and the hints say.
//!
//! Every encrypted-repository flow (`gunnar crypt init`, `gunnar crypt unlock`, the
//! browser decrypt) needs the CLI on the reader's own computer. Every Gunnar
//! site serves it: the front embeds the SIGNED binary from the estate's own
//! release build and answers `/download/index.json` ([`MANIFEST`]) with what it
//! carries. The console only renders that (thin layer): the page at [`PAGE`],
//! and a one-line hint linking to it wherever a flow needs the CLI.

use serde::Deserialize;

/// The console's page.
pub const PAGE: &str = "/cli";
/// What the front answers with (gunnar-front `downloads`).
pub const MANIFEST: &str = "/download/index.json";
/// Where the files are.
pub const DOWNLOADS: &str = "/download";
/// The hint's link text, beside every flow that needs the CLI.
pub const LINK: &str = "Get the Gunnar CLI";

/// One signed binary.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Binary {
    pub file: String,
    pub target: String,
    pub bytes: u64,
    pub sha256: String,
    pub blake3: String,
    pub signature: String,
    pub verify: String,
}

/// The trust root a signature chains to.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Root {
    pub file: String,
    pub sha256: String,
}

/// `/download/index.json`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Manifest {
    pub version: String,
    #[serde(default)]
    pub binaries: Vec<Binary>,
    pub root: Option<Root>,
    pub cargo_install: String,
    pub absent: Option<String>,
}

/// The second way, when the site offers no binary (or the reader prefers it):
/// what the front says, else the same line.
/// **A site that carries no CLI downloads** (GUN-FIELD-TEST, 2026-11-01): a console with no
/// front behind it has no `/download/index.json`, and the page said "The request failed
/// … HTTP 200". It says this instead, and offers crates.io.
pub const NO_DOWNLOADS: &str = "This site carries no signed builds of the Gunnar CLI. Install it from crates.io:";

pub const CARGO_INSTALL: &str = "cargo install gunnar-cli --locked --no-default-features --features client";

/// The URL of `file` on the site at `origin` (`https://host`).
pub fn url(origin: &str, file: &str) -> String {
    format!("{}{DOWNLOADS}/{file}", origin.trim_end_matches('/'))
}

/// Pure: **the one line that fetches** a binary, its signature, its sha256 and
/// the trust root into the current directory — then [`Binary::verify`] checks them.
pub fn fetch_line(origin: &str, b: &Binary, root: &Root) -> String {
    let files = [b.file.clone(), b.signature.clone(), format!("{}.sha256", b.file), root.file.clone()];
    let urls: Vec<String> = files.iter().map(|f| url(origin, f)).collect();
    format!("curl -fL --remote-name-all {}", urls.join(" "))
}

/// Pure: a size as the page says it.
pub fn size(bytes: u64) -> String {
    const MIB: f64 = 1024.0 * 1024.0;
    if bytes >= 1024 * 1024 {
        format!("{:.1} MiB", bytes as f64 / MIB)
    } else {
        format!("{} KiB", bytes.div_ceil(1024))
    }
}

/// **What a hint beside a flow says**, by flow, before the link to [`PAGE`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Needs {
    /// Opening an encrypted file: the security key here, or the CLI.
    Unlock,
    /// Sealing a repository's files before the first push (`gunnar crypt init`).
    Seal,
    /// Reading an encrypted repository at all: a clone through the CLI.
    Read,
}

impl Needs {
    pub fn line(self) -> &'static str {
        match self {
            Needs::Unlock => "Or open it with `gunnar crypt unlock` in a clone.",
            Needs::Seal => "Seal it with `gunnar crypt init` on your computer: quantum-safe by default.",
            Needs::Read => "Encrypted: clone and read it with the Gunnar CLI.",
        }
    }
}

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

    fn manifest() -> Manifest {
        serde_json::from_value(serde_json::json!({
            "version": "1.5.0",
            "binaries": [{
                "file": "gunnar-x86_64-unknown-linux-musl",
                "target": "x86_64-unknown-linux-musl",
                "bytes": 18_874_368u64,
                "sha256": "a".repeat(64),
                "blake3": "b".repeat(64),
                "signature": "gunnar-x86_64-unknown-linux-musl.p7s",
                "verify": "sha256sum -c gunnar-x86_64-unknown-linux-musl.sha256 && …"
            }],
            "root": { "file": "korp-root-ca.der", "sha256": "c".repeat(64) },
            "cargo_install": CARGO_INSTALL,
            "absent": null
        }))
        .expect("the front's manifest reads")
    }

    /// ★ The page reads what the front serves, and its fetch line takes the
    /// binary, its signature, its sha256 and the root from THIS site.
    #[test]
    fn the_fetch_line_takes_everything_the_verify_line_needs_from_this_site() {
        let m = manifest();
        let b = &m.binaries[0];
        let line = fetch_line("https://acme.gunnar.rs/", b, m.root.as_ref().unwrap());
        for f in ["gunnar-x86_64-unknown-linux-musl", "gunnar-x86_64-unknown-linux-musl.p7s", "gunnar-x86_64-unknown-linux-musl.sha256", "korp-root-ca.der"] {
            assert!(line.contains(&format!("https://acme.gunnar.rs/download/{f}")), "{f}: {line}");
        }
        assert!(line.starts_with("curl -fL --remote-name-all "), "{line}");
        assert!(!line.contains("//download"), "{line}");
    }

    /// ★ A site without the signed CLI says why by name, and still offers crates.io.
    #[test]
    fn a_site_without_the_cli_names_why_and_still_offers_cargo_install() {
        let m: Manifest = serde_json::from_value(serde_json::json!({
            "version": "1.5.0", "binaries": [], "root": null,
            "cargo_install": CARGO_INSTALL, "absent": "REFUSED [cli-not-on-medium] …"
        }))
        .unwrap();
        assert!(m.binaries.is_empty() && m.absent.unwrap().contains("[cli-not-on-medium]"));
        assert!(m.cargo_install.starts_with("cargo install gunnar-cli"));
    }

    #[test]
    fn sizes_read_as_a_person_says_them() {
        assert_eq!(size(18_874_368), "18.0 MiB");
        assert_eq!(size(1500), "2 KiB");
    }

    /// ★ Each hint names the command the flow runs, in one line.
    #[test]
    fn each_hint_names_its_command_in_one_line() {
        assert!(Needs::Unlock.line().contains("gunnar crypt unlock"));
        assert!(Needs::Seal.line().contains("gunnar crypt init"));
        assert!(Needs::Read.line().contains("Gunnar CLI"));
        for n in [Needs::Unlock, Needs::Seal, Needs::Read] {
            assert!(n.line().len() + LINK.len() < crate::hint::ONE_LINE, "{}", n.line());
        }
        assert_eq!(PAGE, "/cli");
    }
}

#[cfg(test)]
mod gunfield_tests {
    /// ★ No manifest is not a failure: the page names it and offers crates.io, and draws
    /// no download it does not have (GUN-FIELD-TEST, 2026-11-01).
    #[test]
    fn a_console_without_downloads_offers_crates_io_and_no_failure() {
        let page = crate::code::of(include_str!("pages/cli.rs"));
        assert!(page.contains("Some(Err(_))") && page.contains("crate::cli::NO_DOWNLOADS"), "the no-downloads arm");
        assert!(!page.contains("resolved(manifest.get()"), "a missing manifest is not drawn as a failure");
    }
}

#[cfg(test)]
mod gunfield_css {
    /// ★ The install line wraps on a phone instead of widening the page (GUN-FIELD-TEST,
    /// 2026-11-01: /cli at 400 px scrolled sideways under the cargo install line).
    #[test]
    fn the_install_line_wraps_on_a_phone() {
        let css = include_str!("../styles/console.css");
        let rule = css.split("\n.cli-line {").nth(1).and_then(|r| r.split('}').next()).unwrap_or("");
        assert!(rule.contains("white-space: pre-wrap") && rule.contains("overflow-wrap: anywhere"), "{rule:?}");
    }
}