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:?}");
}
}