Skip to main content

git_ark/
hostcmd.rs

1//! `host add`: the SSH orchestration that wires the pure pieces in
2//! `hostspec.rs`/`registry.rs` onto a real box.
3//!
4//! Everything here that actually talks to a host (`ssh_run`,
5//! `ssh_pipe_file`, `ssh_append_unique`, `host_add`) is exercised end to end
6//! by the controller against `docker/test-host`, not by a unit test — there
7//! is no live host in CI. What IS unit-tested is every pure seam: parsing
8//! `user@host`, building the `ssh` argument vector, the remote shell
9//! commands those transport fns run, and the client-side path resolution
10//! (keydir, registry path, `~/.ssh/config` upsert).
11
12use crate::config::S3Config;
13use crate::github;
14use crate::hostspec::{
15    assess, forced_command_line, parse_probe, per_host_prefix, render_config, render_secrets,
16    ssh_config_block, PROBE_SCRIPT,
17};
18use crate::registry::{Host, Registry};
19use crate::release;
20use crate::repo_policy::read_repo_policy;
21use crate::sshdiag::{classify_ssh_error, diagnosis_message, SshDiagnosis};
22use anyhow::{anyhow, bail, Context, Result};
23use std::collections::HashMap;
24use std::io::Write;
25use std::path::{Path, PathBuf};
26use std::process::{Command, Stdio};
27
28// ---------------------------------------------------------------------
29// SSH transport
30// ---------------------------------------------------------------------
31
32/// The control-channel SSH target: the operator's interactive key, never the
33/// forced-command key that `host add` goes on to install.
34#[derive(Debug, Clone)]
35pub struct SshSpec {
36    pub user: String,
37    pub host: String,
38    pub port: Option<u16>,
39    pub identity: Option<PathBuf>,
40}
41
42impl SshSpec {
43    fn display(&self) -> String {
44        format!("{}@{}", self.user, self.host)
45    }
46}
47
48/// Split an ssh target `user@host` into `(user, host)`. git-ark hosts always
49/// run under a dedicated user, so a bare hostname (no `@`) is rejected rather
50/// than guessed at.
51pub fn parse_target(target: &str) -> Result<(String, String)> {
52    let (user, host) = target
53        .split_once('@')
54        .ok_or_else(|| anyhow::anyhow!("target must be `user@host` (got {target:?})"))?;
55    if user.is_empty() || host.is_empty() {
56        bail!("target must be `user@host` (got {target:?})");
57    }
58    Ok((user.to_string(), host.to_string()))
59}
60
61/// The `ssh` argument vector for `spec`, up to (not including) the trailing
62/// remote command: `[-p <port>] [-i <identity>] -o StrictHostKeyChecking=accept-new
63/// <user@host>`. `accept-new` trusts a host on first contact but still detects
64/// a changed key on a later connection — the right default for boxes named
65/// explicitly by the operator (this slice has no other host-identity check).
66fn ssh_args(spec: &SshSpec) -> Vec<String> {
67    let mut args = Vec::new();
68    if let Some(port) = spec.port {
69        args.push("-p".to_string());
70        args.push(port.to_string());
71    }
72    if let Some(identity) = &spec.identity {
73        args.push("-i".to_string());
74        args.push(identity.display().to_string());
75    }
76    args.push("-o".to_string());
77    args.push("StrictHostKeyChecking=accept-new".to_string());
78    args.push(spec.display());
79    args
80}
81
82fn ssh_command(spec: &SshSpec) -> Command {
83    let mut cmd = Command::new("ssh");
84    cmd.args(ssh_args(spec));
85    cmd
86}
87
88/// Run `cmd` on `spec` over the control channel, returning stdout. Non-zero
89/// exit is an error carrying stderr.
90pub fn ssh_run(spec: &SshSpec, cmd: &str) -> Result<String> {
91    let out = ssh_command(spec)
92        .arg(cmd)
93        .output()
94        .with_context(|| format!("spawning ssh to {}", spec.display()))?;
95    if !out.status.success() {
96        bail!(
97            "ssh {} failed: {}",
98            spec.display(),
99            String::from_utf8_lossy(&out.stderr).trim()
100        );
101    }
102    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
103}
104
105/// Like `ssh_run`, but writes `input` to the remote command's stdin before
106/// waiting on it — the transport underneath `ssh_pipe_file` and
107/// `ssh_append_unique`.
108fn ssh_run_with_stdin(spec: &SshSpec, cmd: &str, input: &[u8]) -> Result<String> {
109    let mut child = ssh_command(spec)
110        .arg(cmd)
111        .stdin(Stdio::piped())
112        .stdout(Stdio::piped())
113        .stderr(Stdio::piped())
114        .spawn()
115        .with_context(|| format!("spawning ssh to {}", spec.display()))?;
116    child
117        .stdin
118        .take()
119        .expect("stdin was piped")
120        .write_all(input)
121        .context("writing to ssh stdin")?;
122    let out = child
123        .wait_with_output()
124        .with_context(|| format!("waiting on ssh to {}", spec.display()))?;
125    if !out.status.success() {
126        bail!(
127            "ssh {} failed: {}",
128            spec.display(),
129            String::from_utf8_lossy(&out.stderr).trim()
130        );
131    }
132    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
133}
134
135/// The remote shell command `ssh_pipe_file` runs: capture stdin to a temp
136/// file next to the target, `chmod` it, then atomically `mv` it into place —
137/// so a connection dropped mid-transfer never leaves a partial file at
138/// `remote_path`. `umask 077` makes the staged temp file private from the
139/// moment it's created, rather than briefly world/group-readable under the
140/// remote's default umask until the explicit `chmod` lands — belt-and-
141/// suspenders for `secrets.toml`, harmless for the world-readable modes.
142fn pipe_file_command(remote_path: &str, mode: u32) -> String {
143    let tmp = format!("{remote_path}.uploading");
144    let remote_q = shell_words::quote(remote_path);
145    let tmp_q = shell_words::quote(&tmp);
146    format!("umask 077; cat > {tmp_q} && chmod {mode:o} {tmp_q} && mv {tmp_q} {remote_q}")
147}
148
149/// Stream `bytes` to `remote_path` on the host, then `chmod mode`. Used for
150/// the binary, `config.toml`, and `secrets.toml` — the proven pattern of
151/// piping over the same control-channel `ssh` rather than requiring `scp`.
152pub fn ssh_pipe_file(spec: &SshSpec, remote_path: &str, bytes: &[u8], mode: u32) -> Result<()> {
153    ssh_run_with_stdin(spec, &pipe_file_command(remote_path, mode), bytes)?;
154    Ok(())
155}
156
157/// The remote shell command `ssh_append_unique` runs: remove any existing
158/// line containing `marker`, then append the line read from stdin — so
159/// re-running `host add` with the *same* key is a no-op (removed, then
160/// re-added, byte-identical) and re-running with a *rotated* key actually
161/// replaces the stale line instead of silently keeping it (skip-on-marker
162/// would leave a rotated forced-command key permanently unauthorized, since
163/// `marker` — `install_dir` — is present regardless of which pubkey the line
164/// carries). Backs `remote_file` up to `<remote_file>.bak` the first time
165/// it's touched (never overwrites an existing backup).
166///
167/// `grep -v` exits 1 when it filters everything out of a file (e.g. the file
168/// held only the stale line), so that step is deliberately decoupled from the
169/// `&&` chain with `|| true` — otherwise the very rotation case this is meant
170/// to fix would abort before the new line gets appended.
171///
172/// `remote_file` is interpolated unquoted (as a bare `sh` assignment) so a
173/// leading `~` still tilde-expands — quoting it would suppress that. Every
174/// call site in this file passes a fixed literal, never operator input, so
175/// this is safe.
176fn append_unique_script(remote_file: &str, marker: &str) -> String {
177    let marker_q = shell_words::quote(marker);
178    format!(
179        "f={remote_file}; d=$(dirname \"$f\"); mkdir -p \"$d\" && touch \"$f\" && \
180         line=\"$(cat)\" && {{ [ -f \"$f.bak\" ] || cp \"$f\" \"$f.bak\"; }} && \
181         {{ grep -vF {marker_q} \"$f\" > \"$f.tmp\" 2>/dev/null || true; }} && \
182         printf '%s\\n' \"$line\" >> \"$f.tmp\" && mv \"$f.tmp\" \"$f\""
183    )
184}
185
186/// Append `line` to `remote_file` on the host, idempotently (see
187/// `append_unique_script`).
188pub fn ssh_append_unique(
189    spec: &SshSpec,
190    remote_file: &str,
191    line: &str,
192    marker: &str,
193) -> Result<()> {
194    ssh_run_with_stdin(
195        spec,
196        &append_unique_script(remote_file, marker),
197        line.as_bytes(),
198    )?;
199    Ok(())
200}
201
202/// The remote selfcheck command: `<install_dir>/bin/git-ark selfcheck
203/// --config <install_dir>/config.toml`, both paths shell-quoted like every
204/// other remote command here — an unquoted `install_dir` containing a space
205/// (a spaced remote `$HOME`) would otherwise split apart and false-abort an
206/// otherwise-good host.
207fn selfcheck_command(install_dir: &str) -> String {
208    let bin_q = shell_words::quote(&format!("{install_dir}/bin/git-ark")).into_owned();
209    let cfg_q = shell_words::quote(&format!("{install_dir}/config.toml")).into_owned();
210    format!("{bin_q} selfcheck --config {cfg_q}")
211}
212
213/// `mkdir -p` one or more remote directories, each shell-quoted.
214fn ssh_mkdir_p(spec: &SshSpec, dirs: &[String]) -> Result<()> {
215    let quoted: Vec<String> = dirs
216        .iter()
217        .map(|d| shell_words::quote(d).into_owned())
218        .collect();
219    ssh_run(spec, &format!("mkdir -p {}", quoted.join(" ")))?;
220    Ok(())
221}
222
223// ---------------------------------------------------------------------
224// Client-side paths (keydir, registry, ~/.ssh/config)
225// ---------------------------------------------------------------------
226
227/// Client-side git-ark state directory — per-host forced-command keypairs,
228/// and (absent a `GIT_ARK_HOSTS` override) the host registry. `~/.config/git-ark`:
229/// colocated with the other client-side state (the age `identity.txt`), and
230/// distinct from `~/.ssh` — a per-host key here is git-ark's own, not a
231/// general-purpose SSH identity an operator would expect to find in `~/.ssh`.
232fn client_dir(home: &Path) -> PathBuf {
233    home.join(".config").join("git-ark")
234}
235
236fn home_dir() -> Result<PathBuf> {
237    if let Ok(home) = std::env::var("HOME") {
238        return Ok(PathBuf::from(home));
239    }
240    // Windows sets USERPROFILE, not HOME — the client (unlike the host) is
241    // cross-platform, so fall back rather than erroring on every Windows run.
242    std::env::var("USERPROFILE")
243        .map(PathBuf::from)
244        .context("neither HOME nor USERPROFILE is set")
245}
246
247pub fn client_keydir() -> Result<PathBuf> {
248    Ok(client_dir(&home_dir()?))
249}
250
251pub fn client_ssh_config_path() -> Result<PathBuf> {
252    Ok(home_dir()?.join(".ssh").join("config"))
253}
254
255/// The host registry path: `GIT_ARK_HOSTS` overrides (tests, alternate
256/// profiles); else `<keydir>/hosts.toml`.
257pub fn registry_path() -> Result<PathBuf> {
258    if let Ok(p) = std::env::var("GIT_ARK_HOSTS") {
259        return Ok(PathBuf::from(p));
260    }
261    Ok(client_keydir()?.join("hosts.toml"))
262}
263
264/// Drop the `Host git-ark-<name>` block from `existing`'s lines, keyed by its
265/// `Host` line — every other block is preserved untouched, and trailing blank
266/// lines are trimmed so callers control spacing after. Shared by
267/// `upsert_ssh_config_block` (which re-appends a fresh block) and
268/// `remove_ssh_config_block` (which doesn't).
269fn drop_ssh_config_block<'a>(existing: &'a str, name: &str) -> Vec<&'a str> {
270    let marker = format!("Host git-ark-{name}");
271    let lines: Vec<&str> = existing.lines().collect();
272    let mut kept: Vec<&str> = Vec::new();
273    let mut i = 0;
274    while i < lines.len() {
275        if lines[i].trim() == marker {
276            i += 1;
277            // Drop the old block's body: everything up to the next `Host `
278            // or `Match ` line — ssh_config stanzas are terminated by either
279            // (the trailing space also keeps this from matching `HostName`).
280            while i < lines.len()
281                && !lines[i].trim_start().starts_with("Host ")
282                && !lines[i].trim_start().starts_with("Match ")
283            {
284                i += 1;
285            }
286            continue;
287        }
288        kept.push(lines[i]);
289        i += 1;
290    }
291    while matches!(kept.last(), Some(l) if l.trim().is_empty()) {
292        kept.pop();
293    }
294    kept
295}
296
297/// Insert or replace the `Host git-ark-<name>` block within `existing` (the
298/// current contents of `~/.ssh/config`), keyed by its `Host` line. Every
299/// other block is preserved untouched; a prior block for this name is
300/// replaced in place (idempotent re-runs of `host add`), otherwise the new
301/// block is appended.
302pub fn upsert_ssh_config_block(existing: &str, name: &str, block: &str) -> String {
303    let kept = drop_ssh_config_block(existing, name);
304    let mut out = kept.join("\n");
305    if !out.is_empty() {
306        out.push_str("\n\n");
307    }
308    out.push_str(block.trim_end());
309    out.push('\n');
310    out
311}
312
313/// Remove the `Host git-ark-<name>` block from `existing`, if present. Every
314/// other block is preserved untouched. Used by `host remove` to drop the
315/// client `~/.ssh/config` alias once a host leaves the registry.
316pub fn remove_ssh_config_block(existing: &str, name: &str) -> String {
317    let kept = drop_ssh_config_block(existing, name);
318    if kept.is_empty() {
319        return String::new();
320    }
321    let mut out = kept.join("\n");
322    out.push('\n');
323    out
324}
325
326/// The best `~/.ssh/config` alias that already reaches (`host`, `user`), for
327/// recording as a host's push alias during adopt. Scans every `Host` stanza
328/// (not just git-ark ones); a stanza matches when its `HostName` equals `host`
329/// and, when `user` is given, its `User` equals `user` (a stanza with no `User`
330/// line still matches — ssh falls through to the command-line user). Among the
331/// matching stanzas' aliases, prefers an exact `git-ark-<name>`, then the first
332/// beginning `git-ark`, then the first alias seen. `None` if nothing matches.
333fn find_alias_for_target(
334    ssh_config: &str,
335    host: &str,
336    user: Option<&str>,
337    name: &str,
338) -> Option<String> {
339    let lines: Vec<&str> = ssh_config.lines().collect();
340    let mut candidates: Vec<String> = Vec::new();
341    let mut i = 0;
342    while i < lines.len() {
343        let mut head = lines[i].split_whitespace();
344        if !matches!(head.next(), Some(k) if k.eq_ignore_ascii_case("Host")) {
345            i += 1;
346            continue;
347        }
348        let aliases: Vec<String> = head.map(|s| s.to_string()).collect();
349        // Read the stanza body (until the next Host/Match) for HostName / User.
350        i += 1;
351        let mut hostname: Option<&str> = None;
352        let mut block_user: Option<&str> = None;
353        while i < lines.len() {
354            let mut bt = lines[i].split_whitespace();
355            match bt.next() {
356                Some(k) if k.eq_ignore_ascii_case("Host") || k.eq_ignore_ascii_case("Match") => {
357                    break
358                }
359                Some(k) if k.eq_ignore_ascii_case("HostName") => hostname = bt.next(),
360                Some(k) if k.eq_ignore_ascii_case("User") => block_user = bt.next(),
361                _ => {}
362            }
363            i += 1;
364        }
365        let host_matches = hostname == Some(host);
366        let user_matches = match (user, block_user) {
367            (None, _) | (Some(_), None) => true,
368            (Some(u), Some(bu)) => u == bu,
369        };
370        if host_matches && user_matches {
371            candidates.extend(aliases);
372        }
373    }
374    let exact = format!("git-ark-{name}");
375    candidates
376        .iter()
377        .find(|a| **a == exact)
378        .or_else(|| candidates.iter().find(|a| a.starts_with("git-ark")))
379        .or_else(|| candidates.first())
380        .cloned()
381}
382
383/// Parse `key=value` lines (probe/selfcheck output shape) into a lookup map.
384fn parse_kv(text: &str) -> std::collections::HashMap<String, String> {
385    text.lines()
386        .filter_map(|l| l.split_once('='))
387        .map(|(k, v)| (k.to_string(), v.to_string()))
388        .collect()
389}
390
391// ---------------------------------------------------------------------
392// host add
393// ---------------------------------------------------------------------
394
395pub struct HostAddArgs {
396    pub name: String,
397    pub target: String,
398    pub port: Option<u16>,
399    pub identity: Option<PathBuf>,
400    pub bucket: String,
401    pub region: String,
402    pub prefix: String,
403    pub endpoint: Option<String>,
404    pub recipient: String,
405    /// Binary to ship to the host. `None` auto-fetches the matching release
406    /// asset for this client's version (see `release::fetch_host_binary`).
407    pub binary: Option<PathBuf>,
408    /// Designate this host the GitHub mirror once it's wired and registered.
409    /// Requires `GIT_ARK_GITHUB_TOKEN`; demotes whichever other host
410    /// currently holds the mirror (see `designate_mirror`).
411    pub mirror: bool,
412}
413
414/// Reject a `name` that isn't safe to use as a client keydir path component,
415/// an ssh alias (`Host git-ark-<name>`), and a registry key. Checked before
416/// any other work in `host_add` — unvalidated, `name` could walk
417/// `keydir.join(name)` outside `~/.config/git-ark` (e.g. `../../foo`) or emit
418/// a broken `Host git-ark-<name>` block.
419fn validate_host_name(name: &str) -> Result<()> {
420    let ok = !name.is_empty()
421        && name != "."
422        && name != ".."
423        && name
424            .chars()
425            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
426    if !ok {
427        bail!("host name {name:?} must match [A-Za-z0-9._-]+ (and not be `.` or `..`)");
428    }
429    Ok(())
430}
431
432/// Probe, wire, verify, and register a host. Fails before mutating anything
433/// on the host if the probe/capability check doesn't pass; re-running for
434/// the same `name` is idempotent (re-streams the binary/config, never
435/// duplicates the `authorized_keys`/`~/.ssh/config` entries).
436pub fn host_add(args: &HostAddArgs) -> Result<()> {
437    validate_host_name(&args.name)?;
438
439    // Host credentials, read first — before the probe or any ssh call — so a
440    // missing/empty cred aborts with the host completely untouched, rather
441    // than after dirs are created and the binary streamed.
442    let key_id = std::env::var("GIT_ARK_HOST_S3_KEY_ID")
443        .ok()
444        .filter(|s| !s.is_empty());
445    let secret = std::env::var("GIT_ARK_HOST_S3_SECRET")
446        .ok()
447        .filter(|s| !s.is_empty());
448    let (key_id, secret) = match (key_id, secret) {
449        (Some(k), Some(s)) => (k, s),
450        _ => bail!(
451            "GIT_ARK_HOST_S3_KEY_ID and GIT_ARK_HOST_S3_SECRET must both be set \
452             (the host's write-only S3 credential); nothing was written to the host"
453        ),
454    };
455
456    let (user, host) = parse_target(&args.target)?;
457    let spec = SshSpec {
458        user: user.clone(),
459        host: host.clone(),
460        port: args.port,
461        identity: args.identity.clone(),
462    };
463
464    // 1. Probe + capability check. Nothing is written to the host until this
465    // passes — a red probe leaves the box exactly as found.
466    let probe_out = ssh_run(&spec, PROBE_SCRIPT)
467        .inspect_err(|e| {
468            // Diagnosis is a side note on the way to the same error, not a
469            // replacement for it — `Other` prints nothing rather than
470            // fabricate advice for an unrecognized failure.
471            let diagnosis = classify_ssh_error(&e.to_string());
472            if diagnosis != SshDiagnosis::Other {
473                eprintln!(
474                    "{}",
475                    diagnosis_message(diagnosis, &spec.display(), args.port.unwrap_or(22))
476                );
477            }
478        })
479        .with_context(|| format!("probing {}", spec.display()))?;
480    let facts = parse_probe(&probe_out);
481    let plan = match assess(&facts) {
482        Ok(plan) => plan,
483        Err(reasons) => {
484            eprintln!("✗ {} is not ready for git-ark:", spec.display());
485            for r in &reasons {
486                eprintln!("  - {r}");
487            }
488            bail!(
489                "preflight probe failed ({} reason(s)); nothing was written to the host",
490                reasons.len()
491            );
492        }
493    };
494
495    // 2. Resolve the binary. With `--binary`, read that path (a filename that
496    // doesn't look like the target triple is a warning, not a hard stop — it
497    // may be a renamed/symlinked binary that's still correct). Without it,
498    // auto-fetch the matching release asset for the probed triple, verified
499    // against the release SHA256SUMS.
500    let binary_bytes = match &args.binary {
501        Some(path) => {
502            let bytes = std::fs::read(path)
503                .with_context(|| format!("reading --binary {}", path.display()))?;
504            if !path.to_string_lossy().contains(&plan.triple) {
505                eprintln!(
506                    "⚠ --binary {} doesn't look like it's built for {} (the probed host's triple) — continuing anyway",
507                    path.display(),
508                    plan.triple
509                );
510            }
511            bytes
512        }
513        None => {
514            println!(
515                "• fetching git-ark v{} for {} from the release…",
516                release::version(),
517                plan.triple
518            );
519            release::fetch_host_binary(&plan.triple)?
520        }
521    };
522
523    // 3. Forced-command keypair — generated client-side; only the public
524    // half ever reaches the host. Skip if a key for this name already exists
525    // (idempotent re-run).
526    let keydir = client_keydir()?;
527    std::fs::create_dir_all(&keydir).with_context(|| format!("creating {}", keydir.display()))?;
528    let key_path = keydir.join(&args.name);
529    let pub_path = keydir.join(format!("{}.pub", args.name));
530    if !key_path.exists() {
531        let status = Command::new("ssh-keygen")
532            .args(["-t", "ed25519", "-N", "", "-q"])
533            .arg("-f")
534            .arg(&key_path)
535            .arg("-C")
536            .arg(format!("git-ark-{}", args.name))
537            .status()
538            .context("spawning ssh-keygen")?;
539        if !status.success() {
540            bail!("ssh-keygen failed generating {}", key_path.display());
541        }
542    }
543    let pubkey = std::fs::read_to_string(&pub_path)
544        .with_context(|| format!("reading {}", pub_path.display()))?
545        .trim()
546        .to_string();
547
548    // 4. Remote install dirs.
549    ssh_mkdir_p(
550        &spec,
551        &[
552            format!("{}/bin", plan.install_dir),
553            format!("{}/repos", plan.install_dir),
554        ],
555    )?;
556
557    // 5. Stream the binary.
558    ssh_pipe_file(
559        &spec,
560        &format!("{}/bin/git-ark", plan.install_dir),
561        &binary_bytes,
562        0o755,
563    )?;
564
565    // 6. Stream config.toml and secrets.toml. The host's write-only S3
566    // credential was read from env at the top of this fn — never argv,
567    // never printed. The rendered config's prefix is namespaced by host name
568    // (per_host_prefix) so each host writes its own S3 subtree — the
569    // registry keeps `args.prefix` as the shared base (see the `Host {..}`
570    // below). No `--mirror` yet (Task 3); every host add is a plain,
571    // non-mirror host until then.
572    let s3 = S3Config {
573        bucket: args.bucket.clone(),
574        region: args.region.clone(),
575        prefix: per_host_prefix(&args.prefix, &args.name),
576        endpoint: args.endpoint.clone(),
577    };
578    let config_text = render_config(&plan.install_dir, &args.recipient, &s3, false);
579    ssh_pipe_file(
580        &spec,
581        &format!("{}/config.toml", plan.install_dir),
582        config_text.as_bytes(),
583        0o644,
584    )?;
585    let secrets_text = render_secrets(&key_id, &secret, None);
586    ssh_pipe_file(
587        &spec,
588        &format!("{}/secrets.toml", plan.install_dir),
589        secrets_text.as_bytes(),
590        0o600,
591    )?;
592
593    // 7. Install the forced-command key, replacing any existing line for
594    // this install_dir (marker = install_dir, baked into the command= it's
595    // paired with) — so a rotated/regenerated key replaces the stale one
596    // instead of silently no-opping (see `append_unique_script`).
597    let line = forced_command_line(&plan.install_dir, &pubkey);
598    ssh_append_unique(&spec, "~/.ssh/authorized_keys", &line, &plan.install_dir)?;
599
600    // 8. Verify. A failed/unhealthy selfcheck aborts here — the host is left
601    // wired as-is (not rolled back) so the operator can inspect and re-run.
602    let selfcheck_out = ssh_run(&spec, &selfcheck_command(&plan.install_dir))?;
603    let selfcheck = parse_kv(&selfcheck_out);
604    let config_valid = selfcheck.get("config_valid").map(String::as_str) == Some("true");
605    let version = selfcheck.get("git_ark_version");
606    if !config_valid || version.is_none() {
607        bail!(
608            "selfcheck did not report a healthy host (config_valid={:?}, git_ark_version={:?}); \
609             the host has been wired, but `host add {}` should be re-run once fixed",
610            selfcheck.get("config_valid"),
611            version,
612            args.name
613        );
614    }
615
616    // 9. Client ~/.ssh/config alias, idempotent by `Host git-ark-<name>`.
617    let port = args.port.unwrap_or(22);
618    let block = ssh_config_block(
619        &args.name,
620        &host,
621        port,
622        &user,
623        &key_path.display().to_string(),
624    );
625    let ssh_config_path = client_ssh_config_path()?;
626    if let Some(parent) = ssh_config_path.parent() {
627        std::fs::create_dir_all(parent)
628            .with_context(|| format!("creating {}", parent.display()))?;
629    }
630    let existing = std::fs::read_to_string(&ssh_config_path).unwrap_or_default();
631    let updated = upsert_ssh_config_block(&existing, &args.name, &block);
632    std::fs::write(&ssh_config_path, updated)
633        .with_context(|| format!("writing {}", ssh_config_path.display()))?;
634
635    // 10. Register.
636    let registry_path = registry_path()?;
637    let mut registry = Registry::load(&registry_path)?;
638    registry.upsert(Host {
639        name: args.name.clone(),
640        ssh_target: args.target.clone(),
641        port,
642        // The CONTROL-channel identity (the operator's interactive key), NOT the
643        // forced-command key: management ops (apply_host_config / mirror set /
644        // upgrade) run arbitrary commands and must use the control channel, or
645        // they hit the git-only forced-command shim. The forced-command key is
646        // used solely by the git-push ssh alias (`git-ark-<name>`).
647        identity: args.identity.clone(),
648        triple: plan.triple.clone(),
649        install_dir: plan.install_dir.clone(),
650        recipient: args.recipient.clone(),
651        bucket: args.bucket.clone(),
652        region: args.region.clone(),
653        prefix: args.prefix.clone(),
654        endpoint: args.endpoint.clone(),
655        mirror: false,
656        // Conventional host — the alias is `git-ark-<name>`, which host_add just
657        // wrote. `None` resolves to exactly that; recording it would be noise.
658        push_alias: None,
659    });
660    registry.save(&registry_path)?;
661
662    println!(
663        "✓ {} ready — git push git-ark-{}:<repo>",
664        args.name, args.name
665    );
666    if let (Some(pct), Some(bytes)) = (
667        selfcheck.get("disk_free_percent"),
668        selfcheck.get("disk_free_bytes"),
669    ) {
670        println!("  disk: {pct}% free ({bytes} bytes)");
671    }
672
673    // 11. --mirror: designate this host the GitHub mirror now that it's
674    // wired + registered. Reuses the `registry` already in scope (it holds
675    // the just-upserted host, mirror=false) — `designate_mirror` re-renders
676    // this host's config/secrets a second time (mirror=true, token written)
677    // and, if some other host was the mirror, demotes it too.
678    if args.mirror {
679        let token = match std::env::var("GIT_ARK_GITHUB_TOKEN")
680            .ok()
681            .filter(|s| !s.is_empty())
682        {
683            Some(t) => t,
684            None => bail!(
685                "GIT_ARK_GITHUB_TOKEN must be set to add {} as the GitHub mirror (--mirror); \
686                 the host was still added, just not designated the mirror \
687                 (run `git-ark mirror set {}` once the token is set)",
688                args.name,
689                args.name
690            ),
691        };
692        designate_mirror(&mut registry, &args.name, &token)?;
693        println!("✓ {} designated the GitHub mirror", args.name);
694    }
695
696    Ok(())
697}
698
699// ---------------------------------------------------------------------
700// host adopt — register an already-deployed host without touching it
701// ---------------------------------------------------------------------
702
703/// Inputs for `host adopt`.
704pub struct HostAdoptArgs {
705    pub name: String,
706    pub target: String,
707    pub port: Option<u16>,
708    pub identity: Option<PathBuf>,
709    /// Path to the host's `config.toml`; if omitted, adopt reads it from the
710    /// git-ark forced-command line in the host's `authorized_keys`.
711    pub config: Option<String>,
712    /// The push alias to record, overriding discovery. Use when `~/.ssh/config`
713    /// has several stanzas for this target, or none and you want to name it.
714    pub push_alias: Option<String>,
715}
716
717/// Adopt an already-deployed host into the client registry — the recovery path
718/// (a lost registry) and the way to bring in a host wired before the registry
719/// existed. Reconstructs the registry entry from the host's deployed surface
720/// over the control channel, verifies with `selfcheck`, and registers it.
721///
722/// It is **read-only on the host** and writes **nothing** to the client's ssh
723/// config (no `git-ark-<name>` alias) — so existing remotes and aliases are
724/// untouched. `status`/`upgrade` ride the control channel and need no alias.
725pub fn host_adopt(args: &HostAdoptArgs) -> Result<()> {
726    validate_host_name(&args.name)?;
727    let (user, host) = parse_target(&args.target)?;
728    let spec = SshSpec {
729        user,
730        host,
731        port: args.port,
732        identity: args.identity.clone(),
733    };
734
735    // 1. Where does git-ark live on the host? Explicit --config, else parse the
736    //    forced-command line in authorized_keys.
737    let config_path = match &args.config {
738        Some(c) => c.clone(),
739        None => {
740            let ak = ssh_run(&spec, "cat ~/.ssh/authorized_keys 2>/dev/null || true")?;
741            parse_forced_command_config(&ak).ok_or_else(|| {
742                anyhow!(
743                    "no git-ark forced-command line found in {}'s authorized_keys — \
744                     pass --config <path-to-config.toml> to point me at it",
745                    args.target
746                )
747            })?
748        }
749    };
750    let install_dir = std::path::Path::new(&config_path)
751        .parent()
752        .and_then(|p| p.to_str())
753        .ok_or_else(|| anyhow!("can't derive the install dir from {config_path}"))?
754        .to_string();
755
756    // 2. Triple from uname.
757    let triple = triple_from_uname(&ssh_run(&spec, "uname -s; uname -m")?)?;
758
759    // 3. Read the host's config.toml → the vault fields for a complete entry.
760    let cfg_text = ssh_run(&spec, &format!("cat {}", shell_words::quote(&config_path)))?;
761    let cfg: toml::Value =
762        toml::from_str(&cfg_text).map_err(|e| anyhow!("parsing the host's config.toml: {e}"))?;
763    let str_at = |v: Option<&toml::Value>| v.and_then(|x| x.as_str()).unwrap_or("").to_string();
764    let s3 = cfg.get("s3");
765    let recipient = str_at(cfg.get("age_recipient"));
766    let bucket = str_at(s3.and_then(|v| v.get("bucket")));
767    let region = str_at(s3.and_then(|v| v.get("region")));
768    let prefix = str_at(s3.and_then(|v| v.get("prefix")));
769    let endpoint = s3
770        .and_then(|v| v.get("endpoint"))
771        .and_then(|v| v.as_str())
772        .map(str::to_string);
773    let mirror = cfg.get("mirror").and_then(|v| v.as_bool()).unwrap_or(false);
774
775    // 4. Verify with selfcheck (read-only). Use the exact config path found.
776    let bin_q = shell_words::quote(&format!("{install_dir}/bin/git-ark")).into_owned();
777    let cfg_q = shell_words::quote(&config_path).into_owned();
778    let selfcheck = ssh_run(&spec, &format!("{bin_q} selfcheck --config {cfg_q}"))
779        .context("running the host's git-ark selfcheck")?;
780    if !selfcheck.contains("config_valid=true") {
781        bail!(
782            "selfcheck didn't confirm a working git-ark at {install_dir} on {}:\n{}",
783            args.target,
784            selfcheck.trim()
785        );
786    }
787    let version = parse_kv(&selfcheck)
788        .get("git_ark_version")
789        .cloned()
790        .unwrap_or_else(|| "?".into());
791
792    // 5. Discover the push alias — an explicit --push-alias wins; else read the
793    //    client's own ~/.ssh/config (still touching nothing) for a stanza that
794    //    already reaches this target. `None` if there's nothing to discover:
795    //    status/upgrade still work; push/route need an alias set later.
796    let push_alias = args.push_alias.clone().or_else(|| {
797        client_ssh_config_path()
798            .ok()
799            .and_then(|p| std::fs::read_to_string(p).ok())
800            .and_then(|cfg| find_alias_for_target(&cfg, &spec.host, Some(&spec.user), &args.name))
801    });
802
803    // 6. Register — the ONLY thing written. No ssh alias, no host mutation.
804    let registry_path = registry_path()?;
805    let mut registry = Registry::load(&registry_path)?;
806    registry.upsert(Host {
807        name: args.name.clone(),
808        ssh_target: args.target.clone(),
809        port: args.port.unwrap_or(22),
810        identity: args.identity.clone(),
811        triple,
812        install_dir,
813        recipient,
814        bucket,
815        region,
816        prefix,
817        endpoint,
818        mirror,
819        push_alias: push_alias.clone(),
820    });
821    registry.save(&registry_path)?;
822
823    println!(
824        "✓ adopted '{}' (git-ark {version}) — registered from its deployed config; \
825         nothing on the host or in your ssh config was touched",
826        args.name
827    );
828    println!("  it now appears in `git-ark status` and can be `git-ark upgrade`d.");
829    if mirror {
830        println!("  (it holds the GitHub mirror.)");
831    }
832    match &push_alias {
833        Some(alias) => println!("  push alias: {alias} — `git-ark route` and pushes will use it."),
834        None => println!(
835            "  no push alias found in ~/.ssh/config — status/upgrade work; set one with \
836             `git-ark host adopt {} {} --push-alias <alias>` to enable push/route.",
837            args.name, args.target
838        ),
839    }
840    Ok(())
841}
842
843/// Extract the `--config <path>` from a git-ark forced-command line in an
844/// `authorized_keys` body. The path ends at the first quote or whitespace.
845pub fn parse_forced_command_config(authorized_keys: &str) -> Option<String> {
846    for line in authorized_keys.lines() {
847        if !line.contains("git-ark") {
848            continue;
849        }
850        if let Some(idx) = line.find("--config ") {
851            let rest = &line[idx + "--config ".len()..];
852            let end = rest
853                .find(|c: char| c == '"' || c.is_whitespace())
854                .unwrap_or(rest.len());
855            let path = &rest[..end];
856            if !path.is_empty() {
857                return Some(path.to_string());
858            }
859        }
860    }
861    None
862}
863
864/// Map `uname -s` / `uname -m` output (two lines) to a git-ark release triple.
865pub fn triple_from_uname(uname: &str) -> Result<String> {
866    let mut lines = uname.lines().map(str::trim);
867    let os = lines.next().unwrap_or("");
868    let arch = lines.next().unwrap_or("");
869    match (os, arch) {
870        ("Linux", "x86_64") => Ok("x86_64-unknown-linux-musl".to_string()),
871        ("Linux", "aarch64" | "arm64") => Ok("aarch64-unknown-linux-musl".to_string()),
872        _ => bail!("unsupported host {os}/{arch} — git-ark hosts are Linux x86_64 or aarch64"),
873    }
874}
875
876// ---------------------------------------------------------------------
877// host list / host remove
878// ---------------------------------------------------------------------
879
880/// Print the registry, one line per host as `name  ssh_target  triple`,
881/// sorted by name. `Registry::list()` is in insertion order (only `save`
882/// sorts on disk), so sort a copy here — an empty/missing registry prints
883/// nothing.
884pub fn host_list() -> Result<()> {
885    let registry = Registry::load(&registry_path()?)?;
886    let mut hosts: Vec<&Host> = registry.list().iter().collect();
887    hosts.sort_by(|a, b| a.name.cmp(&b.name));
888    for h in hosts {
889        println!("{}  {}  {}", h.name, h.ssh_target, h.triple);
890    }
891    Ok(())
892}
893
894/// One host as clients see it over `host list --json`. `alias` is the RESOLVED
895/// push alias (`Host::push_alias()`), never the raw `Option`, so a consumer
896/// never re-derives it. The shape evolves additively: fields may be added; a
897/// consumer tolerates unknown ones.
898#[derive(Debug, serde::Serialize)]
899struct HostRow<'a> {
900    name: &'a str,
901    alias: String,
902    ssh_target: &'a str,
903    port: u16,
904    triple: &'a str,
905    bucket: &'a str,
906    region: &'a str,
907    prefix: &'a str,
908    endpoint: Option<&'a str>,
909    mirror: bool,
910}
911
912/// The machine-readable fleet: one row per host, sorted by name. Pure over the
913/// registry so it's unit-testable without touching disk.
914fn host_rows(registry: &Registry) -> Vec<HostRow<'_>> {
915    let mut hosts: Vec<&Host> = registry.list().iter().collect();
916    hosts.sort_by(|a, b| a.name.cmp(&b.name));
917    hosts
918        .into_iter()
919        .map(|h| HostRow {
920            name: &h.name,
921            alias: h.push_alias(),
922            ssh_target: &h.ssh_target,
923            port: h.port,
924            triple: &h.triple,
925            bucket: &h.bucket,
926            region: &h.region,
927            prefix: &h.prefix,
928            endpoint: h.endpoint.as_deref(),
929            mirror: h.mirror,
930        })
931        .collect()
932}
933
934/// `host list --json`: the client contract for resolving a host name to its
935/// push alias and backend. Emits a flat JSON array of [`HostRow`].
936pub fn host_list_json() -> Result<()> {
937    let registry = Registry::load(&registry_path()?)?;
938    let rows = host_rows(&registry);
939    println!("{}", serde_json::to_string_pretty(&rows)?);
940    Ok(())
941}
942
943/// Remove `name` from the registry and drop its `~/.ssh/config` alias block.
944/// A name not in the registry is reported, not an error — there's nothing to
945/// retry.
946pub fn host_remove(name: &str) -> Result<()> {
947    let registry_path = registry_path()?;
948    let mut registry = Registry::load(&registry_path)?;
949    if !registry.remove(name) {
950        println!("no such host: {name}");
951        return Ok(());
952    }
953    registry.save(&registry_path)?;
954
955    // Drop the SSH alias too, if the client has one. A missing ~/.ssh/config
956    // means there's nothing to drop — don't create the file just to remove
957    // from it.
958    let ssh_config_path = client_ssh_config_path()?;
959    if let Ok(existing) = std::fs::read_to_string(&ssh_config_path) {
960        let updated = remove_ssh_config_block(&existing, name);
961        std::fs::write(&ssh_config_path, updated)
962            .with_context(|| format!("writing {}", ssh_config_path.display()))?;
963    }
964
965    println!("removed {name}");
966    Ok(())
967}
968
969// ---------------------------------------------------------------------
970// host discover
971// ---------------------------------------------------------------------
972
973/// The `a.b.c` prefix of `ip`'s /24, for the empty-result message.
974fn base_prefix(ip: std::net::Ipv4Addr) -> String {
975    let o = ip.octets();
976    format!("{}.{}.{}", o[0], o[1], o[2])
977}
978
979/// Scan the local `/24` for hosts answering on `port`, printing each reachable
980/// `ip:port` — labeled `(known: <name>)` when the IP already appears in a
981/// registered host's `ssh_target`. Finding nothing is success (exit 0), not
982/// an error; a probe is a bare TCP connect, no auth, no data.
983pub fn host_discover(port: u16, timeout_ms: u64, subnet: Option<std::net::Ipv4Addr>) -> Result<()> {
984    let base = match subnet {
985        Some(b) => b,
986        None => crate::subnet::local_ipv4()
987            .context("could not determine the local IPv4 address; pass --subnet a.b.c.0")?,
988    };
989    let ips = crate::subnet::hosts_in_slash24(base);
990    let found =
991        crate::scan::scan_port(&ips, port, std::time::Duration::from_millis(timeout_ms), 64);
992
993    // Cross-reference the registry so already-added hosts are labeled.
994    let reg = Registry::load(&registry_path()?).unwrap_or_default();
995    if found.is_empty() {
996        println!(
997            "no hosts responding on port {port} in {}.0/24",
998            base_prefix(base)
999        );
1000        return Ok(());
1001    }
1002    for ip in found {
1003        let s = ip.to_string();
1004        match reg.list().iter().find(|h| h.ssh_target.contains(&s)) {
1005            Some(h) => println!("{s}:{port}  (known: {})", h.name),
1006            None => println!("{s}:{port}"),
1007        }
1008    }
1009    Ok(())
1010}
1011
1012// ---------------------------------------------------------------------
1013// status — fleet health/liveness/drift
1014// ---------------------------------------------------------------------
1015
1016/// One host's status, as `probe_status` diagnoses it. `Reachable` carries
1017/// exactly what the host's own `selfcheck` reported; `Broken` is a real ssh
1018/// connection that came back unhealthy (selfcheck errored); `Unreachable` is
1019/// no connection at all — the graduated diagnosis `status` needs to tell
1020/// "the box is off" from "the box is up but git-ark is broken on it".
1021#[derive(Debug, Clone, PartialEq)]
1022pub enum HostStatus {
1023    Reachable {
1024        version: String,
1025        disk_free: u64,
1026        disk_low: bool,
1027        config_valid: bool,
1028    },
1029    Broken(String),
1030    Unreachable,
1031}
1032
1033/// Probe `host` over the control channel: run its own `selfcheck` and parse
1034/// the result. Never errors — a dead or misconfigured host is a `HostStatus`
1035/// variant, not a `Result::Err`, so `status()` can report the whole fleet
1036/// even when part of it is down.
1037pub fn probe_status(host: &Host) -> HostStatus {
1038    let (user, hostname) = match parse_target(&host.ssh_target) {
1039        Ok(v) => v,
1040        Err(e) => return HostStatus::Broken(e.to_string()),
1041    };
1042    let spec = SshSpec {
1043        user,
1044        host: hostname,
1045        port: Some(host.port),
1046        identity: host.identity.clone(),
1047    };
1048
1049    match ssh_run(&spec, &selfcheck_command(&host.install_dir)) {
1050        Ok(out) => {
1051            let kv = parse_kv(&out);
1052            let version = kv.get("git_ark_version").cloned().unwrap_or_default();
1053            let disk_free = kv
1054                .get("disk_free_bytes")
1055                .and_then(|s| s.parse::<u64>().ok())
1056                .unwrap_or(0);
1057            let disk_low = kv.get("disk_low").map(String::as_str) == Some("true");
1058            let config_valid = kv.get("config_valid").map(String::as_str) == Some("true");
1059            HostStatus::Reachable {
1060                version,
1061                disk_free,
1062                disk_low,
1063                config_valid,
1064            }
1065        }
1066        // The selfcheck itself failed (bad config, missing binary, …) —
1067        // distinguish that from no connection at all with a cheap `true`.
1068        Err(_) => match ssh_run(&spec, "true") {
1069            Ok(_) => HostStatus::Broken("selfcheck failed".to_string()),
1070            Err(_) => HostStatus::Unreachable,
1071        },
1072    }
1073}
1074
1075/// Render one `status` row. PURE — `probe_status` does the SSH; this only
1076/// formats what it found. `mirror` (from the registry entry, not carried on
1077/// `HostStatus`) appends a `★ mirror` marker.
1078pub fn format_status_row(name: &str, mirror: bool, s: &HostStatus) -> String {
1079    let mirror_marker = if mirror { "  ★ mirror" } else { "" };
1080    match s {
1081        HostStatus::Reachable {
1082            version,
1083            disk_free,
1084            disk_low,
1085            config_valid,
1086        } => {
1087            let mark = if *disk_low { "⚠" } else { "✓" };
1088            let mut line = format!(
1089                "{mark} {name}  v{version}  {} free",
1090                crate::backup::human_bytes(*disk_free)
1091            );
1092            if *disk_low {
1093                line.push_str("  disk low");
1094            }
1095            if !config_valid {
1096                line.push_str("  config invalid");
1097            }
1098            line.push_str(mirror_marker);
1099            line
1100        }
1101        HostStatus::Broken(reason) => format!("✗ {name}  broken: {reason}{mirror_marker}"),
1102        HostStatus::Unreachable => format!("✗ {name}  unreachable{mirror_marker}"),
1103    }
1104}
1105
1106/// Print a fleet health/liveness/drift dashboard: one row per registered
1107/// host, probed over the control channel. Never fails the process on a
1108/// dead/broken host — that's the point of the dashboard — only on a genuine
1109/// local error (e.g. an unreadable registry).
1110pub fn status() -> Result<()> {
1111    let registry = Registry::load(&registry_path()?)?;
1112    let mut hosts: Vec<&Host> = registry.list().iter().collect();
1113    if hosts.is_empty() {
1114        println!("no hosts — run `git-ark host add`");
1115        return Ok(());
1116    }
1117    hosts.sort_by(|a, b| a.name.cmp(&b.name));
1118    for h in hosts {
1119        let s = probe_status(h);
1120        println!("{}", format_status_row(&h.name, h.mirror, &s));
1121    }
1122    Ok(())
1123}
1124
1125// ---------------------------------------------------------------------
1126// upgrade — push a new binary to a host (or all) from the client
1127// ---------------------------------------------------------------------
1128
1129/// Resolve which hosts `upgrade` targets. `all` wins outright — every
1130/// registered host, bailing on an empty registry (nothing to upgrade) —
1131/// without even looking at `names`, so a stray unknown name alongside
1132/// `--all` never turns a fleet-wide upgrade into an error. Absent `all`,
1133/// explicit `names` must each resolve to a registered host (bailing on the
1134/// first that doesn't); naming neither is an error, not a silent no-op.
1135fn resolve_upgrade_targets(registry: &Registry, names: &[String], all: bool) -> Result<Vec<Host>> {
1136    if all {
1137        if registry.list().is_empty() {
1138            bail!("no hosts registered — nothing to upgrade");
1139        }
1140        return Ok(registry.list().to_vec());
1141    }
1142    if names.is_empty() {
1143        bail!("name a host or pass --all");
1144    }
1145    names
1146        .iter()
1147        .map(|name| {
1148            registry
1149                .list()
1150                .iter()
1151                .find(|h| &h.name == name)
1152                .cloned()
1153                .ok_or_else(|| {
1154                    anyhow::anyhow!(
1155                        "no such host: {name} (run `git-ark host list` to see registered hosts)"
1156                    )
1157                })
1158        })
1159        .collect()
1160}
1161
1162/// Stream `bytes` to `host`'s binary path and re-verify with its own
1163/// `selfcheck`, printing the version it now reports. Split out of `upgrade`
1164/// so a per-host failure can be caught there with `?` and reported without
1165/// aborting the rest of the fleet.
1166fn upgrade_one(host: &Host, bytes: &[u8]) -> Result<()> {
1167    let (user, hostname) = parse_target(&host.ssh_target)?;
1168    let spec = SshSpec {
1169        user,
1170        host: hostname,
1171        port: Some(host.port),
1172        identity: host.identity.clone(),
1173    };
1174    ssh_pipe_file(
1175        &spec,
1176        &format!("{}/bin/git-ark", host.install_dir),
1177        bytes,
1178        0o755,
1179    )?;
1180    let out = ssh_run(&spec, &selfcheck_command(&host.install_dir))?;
1181    let kv = parse_kv(&out);
1182    let version = kv.get("git_ark_version").cloned().unwrap_or_default();
1183    println!("✓ {} upgraded → {version}", host.name);
1184    Ok(())
1185}
1186
1187/// Push `binary` to `names` (or every registered host, with `all`) over the
1188/// control channel and re-verify each with `selfcheck`. The binary is read
1189/// once and streamed unchanged to every target. A per-host failure is
1190/// printed and does not abort the rest — the fleet-wide `--all` case must
1191/// finish attempting every host before this returns a non-zero error, so a
1192/// script driving it can detect a partial upgrade without losing the hosts
1193/// that did succeed.
1194pub fn upgrade(names: &[String], all: bool, binary: Option<&Path>) -> Result<()> {
1195    let registry = Registry::load(&registry_path()?)?;
1196    let targets = resolve_upgrade_targets(&registry, names, all)?;
1197
1198    // With --binary, read it once — it applies to every target. Without it,
1199    // auto-fetch the matching release asset per host triple, caching by triple
1200    // so hosts of the same arch share a single download.
1201    let explicit = match binary {
1202        Some(path) => Some(
1203            std::fs::read(path).with_context(|| format!("reading --binary {}", path.display()))?,
1204        ),
1205        None => None,
1206    };
1207    let mut fetched: HashMap<String, Vec<u8>> = HashMap::new();
1208
1209    let mut any_failed = false;
1210    for host in &targets {
1211        let bytes: &[u8] = if let Some(explicit) = &explicit {
1212            if let Some(path) = binary {
1213                if !path.to_string_lossy().contains(&host.triple) {
1214                    eprintln!(
1215                        "⚠ --binary {} doesn't look like it's built for {}'s triple ({}) — continuing anyway",
1216                        path.display(),
1217                        host.name,
1218                        host.triple
1219                    );
1220                }
1221            }
1222            explicit
1223        } else {
1224            if !fetched.contains_key(&host.triple) {
1225                println!(
1226                    "• fetching git-ark v{} for {} ({})…",
1227                    release::version(),
1228                    host.name,
1229                    host.triple
1230                );
1231                match release::fetch_host_binary(&host.triple) {
1232                    Ok(b) => {
1233                        fetched.insert(host.triple.clone(), b);
1234                    }
1235                    Err(e) => {
1236                        println!("✗ {}: {e}", host.name);
1237                        any_failed = true;
1238                        continue;
1239                    }
1240                }
1241            }
1242            &fetched[&host.triple]
1243        };
1244
1245        if let Err(e) = upgrade_one(host, bytes) {
1246            println!("✗ {}: {e}", host.name);
1247            any_failed = true;
1248        }
1249    }
1250
1251    if any_failed {
1252        bail!("one or more hosts failed to upgrade — see above");
1253    }
1254    Ok(())
1255}
1256
1257// ---------------------------------------------------------------------
1258// mirror designation
1259// ---------------------------------------------------------------------
1260
1261/// Pure singleton-selection: given the current registry and a target host
1262/// `name`, decide which host (if any) must be demoted and which is promoted.
1263/// `target` is always promoted; `demote` is `Some(other)` when some *other*
1264/// host currently holds `mirror==true` — `None` when there's no current
1265/// mirror, or the target already is it (both are no-op demotes).
1266fn mirror_transition(registry: &Registry, target: &str) -> (Option<String>, String) {
1267    let demote = registry
1268        .mirror_host()
1269        .filter(|h| h.name != target)
1270        .map(|h| h.name.clone());
1271    (demote, target.to_string())
1272}
1273
1274/// Re-render and re-push `config.toml`/`secrets.toml` to `host` over SSH —
1275/// the same shape `host_add` writes on first wiring, minus the probe/binary/
1276/// authorized_keys steps (the host is already fully wired; only its config
1277/// changes). `config.toml` gets the host's stored S3 values + its per-host
1278/// prefix + `mirror`; `secrets.toml` gets the host's write-only S3
1279/// credential (from `GIT_ARK_HOST_S3_KEY_ID`/`GIT_ARK_HOST_S3_SECRET` — so
1280/// reassigning the mirror needs these set too, same as `host add`) plus
1281/// `github_token`.
1282fn apply_host_config(host: &Host, mirror: bool, github_token: Option<&str>) -> Result<()> {
1283    let key_id = std::env::var("GIT_ARK_HOST_S3_KEY_ID")
1284        .ok()
1285        .filter(|s| !s.is_empty());
1286    let secret = std::env::var("GIT_ARK_HOST_S3_SECRET")
1287        .ok()
1288        .filter(|s| !s.is_empty());
1289    let (key_id, secret) = match (key_id, secret) {
1290        (Some(k), Some(s)) => (k, s),
1291        _ => bail!(
1292            "GIT_ARK_HOST_S3_KEY_ID and GIT_ARK_HOST_S3_SECRET must both be set to re-apply \
1293             {}'s config (changing the mirror re-renders secrets.toml, which needs the host's \
1294             write-only S3 credential, not just the GitHub token)",
1295            host.name
1296        ),
1297    };
1298
1299    let (user, hostname) = parse_target(&host.ssh_target)?;
1300    let spec = SshSpec {
1301        user,
1302        host: hostname,
1303        port: Some(host.port),
1304        identity: host.identity.clone(),
1305    };
1306
1307    let s3 = S3Config {
1308        bucket: host.bucket.clone(),
1309        region: host.region.clone(),
1310        prefix: per_host_prefix(&host.prefix, &host.name),
1311        endpoint: host.endpoint.clone(),
1312    };
1313    let config_text = render_config(&host.install_dir, &host.recipient, &s3, mirror);
1314    ssh_pipe_file(
1315        &spec,
1316        &format!("{}/config.toml", host.install_dir),
1317        config_text.as_bytes(),
1318        0o644,
1319    )?;
1320    let secrets_text = render_secrets(&key_id, &secret, github_token);
1321    ssh_pipe_file(
1322        &spec,
1323        &format!("{}/secrets.toml", host.install_dir),
1324        secrets_text.as_bytes(),
1325        0o600,
1326    )?;
1327    Ok(())
1328}
1329
1330/// Enforce the client-enforced single-mirror invariant: reassign the GitHub
1331/// mirror designation to `name`, demoting whichever other host currently
1332/// holds it — config `mirror=false`, secrets stripped of `[github]` (a
1333/// client-side revocation) — before promoting the target — config
1334/// `mirror=true`, secrets carrying `github_token`. Bails, before touching any
1335/// host, if `name` isn't registered.
1336fn designate_mirror(registry: &mut Registry, name: &str, github_token: &str) -> Result<()> {
1337    if !registry.list().iter().any(|h| h.name == name) {
1338        bail!("no such host: {name} (run `git-ark host list` to see registered hosts)");
1339    }
1340    let (demote, promote) = mirror_transition(registry, name);
1341
1342    if let Some(old_name) = &demote {
1343        let old = registry
1344            .list()
1345            .iter()
1346            .find(|h| &h.name == old_name)
1347            .expect("mirror_transition only names a host present in the registry")
1348            .clone();
1349        apply_host_config(&old, false, None)?;
1350        registry
1351            .hosts
1352            .iter_mut()
1353            .find(|h| h.name == old.name)
1354            .expect("just found above")
1355            .mirror = false;
1356        // Persist the demotion NOW: if the promote below fails, the on-disk
1357        // registry must not still claim this demoted, token-stripped host is the
1358        // mirror. Worst case is a transient no-mirror that a re-run repairs.
1359        registry.save(&registry_path()?)?;
1360    }
1361
1362    let target = registry
1363        .list()
1364        .iter()
1365        .find(|h| h.name == promote)
1366        .expect("checked at the top of this fn")
1367        .clone();
1368    apply_host_config(&target, true, Some(github_token)).with_context(|| {
1369        format!(
1370            "demoted the previous mirror but could not promote {promote} — the fleet has no \
1371             mirror right now; re-run `git-ark mirror set {promote}` once it is reachable"
1372        )
1373    })?;
1374    registry
1375        .hosts
1376        .iter_mut()
1377        .find(|h| h.name == promote)
1378        .expect("just found above")
1379        .mirror = true;
1380
1381    registry.save(&registry_path()?)?;
1382    Ok(())
1383}
1384
1385/// Reassign the GitHub mirror to `name`: load the registry, require the host
1386/// exists, read the token from `GIT_ARK_GITHUB_TOKEN` (never argv), and
1387/// enforce the singleton via `designate_mirror`.
1388pub fn mirror_set(name: &str) -> Result<()> {
1389    let mut registry = Registry::load(&registry_path()?)?;
1390    if !registry.list().iter().any(|h| h.name == name) {
1391        bail!("no such host: {name} (run `git-ark host list` to see registered hosts)");
1392    }
1393    let token = match std::env::var("GIT_ARK_GITHUB_TOKEN")
1394        .ok()
1395        .filter(|s| !s.is_empty())
1396    {
1397        Some(t) => t,
1398        None => bail!("GIT_ARK_GITHUB_TOKEN must be set to designate a GitHub mirror"),
1399    };
1400    designate_mirror(&mut registry, name, &token)?;
1401    println!("✓ {name} is now the GitHub mirror");
1402    Ok(())
1403}
1404
1405/// Print the current mirror host's name, or `none`.
1406pub fn mirror_show() -> Result<()> {
1407    let registry = Registry::load(&registry_path()?)?;
1408    match registry.mirror_host() {
1409        Some(h) => println!("{}", h.name),
1410        None => println!("none"),
1411    }
1412    Ok(())
1413}
1414
1415// ---------------------------------------------------------------------
1416// routing — multi-host push fan-out
1417// ---------------------------------------------------------------------
1418
1419/// The `<alias>:<repo>` push URL for each host in `host_names`, in order. The
1420/// alias is the one the registry records for that host (`Host::push_alias()`) —
1421/// `git-ark-<name>` for a conventional host, its real alias for a hand-wired or
1422/// adopted one. A name not in the registry falls back to the convention (route
1423/// rejects unknown names before this, so that path is only exercised in tests).
1424fn push_urls(registry: &Registry, host_names: &[String], repo: &str) -> Vec<String> {
1425    host_names
1426        .iter()
1427        .map(|name| {
1428            let alias = registry
1429                .list()
1430                .iter()
1431                .find(|h| &h.name == name)
1432                .map(|h| h.push_alias())
1433                .unwrap_or_else(|| format!("git-ark-{name}"));
1434            format!("{alias}:{repo}")
1435        })
1436        .collect()
1437}
1438
1439/// Run a `git remote` subcommand against `repo_path`, bailing with its
1440/// stderr on failure.
1441fn git_remote(repo_path: &Path, args: &[&str]) -> Result<()> {
1442    let out = Command::new("git")
1443        .arg("-C")
1444        .arg(repo_path)
1445        .arg("remote")
1446        .args(args)
1447        .output()
1448        .with_context(|| format!("spawning git remote {}", args.join(" ")))?;
1449    if !out.status.success() {
1450        bail!(
1451            "git remote {} failed: {}",
1452            args.join(" "),
1453            String::from_utf8_lossy(&out.stderr).trim()
1454        );
1455    }
1456    Ok(())
1457}
1458
1459/// Fan a repo's `git push git-ark` out across `host_names`: verify every
1460/// name is registered, then (re)materialize the `git-ark` remote in
1461/// `repo_path` with one push URL per host, so a single `git push git-ark`
1462/// reaches all of them. Idempotent — re-running (even with a different
1463/// `host_names`) removes and rebuilds the remote rather than accumulating
1464/// stale push URLs alongside new ones.
1465pub fn route(repo_path: &Path, host_names: &[String]) -> Result<()> {
1466    if host_names.is_empty() {
1467        bail!("--to <names> is required (comma-separated host names)");
1468    }
1469
1470    let registry = Registry::load(&registry_path()?)?;
1471    let unknown: Vec<&String> = host_names
1472        .iter()
1473        .filter(|n| !registry.list().iter().any(|h| &h.name == *n))
1474        .collect();
1475    if !unknown.is_empty() {
1476        let names: Vec<&str> = unknown.iter().map(|s| s.as_str()).collect();
1477        bail!(
1478            "unknown host(s): {} (run `git-ark host list` to see registered hosts)",
1479            names.join(", ")
1480        );
1481    }
1482
1483    // Canonicalize first so a relative path like "." resolves to a real
1484    // directory name — `Path::new(".").file_name()` is `None`.
1485    let abs = repo_path
1486        .canonicalize()
1487        .with_context(|| format!("resolving {}", repo_path.display()))?;
1488    let repo = abs
1489        .file_name()
1490        .and_then(|n| n.to_str())
1491        .ok_or_else(|| anyhow::anyhow!("{} has no usable directory name", repo_path.display()))?
1492        .to_string();
1493    let urls = push_urls(&registry, host_names, &repo);
1494
1495    // Remove any prior git-ark remote first — ignore failure (there may be
1496    // none yet) — so re-routing never leaves a stale push URL alongside the
1497    // fresh set.
1498    let _ = Command::new("git")
1499        .arg("-C")
1500        .arg(repo_path)
1501        .args(["remote", "remove", "git-ark"])
1502        .output();
1503
1504    git_remote(repo_path, &["add", "git-ark", &urls[0]])?;
1505    for url in &urls {
1506        git_remote(repo_path, &["set-url", "--add", "--push", "git-ark", url])?;
1507    }
1508
1509    println!(
1510        "✓ {repo} → git push git-ark fans to: {}",
1511        host_names.join(",")
1512    );
1513    Ok(())
1514}
1515
1516// ---------------------------------------------------------------------
1517// mirror check — preflight the GitHub token against a repo's mirror config
1518// ---------------------------------------------------------------------
1519
1520/// Whether `repo_path/.github/workflows` exists and has at least one entry —
1521/// the signal that mirroring this repo needs the token's `workflow` scope
1522/// (classic PAT) or Workflows:write (fine-grained).
1523fn needs_workflow(repo_path: &Path) -> bool {
1524    std::fs::read_dir(repo_path.join(".github").join("workflows"))
1525        .map(|mut entries| entries.next().is_some())
1526        .unwrap_or(false)
1527}
1528
1529/// Format a `TokenCheck` as the report `mirror_check` prints: one line per
1530/// check, `✗`-prefixed lines are hard failures.
1531fn format_check(c: &github::TokenCheck, owner: &str, repo: &str) -> String {
1532    let mut lines = Vec::new();
1533
1534    lines.push(if c.valid {
1535        format!(
1536            "✓ authenticates (login: {})",
1537            c.login.as_deref().unwrap_or("?")
1538        )
1539    } else {
1540        "✗ token does not authenticate".to_string()
1541    });
1542
1543    lines.push(match &c.repo {
1544        github::RepoAccess::Exists { admin: true, .. } => {
1545            format!("✓ can administer {owner}/{repo}")
1546        }
1547        github::RepoAccess::Exists {
1548            push: true,
1549            admin: false,
1550        } => format!("✓ can push {owner}/{repo} (no admin — ok if it already exists)"),
1551        github::RepoAccess::Exists { push: false, .. } => {
1552            format!("✗ no push access to {owner}/{repo}")
1553        }
1554        github::RepoAccess::Absent => format!(
1555            "⚠ {owner}/{repo}: 404 — absent (created on first mirror) OR the token can't see it \
1556             (missing org access / unauthorized SSO PAT). GitHub returns 404 either way — verify \
1557             before relying on it"
1558        ),
1559        github::RepoAccess::Error(e) => format!("✗ {owner}/{repo}: {e}"),
1560    });
1561
1562    if c.needs_workflow {
1563        lines.push(match c.has_workflow_scope() {
1564            Some(true) => "✓ workflow scope".to_string(),
1565            Some(false) => {
1566                "✗ missing 'workflow' scope (needed to push .github/workflows/)".to_string()
1567            }
1568            None => "⚠ repo ships workflows; a fine-grained PAT needs Workflows:write (can't verify here)".to_string(),
1569        });
1570    }
1571
1572    lines.join("\n")
1573}
1574
1575/// Preflight the GitHub token against a repo's mirror config: read the
1576/// committed `.git-ark.yml` github block, check the token's auth/scopes and
1577/// its access to the target repo, and print a report — catching a token
1578/// lacking org access or the `workflow` scope before a real push does.
1579pub fn mirror_check(repo_path: &Path) -> Result<()> {
1580    let policy = read_repo_policy(repo_path)?;
1581    let g = policy
1582        .as_ref()
1583        .and_then(|p| p.github.as_ref())
1584        .filter(|g| g.enabled)
1585        .ok_or_else(|| {
1586            anyhow::anyhow!(
1587                "{} has no enabled `github:` block in .git-ark.yml — nothing to check",
1588                repo_path.display()
1589            )
1590        })?;
1591    let repo_name = g.repo.clone().unwrap_or_else(|| {
1592        repo_path
1593            .canonicalize()
1594            .ok()
1595            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
1596            .unwrap_or_default()
1597    });
1598
1599    let token = std::env::var("GIT_ARK_GITHUB_TOKEN")
1600        .ok()
1601        .filter(|s| !s.is_empty())
1602        .ok_or_else(|| anyhow::anyhow!("GIT_ARK_GITHUB_TOKEN must be set to run `mirror check`"))?;
1603
1604    let workflow = needs_workflow(repo_path);
1605    let check = github::check_token(&token, &g.owner, &repo_name, workflow);
1606    let report = format_check(&check, &g.owner, &repo_name);
1607    println!("{report}");
1608
1609    if report.lines().any(|l| l.starts_with('✗')) {
1610        bail!("mirror check found hard failures — see above");
1611    }
1612    Ok(())
1613}
1614
1615// ---------------------------------------------------------------------
1616// host setup-key — generate + copy a client SSH key to a host
1617// ---------------------------------------------------------------------
1618
1619/// What `pick_or_plan_keygen` decided for the client's control key: reuse
1620/// an existing one, or generate a fresh one. Both carry the PRIVATE key
1621/// path — its public half is always `<path>.pub`, the standard ssh-keygen
1622/// naming.
1623#[derive(Debug, Clone, PartialEq)]
1624pub enum KeyPlan {
1625    Reuse(PathBuf),
1626    Generate(PathBuf),
1627}
1628
1629/// Candidate private-key basenames in `~/.ssh`, most-preferred first — the
1630/// same three `ssh` itself tries by default.
1631const KEY_CANDIDATES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"];
1632
1633/// Decide which client key `host setup-key` should use. PURE: `existing` is
1634/// the set of candidate basenames the caller found actually present in
1635/// `ssh_dir` — this only picks among them. Reuses the first preferred name
1636/// present (ed25519 > ecdsa > rsa); plans to generate `id_ed25519` when none
1637/// exist. Never plans to overwrite an existing key.
1638pub fn pick_or_plan_keygen(ssh_dir: &Path, existing: &[String]) -> KeyPlan {
1639    for candidate in KEY_CANDIDATES {
1640        if existing.iter().any(|e| e == candidate) {
1641            return KeyPlan::Reuse(ssh_dir.join(candidate));
1642        }
1643    }
1644    KeyPlan::Generate(ssh_dir.join("id_ed25519"))
1645}
1646
1647/// Print the public key at `pub_path` plus the manual fallback instructions
1648/// for `target` — used both when `ssh-copy-id` fails/is missing and when the
1649/// post-copy verify still can't authenticate.
1650fn print_manual_fallback(target: &str, pub_path: &Path) -> Result<()> {
1651    let pubkey = std::fs::read_to_string(pub_path)
1652        .with_context(|| format!("reading {}", pub_path.display()))?;
1653    println!("{}", pubkey.trim());
1654    println!(
1655        "add this line to {target}:~/.ssh/authorized_keys (e.g. via console access), \
1656         then re-run git-ark host add"
1657    );
1658    Ok(())
1659}
1660
1661/// Generate a client SSH key (if none exists) and copy it to `target` via
1662/// `ssh-copy-id`, so a box without key auth set up can get to "sshable"
1663/// without leaving git-ark. This is the operator's own CONTROL key, never
1664/// the forced-command key `host add` goes on to install — `setup-key` never
1665/// touches an existing key, only creates one when none exists.
1666///
1667/// Wraps the standard tools (`ssh-keygen`, `ssh-copy-id`, `ssh`) rather than
1668/// reimplementing them; all three inherit this process's stdio (the
1669/// `Command` default), so a password prompt reaches the user's own terminal
1670/// — git-ark itself never sees or prints the password.
1671pub fn host_setup_key(target: &str, port: Option<u16>) -> Result<()> {
1672    let ssh_dir = home_dir()?.join(".ssh");
1673    // std::fs, not a unix-only DirBuilder mode — the client is
1674    // cross-platform. `ssh`/`ssh-keygen` are the ones that actually care
1675    // about `~/.ssh`'s permissions, and they run on the host's own platform
1676    // defaults regardless of what we set here.
1677    std::fs::create_dir_all(&ssh_dir).with_context(|| format!("creating {}", ssh_dir.display()))?;
1678
1679    let existing: Vec<String> = KEY_CANDIDATES
1680        .iter()
1681        .filter(|name| ssh_dir.join(name).exists())
1682        .map(|s| s.to_string())
1683        .collect();
1684
1685    let priv_path = match pick_or_plan_keygen(&ssh_dir, &existing) {
1686        KeyPlan::Reuse(p) => {
1687            println!("using existing key {}", p.display());
1688            p
1689        }
1690        KeyPlan::Generate(p) => {
1691            println!(
1692                "no SSH key found in {} — generating {}",
1693                ssh_dir.display(),
1694                p.display()
1695            );
1696            let status = Command::new("ssh-keygen")
1697                .args(["-t", "ed25519", "-f"])
1698                .arg(&p)
1699                .args(["-N", ""])
1700                .status()
1701                .context("spawning ssh-keygen")?;
1702            if !status.success() {
1703                bail!("ssh-keygen failed generating {}", p.display());
1704            }
1705            p
1706        }
1707    };
1708    let pub_path = PathBuf::from(format!("{}.pub", priv_path.display()));
1709
1710    let mut copy_args: Vec<String> = vec!["-i".to_string(), pub_path.display().to_string()];
1711    if let Some(port) = port {
1712        copy_args.push("-p".to_string());
1713        copy_args.push(port.to_string());
1714    }
1715    copy_args.push(target.to_string());
1716    let copy_ok = Command::new("ssh-copy-id")
1717        .args(&copy_args)
1718        .status()
1719        .map(|s| s.success())
1720        .unwrap_or(false);
1721    if !copy_ok {
1722        print_manual_fallback(target, &pub_path)?;
1723        bail!("ssh-copy-id to {target} did not succeed");
1724    }
1725
1726    // Verify with only the new key and no interactive fallback — proves key
1727    // auth actually works rather than trusting ssh-copy-id's exit code alone.
1728    let mut verify_args: Vec<String> = vec![
1729        "-o".to_string(),
1730        "BatchMode=yes".to_string(),
1731        "-o".to_string(),
1732        "ConnectTimeout=5".to_string(),
1733    ];
1734    if let Some(port) = port {
1735        verify_args.push("-p".to_string());
1736        verify_args.push(port.to_string());
1737    }
1738    verify_args.push("-i".to_string());
1739    verify_args.push(priv_path.display().to_string());
1740    verify_args.push(target.to_string());
1741    verify_args.push("true".to_string());
1742    let verified = Command::new("ssh")
1743        .args(&verify_args)
1744        .status()
1745        .map(|s| s.success())
1746        .unwrap_or(false);
1747
1748    if !verified {
1749        print_manual_fallback(target, &pub_path)?;
1750        bail!("key auth to {target} still doesn't work after setup-key");
1751    }
1752
1753    match port {
1754        Some(p) => println!(
1755            "✓ key auth to {target} works — now run: git-ark host add <name> {target} \
1756             --identity {} --port {p} --bucket … --recipient … --binary …",
1757            priv_path.display()
1758        ),
1759        None => println!(
1760            "✓ key auth to {target} works — now run: git-ark host add <name> {target} \
1761             --identity {} --bucket … --recipient … --binary …",
1762            priv_path.display()
1763        ),
1764    }
1765    Ok(())
1766}
1767
1768#[cfg(test)]
1769mod tests {
1770    use super::*;
1771
1772    #[test]
1773    fn parse_target_splits_user_and_host() {
1774        let (user, host) = parse_target("ark@example.com").unwrap();
1775        assert_eq!(user, "ark");
1776        assert_eq!(host, "example.com");
1777    }
1778
1779    #[test]
1780    fn parse_target_rejects_bare_host() {
1781        assert!(parse_target("example.com").is_err());
1782    }
1783
1784    #[test]
1785    fn parse_target_rejects_empty_user_or_host() {
1786        assert!(parse_target("@example.com").is_err());
1787        assert!(parse_target("ark@").is_err());
1788    }
1789
1790    #[test]
1791    fn find_alias_discovers_the_nas_style_manual_block() {
1792        // The exact shape of git-ark's own hand-wired NAS.
1793        let cfg = "Host git-ark\n  HostName store.lan\n  User pfugate\n  \
1794                   IdentityFile ~/.ssh/git-ark\n  IdentitiesOnly yes\n";
1795        assert_eq!(
1796            find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
1797            Some("git-ark".to_string())
1798        );
1799    }
1800
1801    #[test]
1802    fn find_alias_prefers_exact_name_then_git_ark_then_first() {
1803        // Two stanzas reach the same target; exact git-ark-<name> wins.
1804        let cfg = "Host git-ark\n  HostName store.lan\n  User pfugate\n\n\
1805                   Host git-ark-nas\n  HostName store.lan\n  User pfugate\n";
1806        assert_eq!(
1807            find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
1808            Some("git-ark-nas".to_string())
1809        );
1810        // With no exact match, the first git-ark* alias wins over a plain one.
1811        let cfg2 = "Host box\n  HostName store.lan\n  User pfugate\n\n\
1812                    Host git-ark\n  HostName store.lan\n  User pfugate\n";
1813        assert_eq!(
1814            find_alias_for_target(cfg2, "store.lan", Some("pfugate"), "nas"),
1815            Some("git-ark".to_string())
1816        );
1817    }
1818
1819    #[test]
1820    fn find_alias_filters_by_user_but_tolerates_a_userless_stanza() {
1821        let cfg = "Host wrong\n  HostName store.lan\n  User someoneelse\n\n\
1822                   Host right\n  HostName store.lan\n";
1823        // The wrong-user stanza is rejected; the User-less one matches.
1824        assert_eq!(
1825            find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
1826            Some("right".to_string())
1827        );
1828    }
1829
1830    #[test]
1831    fn find_alias_none_when_no_stanza_reaches_the_target() {
1832        let cfg = "Host other\n  HostName elsewhere.example\n  User pfugate\n";
1833        assert_eq!(
1834            find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
1835            None
1836        );
1837    }
1838
1839    #[test]
1840    fn find_alias_handles_multiple_aliases_on_one_host_line() {
1841        // `Host a b` declares two aliases for one stanza; a git-ark* one wins.
1842        let cfg = "Host shorty git-ark-nas\n  HostName store.lan\n  User pfugate\n";
1843        assert_eq!(
1844            find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
1845            Some("git-ark-nas".to_string())
1846        );
1847    }
1848
1849    #[test]
1850    fn adopt_parses_config_path_from_forced_command() {
1851        let ak = "ssh-rsa AAAA someone@key\n\
1852            command=\"/home/op/git-ark/bin/git-ark shell --config /home/op/git-ark/config.toml\",no-pty,no-port-forwarding ssh-ed25519 AAAA git-ark\n";
1853        assert_eq!(
1854            parse_forced_command_config(ak).as_deref(),
1855            Some("/home/op/git-ark/config.toml")
1856        );
1857    }
1858
1859    #[test]
1860    fn adopt_config_path_none_without_a_git_ark_line() {
1861        assert_eq!(
1862            parse_forced_command_config("ssh-ed25519 AAAA me@host\n"),
1863            None
1864        );
1865    }
1866
1867    #[test]
1868    fn adopt_maps_uname_to_triple() {
1869        assert_eq!(
1870            triple_from_uname("Linux\nx86_64").unwrap(),
1871            "x86_64-unknown-linux-musl"
1872        );
1873        assert_eq!(
1874            triple_from_uname("Linux\naarch64").unwrap(),
1875            "aarch64-unknown-linux-musl"
1876        );
1877        assert!(triple_from_uname("Darwin\narm64").is_err());
1878    }
1879
1880    #[test]
1881    fn ssh_args_includes_port_and_identity_when_set() {
1882        let spec = SshSpec {
1883            user: "ark".to_string(),
1884            host: "example.com".to_string(),
1885            port: Some(2222),
1886            identity: Some(PathBuf::from("/home/op/.ssh/id_ed25519")),
1887        };
1888        let args = ssh_args(&spec);
1889        assert_eq!(
1890            args,
1891            vec![
1892                "-p".to_string(),
1893                "2222".to_string(),
1894                "-i".to_string(),
1895                "/home/op/.ssh/id_ed25519".to_string(),
1896                "-o".to_string(),
1897                "StrictHostKeyChecking=accept-new".to_string(),
1898                "ark@example.com".to_string(),
1899            ]
1900        );
1901    }
1902
1903    #[test]
1904    fn ssh_args_omits_port_and_identity_when_unset() {
1905        let spec = SshSpec {
1906            user: "ark".to_string(),
1907            host: "example.com".to_string(),
1908            port: None,
1909            identity: None,
1910        };
1911        let args = ssh_args(&spec);
1912        assert_eq!(
1913            args,
1914            vec![
1915                "-o".to_string(),
1916                "StrictHostKeyChecking=accept-new".to_string(),
1917                "ark@example.com".to_string(),
1918            ]
1919        );
1920    }
1921
1922    #[test]
1923    fn pipe_file_command_stages_then_atomically_moves() {
1924        let cmd = pipe_file_command("/home/ark/git-ark/bin/git-ark", 0o755);
1925        assert!(cmd.starts_with("umask 077; cat > "));
1926        assert!(cmd.contains("chmod 755"));
1927        assert!(cmd.contains("mv "));
1928        assert!(cmd.contains("/home/ark/git-ark/bin/git-ark.uploading"));
1929        assert!(cmd.ends_with("/home/ark/git-ark/bin/git-ark"));
1930    }
1931
1932    #[test]
1933    fn append_unique_script_filters_marker_before_appending() {
1934        let script = append_unique_script("~/.ssh/authorized_keys", "/home/ark/git-ark");
1935        assert!(script.starts_with("f=~/.ssh/authorized_keys;"));
1936        // Replace semantics (grep -v), not skip-if-present (grep -q) — see
1937        // append_unique_script_replaces_stale_line_on_key_rotation below for
1938        // the behavior this enables.
1939        assert!(script.contains("grep -vF"));
1940        assert!(!script.contains("grep -qF"));
1941        assert!(script.contains("/home/ark/git-ark"));
1942        assert!(script.contains(".bak"));
1943    }
1944
1945    /// Proves the S2 fix end to end at the shell level (not just the string
1946    /// shape): a rotated key must evict the stale forced-command line, not
1947    /// sit alongside it or leave it in place. Runs the generated script for
1948    /// real against a temp file via `sh` — Unix-only (`sh` isn't reliably on
1949    /// PATH on Windows; this doesn't run in CI there either, see ci.yml).
1950    #[test]
1951    #[cfg(unix)]
1952    fn append_unique_script_replaces_stale_line_on_key_rotation() {
1953        let dir = tempfile::tempdir().unwrap();
1954        let f = dir.path().join("authorized_keys");
1955        std::fs::write(&f, "ssh-rsa UNRELATED someone-else@laptop\n").unwrap();
1956
1957        let run = |line: &str| {
1958            let script = append_unique_script(&f.display().to_string(), "/home/ark/git-ark");
1959            let mut child = Command::new("sh")
1960                .arg("-c")
1961                .arg(&script)
1962                .stdin(Stdio::piped())
1963                .spawn()
1964                .unwrap();
1965            child
1966                .stdin
1967                .take()
1968                .unwrap()
1969                .write_all(line.as_bytes())
1970                .unwrap();
1971            assert!(child.wait().unwrap().success());
1972        };
1973
1974        let old_line = "command=\"/home/ark/git-ark/bin/git-ark shell --config /home/ark/git-ark/config.toml\",no-pty ssh-ed25519 OLDKEY git-ark-testbox";
1975        let new_line = "command=\"/home/ark/git-ark/bin/git-ark shell --config /home/ark/git-ark/config.toml\",no-pty ssh-ed25519 NEWKEY git-ark-testbox";
1976
1977        run(old_line);
1978        let after_first = std::fs::read_to_string(&f).unwrap();
1979        assert!(after_first.contains("OLDKEY"));
1980        assert!(after_first.contains("UNRELATED"));
1981
1982        // Simulate rotation: same marker (install_dir), regenerated pubkey.
1983        run(new_line);
1984        let after_rotation = std::fs::read_to_string(&f).unwrap();
1985        assert!(
1986            after_rotation.contains("NEWKEY"),
1987            "rotated key must be authorized"
1988        );
1989        assert!(
1990            !after_rotation.contains("OLDKEY"),
1991            "stale key must be evicted, not left authorized"
1992        );
1993        assert!(
1994            after_rotation.contains("UNRELATED"),
1995            "unrelated authorized_keys lines must be preserved"
1996        );
1997        assert_eq!(
1998            after_rotation.matches("git-ark-testbox").count(),
1999            1,
2000            "must not duplicate the forced-command line"
2001        );
2002    }
2003
2004    #[test]
2005    fn selfcheck_command_quotes_install_dir() {
2006        let cmd = selfcheck_command("/home/ark/git ark");
2007        assert!(cmd.contains("'/home/ark/git ark/bin/git-ark'"));
2008        assert!(cmd.contains("--config"));
2009        assert!(cmd.contains("'/home/ark/git ark/config.toml'"));
2010    }
2011
2012    #[test]
2013    fn selfcheck_command_has_expected_shape_without_special_chars() {
2014        let cmd = selfcheck_command("/home/ark/git-ark");
2015        assert_eq!(
2016            cmd,
2017            "/home/ark/git-ark/bin/git-ark selfcheck --config /home/ark/git-ark/config.toml"
2018        );
2019    }
2020
2021    #[test]
2022    fn parse_kv_reads_selfcheck_shaped_output() {
2023        let out = "git_ark_version=0.1.0\nrepos_root=/home/ark/git-ark/repos\nconfig_valid=true\n";
2024        let kv = parse_kv(out);
2025        assert_eq!(kv.get("git_ark_version").map(String::as_str), Some("0.1.0"));
2026        assert_eq!(kv.get("config_valid").map(String::as_str), Some("true"));
2027    }
2028
2029    #[test]
2030    fn client_dir_is_under_config() {
2031        assert_eq!(
2032            client_dir(Path::new("/home/op")),
2033            PathBuf::from("/home/op/.config/git-ark")
2034        );
2035    }
2036
2037    #[test]
2038    fn registry_path_honors_git_ark_hosts_override() {
2039        std::env::set_var("GIT_ARK_HOSTS", "/tmp/git-ark-test-hosts.toml");
2040        let p = registry_path().unwrap();
2041        std::env::remove_var("GIT_ARK_HOSTS");
2042        assert_eq!(p, PathBuf::from("/tmp/git-ark-test-hosts.toml"));
2043    }
2044
2045    #[test]
2046    fn upsert_ssh_config_block_appends_when_absent() {
2047        let existing = "Host other\n  HostName elsewhere.example.com\n";
2048        let block = "Host git-ark-testbox\n  HostName example.com\n  Port 2222\n  User ark\n  IdentityFile /k\n  IdentitiesOnly yes\n";
2049        let out = upsert_ssh_config_block(existing, "testbox", block);
2050        assert!(out.contains("Host other"));
2051        assert!(out.contains("Host git-ark-testbox"));
2052        assert!(out.contains("HostName example.com"));
2053    }
2054
2055    #[test]
2056    fn upsert_ssh_config_block_replaces_in_place_and_preserves_siblings() {
2057        let existing = "Host before\n  HostName a\n\nHost git-ark-testbox\n  HostName old.example.com\n  Port 22\n\nHost after\n  HostName b\n";
2058        let block = "Host git-ark-testbox\n  HostName new.example.com\n  Port 2222\n  User ark\n  IdentityFile /k\n  IdentitiesOnly yes\n";
2059        let out = upsert_ssh_config_block(existing, "testbox", block);
2060
2061        assert!(out.contains("Host before"));
2062        assert!(out.contains("Host after"));
2063        assert!(out.contains("HostName new.example.com"));
2064        assert!(!out.contains("old.example.com"));
2065        // Only one git-ark-testbox block remains.
2066        assert_eq!(out.matches("Host git-ark-testbox").count(), 1);
2067    }
2068
2069    #[test]
2070    fn upsert_ssh_config_block_is_idempotent() {
2071        let block = "Host git-ark-testbox\n  HostName example.com\n  Port 2222\n  User ark\n  IdentityFile /k\n  IdentitiesOnly yes\n";
2072        let once = upsert_ssh_config_block("", "testbox", block);
2073        let twice = upsert_ssh_config_block(&once, "testbox", block);
2074        assert_eq!(once, twice);
2075        assert_eq!(once.matches("Host git-ark-testbox").count(), 1);
2076    }
2077
2078    #[test]
2079    fn remove_ssh_config_block_drops_only_the_named_block() {
2080        let existing = "Host before\n  HostName a\n\nHost git-ark-testbox\n  HostName old.example.com\n  Port 22\n\nHost after\n  HostName b\n";
2081        let out = remove_ssh_config_block(existing, "testbox");
2082        assert!(out.contains("Host before"));
2083        assert!(out.contains("Host after"));
2084        assert!(!out.contains("git-ark-testbox"));
2085        assert!(!out.contains("old.example.com"));
2086    }
2087
2088    #[test]
2089    fn remove_ssh_config_block_absent_is_a_no_op() {
2090        let existing = "Host other\n  HostName elsewhere.example.com\n";
2091        let out = remove_ssh_config_block(existing, "testbox");
2092        assert_eq!(out, "Host other\n  HostName elsewhere.example.com\n");
2093    }
2094
2095    #[test]
2096    fn remove_ssh_config_block_of_only_block_leaves_empty_file() {
2097        let existing = "Host git-ark-testbox\n  HostName example.com\n  Port 2222\n";
2098        let out = remove_ssh_config_block(existing, "testbox");
2099        assert_eq!(out, "");
2100    }
2101
2102    /// S1: an ssh_config stanza is terminated by `Host ` *or* `Match `. A
2103    /// terminator that only recognized `Host ` would swallow a trailing
2104    /// `Match` block into the dropped git-ark body and silently delete it.
2105    #[test]
2106    fn remove_ssh_config_block_preserves_trailing_match_block() {
2107        let existing = "Host git-ark-box\n  HostName example.com\n  Port 22\n  User ark\n  IdentityFile /k\n  IdentitiesOnly yes\n\nMatch host *.corp\n  User alice\n";
2108        let out = remove_ssh_config_block(existing, "box");
2109        assert!(!out.contains("git-ark-box"));
2110        assert!(out.contains("Match host *.corp"));
2111        assert!(out.contains("User alice"));
2112    }
2113
2114    #[test]
2115    fn validate_host_name_accepts_ordinary_names() {
2116        assert!(validate_host_name("testbox").is_ok());
2117        assert!(validate_host_name("box-1.prod_east").is_ok());
2118    }
2119
2120    #[test]
2121    fn validate_host_name_rejects_path_traversal() {
2122        assert!(validate_host_name("../../etc/passwd").is_err());
2123        assert!(validate_host_name("..").is_err());
2124        assert!(validate_host_name(".").is_err());
2125    }
2126
2127    #[test]
2128    fn validate_host_name_rejects_empty_and_unsafe_chars() {
2129        assert!(validate_host_name("").is_err());
2130        assert!(validate_host_name("box/name").is_err());
2131        assert!(validate_host_name("box name").is_err());
2132        assert!(validate_host_name("box$(rm)").is_err());
2133    }
2134
2135    #[test]
2136    fn home_dir_falls_back_to_userprofile_when_home_unset() {
2137        let saved_home = std::env::var("HOME").ok();
2138        std::env::remove_var("HOME");
2139        std::env::set_var("USERPROFILE", "/Users/win");
2140
2141        let result = home_dir();
2142
2143        std::env::remove_var("USERPROFILE");
2144        if let Some(home) = saved_home {
2145            std::env::set_var("HOME", home);
2146        }
2147
2148        assert_eq!(result.unwrap(), PathBuf::from("/Users/win"));
2149    }
2150
2151    fn test_host(name: &str, mirror: bool) -> Host {
2152        Host {
2153            name: name.to_string(),
2154            ssh_target: format!("ark@{name}.example.com"),
2155            port: 22,
2156            identity: None,
2157            triple: "x86_64-unknown-linux-musl".to_string(),
2158            install_dir: "/home/ark/git-ark".to_string(),
2159            recipient: "age1abc".to_string(),
2160            bucket: "b".to_string(),
2161            region: "us-east-1".to_string(),
2162            prefix: "git-ark".to_string(),
2163            endpoint: None,
2164            mirror,
2165            push_alias: None,
2166        }
2167    }
2168
2169    #[test]
2170    fn mirror_transition_promotes_target_when_no_current_mirror() {
2171        let mut registry = Registry::default();
2172        registry.upsert(test_host("nas", false));
2173        registry.upsert(test_host("ec2", false));
2174
2175        let (demote, promote) = mirror_transition(&registry, "ec2");
2176        assert_eq!(demote, None);
2177        assert_eq!(promote, "ec2");
2178    }
2179
2180    #[test]
2181    fn mirror_transition_demotes_a_different_current_mirror() {
2182        let mut registry = Registry::default();
2183        registry.upsert(test_host("nas", true));
2184        registry.upsert(test_host("ec2", false));
2185
2186        let (demote, promote) = mirror_transition(&registry, "ec2");
2187        assert_eq!(demote, Some("nas".to_string()));
2188        assert_eq!(promote, "ec2");
2189    }
2190
2191    #[test]
2192    fn mirror_transition_is_a_no_op_demote_when_target_already_mirror() {
2193        let mut registry = Registry::default();
2194        registry.upsert(test_host("nas", false));
2195        registry.upsert(test_host("ec2", true));
2196
2197        let (demote, promote) = mirror_transition(&registry, "ec2");
2198        assert_eq!(demote, None);
2199        assert_eq!(promote, "ec2");
2200    }
2201
2202    // -----------------------------------------------------------------
2203    // status
2204    // -----------------------------------------------------------------
2205
2206    #[test]
2207    fn format_status_row_reachable_healthy() {
2208        let s = HostStatus::Reachable {
2209            version: "0.1.0".to_string(),
2210            disk_free: 60_000_000_000_000,
2211            disk_low: false,
2212            config_valid: true,
2213        };
2214        let row = format_status_row("nas", false, &s);
2215        assert!(row.contains("nas"));
2216        assert!(row.contains('✓'));
2217        assert!(row.contains("0.1.0"));
2218        assert!(
2219            row.contains("54.6 TB"),
2220            "expected a human free size, got: {row}"
2221        );
2222        assert!(!row.contains('★'));
2223    }
2224
2225    #[test]
2226    fn format_status_row_reachable_marks_mirror() {
2227        let s = HostStatus::Reachable {
2228            version: "0.1.0".to_string(),
2229            disk_free: 60_000_000_000_000,
2230            disk_low: false,
2231            config_valid: true,
2232        };
2233        let row = format_status_row("nas", true, &s);
2234        assert!(row.contains('★'));
2235        assert!(row.to_lowercase().contains("mirror"));
2236    }
2237
2238    #[test]
2239    fn format_status_row_reachable_disk_low_warns() {
2240        let s = HostStatus::Reachable {
2241            version: "0.1.0".to_string(),
2242            disk_free: 1_000_000_000,
2243            disk_low: true,
2244            config_valid: true,
2245        };
2246        let row = format_status_row("nas", false, &s);
2247        assert!(row.contains('⚠'));
2248    }
2249
2250    #[test]
2251    fn format_status_row_broken_shows_reason() {
2252        let s = HostStatus::Broken("selfcheck: config invalid".to_string());
2253        let row = format_status_row("nas", false, &s);
2254        assert!(row.starts_with('✗'));
2255        assert!(row.to_lowercase().contains("broken"));
2256        assert!(row.contains("selfcheck: config invalid"));
2257    }
2258
2259    #[test]
2260    fn format_status_row_unreachable() {
2261        let s = HostStatus::Unreachable;
2262        let row = format_status_row("nas", false, &s);
2263        assert!(row.starts_with('✗'));
2264        assert!(row.to_lowercase().contains("unreachable"));
2265    }
2266
2267    #[test]
2268    fn push_urls_uses_the_registrys_recorded_alias() {
2269        let mut registry = Registry::default();
2270        // Conventional host: no recorded alias → git-ark-<name>.
2271        registry.upsert(test_host("ec2", false));
2272        // Hand-wired/adopted host: records a non-standard alias.
2273        let mut nas = test_host("nas", false);
2274        nas.push_alias = Some("git-ark".to_string());
2275        registry.upsert(nas);
2276
2277        // Includes an unregistered name to exercise the convention fallback.
2278        let names = vec!["nas".to_string(), "ec2".to_string(), "ghost".to_string()];
2279        assert_eq!(
2280            push_urls(&registry, &names, "myrepo"),
2281            vec![
2282                "git-ark:myrepo".to_string(),       // recorded alias wins
2283                "git-ark-ec2:myrepo".to_string(),   // conventional
2284                "git-ark-ghost:myrepo".to_string(), // unregistered → fallback
2285            ]
2286        );
2287    }
2288
2289    #[test]
2290    fn host_rows_resolve_alias_and_sort_by_name() {
2291        let mut registry = Registry::default();
2292        registry.upsert(test_host("zeta", false));
2293        let mut nas = test_host("nas", true);
2294        nas.push_alias = Some("git-ark".to_string());
2295        registry.upsert(nas);
2296
2297        let rows = host_rows(&registry);
2298        assert_eq!(rows[0].name, "nas"); // sorted
2299        assert_eq!(rows[0].alias, "git-ark"); // resolved recorded alias
2300        assert!(rows[0].mirror);
2301        assert_eq!(rows[1].name, "zeta");
2302        assert_eq!(rows[1].alias, "git-ark-zeta"); // conventional
2303
2304        // Serializes as an array of objects carrying the resolved alias.
2305        let json = serde_json::to_string(&rows).unwrap();
2306        assert!(json.contains("\"alias\":\"git-ark\""));
2307        assert!(json.contains("\"name\":\"nas\""));
2308    }
2309
2310    // -----------------------------------------------------------------
2311    // mirror check
2312    // -----------------------------------------------------------------
2313
2314    #[test]
2315    fn needs_workflow_true_when_workflows_dir_has_entries() {
2316        let dir = tempfile::tempdir().unwrap();
2317        let workflows = dir.path().join(".github").join("workflows");
2318        std::fs::create_dir_all(&workflows).unwrap();
2319        std::fs::write(workflows.join("ci.yml"), "name: ci\n").unwrap();
2320        assert!(needs_workflow(dir.path()));
2321    }
2322
2323    #[test]
2324    fn needs_workflow_false_when_workflows_dir_empty() {
2325        let dir = tempfile::tempdir().unwrap();
2326        std::fs::create_dir_all(dir.path().join(".github").join("workflows")).unwrap();
2327        assert!(!needs_workflow(dir.path()));
2328    }
2329
2330    #[test]
2331    fn needs_workflow_false_when_no_github_dir() {
2332        let dir = tempfile::tempdir().unwrap();
2333        assert!(!needs_workflow(dir.path()));
2334    }
2335
2336    fn token_check(
2337        valid: bool,
2338        repo: github::RepoAccess,
2339        scopes: Option<Vec<&str>>,
2340        needs_workflow: bool,
2341    ) -> github::TokenCheck {
2342        github::TokenCheck {
2343            valid,
2344            login: if valid { Some("me".to_string()) } else { None },
2345            scopes: scopes.map(|s| s.into_iter().map(str::to_string).collect()),
2346            repo,
2347            needs_workflow,
2348        }
2349    }
2350
2351    #[test]
2352    fn format_check_reports_failed_auth() {
2353        let c = token_check(false, github::RepoAccess::Absent, None, false);
2354        let out = format_check(&c, "acme", "app");
2355        assert!(out.contains("✗ token does not authenticate"));
2356    }
2357
2358    #[test]
2359    fn format_check_reports_successful_auth_with_login() {
2360        let c = token_check(
2361            true,
2362            github::RepoAccess::Exists {
2363                push: true,
2364                admin: true,
2365            },
2366            None,
2367            false,
2368        );
2369        let out = format_check(&c, "acme", "app");
2370        assert!(out.contains("✓ authenticates (login: me)"));
2371    }
2372
2373    #[test]
2374    fn format_check_repo_admin() {
2375        let c = token_check(
2376            true,
2377            github::RepoAccess::Exists {
2378                push: true,
2379                admin: true,
2380            },
2381            None,
2382            false,
2383        );
2384        let out = format_check(&c, "acme", "app");
2385        assert!(out.contains("✓ can administer acme/app"));
2386    }
2387
2388    #[test]
2389    fn format_check_repo_push_without_admin() {
2390        let c = token_check(
2391            true,
2392            github::RepoAccess::Exists {
2393                push: true,
2394                admin: false,
2395            },
2396            None,
2397            false,
2398        );
2399        let out = format_check(&c, "acme", "app");
2400        assert!(out.contains("✓ can push acme/app"));
2401        assert!(out.contains("no admin"));
2402    }
2403
2404    #[test]
2405    fn format_check_repo_no_push() {
2406        let c = token_check(
2407            true,
2408            github::RepoAccess::Exists {
2409                push: false,
2410                admin: false,
2411            },
2412            None,
2413            false,
2414        );
2415        let out = format_check(&c, "acme", "app");
2416        assert!(out.contains("✗ no push access to acme/app"));
2417    }
2418
2419    #[test]
2420    fn format_check_repo_absent() {
2421        let c = token_check(true, github::RepoAccess::Absent, None, false);
2422        let out = format_check(&c, "acme", "app");
2423        // A 404 is a soft warning (not a hard ✗) but must name the org-access /
2424        // SSO ambiguity, not assert the repo will be created.
2425        assert!(out.contains("⚠ acme/app"));
2426        assert!(out.contains("404") && out.contains("org access"));
2427    }
2428
2429    #[test]
2430    fn format_check_repo_error() {
2431        let c = token_check(
2432            true,
2433            github::RepoAccess::Error("HTTP 403".to_string()),
2434            None,
2435            false,
2436        );
2437        let out = format_check(&c, "acme", "app");
2438        assert!(out.contains("✗ acme/app: HTTP 403"));
2439    }
2440
2441    #[test]
2442    fn format_check_omits_workflow_line_when_not_needed() {
2443        let c = token_check(
2444            true,
2445            github::RepoAccess::Exists {
2446                push: true,
2447                admin: true,
2448            },
2449            Some(vec!["repo"]),
2450            false,
2451        );
2452        let out = format_check(&c, "acme", "app");
2453        assert!(!out.contains("workflow"));
2454    }
2455
2456    #[test]
2457    fn format_check_workflow_scope_present() {
2458        let c = token_check(
2459            true,
2460            github::RepoAccess::Exists {
2461                push: true,
2462                admin: true,
2463            },
2464            Some(vec!["repo", "workflow"]),
2465            true,
2466        );
2467        let out = format_check(&c, "acme", "app");
2468        assert!(out.contains("✓ workflow scope"));
2469    }
2470
2471    #[test]
2472    fn format_check_workflow_scope_missing() {
2473        let c = token_check(
2474            true,
2475            github::RepoAccess::Exists {
2476                push: true,
2477                admin: true,
2478            },
2479            Some(vec!["repo"]),
2480            true,
2481        );
2482        let out = format_check(&c, "acme", "app");
2483        assert!(out.contains("✗ missing 'workflow' scope"));
2484    }
2485
2486    #[test]
2487    fn format_check_workflow_scope_uninspectable() {
2488        let c = token_check(
2489            true,
2490            github::RepoAccess::Exists {
2491                push: true,
2492                admin: true,
2493            },
2494            None,
2495            true,
2496        );
2497        let out = format_check(&c, "acme", "app");
2498        assert!(out.contains("⚠ repo ships workflows"));
2499    }
2500
2501    // -----------------------------------------------------------------
2502    // upgrade
2503    // -----------------------------------------------------------------
2504
2505    #[test]
2506    fn resolve_upgrade_targets_all_returns_every_host() {
2507        let mut registry = Registry::default();
2508        registry.upsert(test_host("nas", false));
2509        registry.upsert(test_host("ec2", true));
2510
2511        let targets = resolve_upgrade_targets(&registry, &[], true).unwrap();
2512        let mut names: Vec<&str> = targets.iter().map(|h| h.name.as_str()).collect();
2513        names.sort();
2514        assert_eq!(names, vec!["ec2", "nas"]);
2515    }
2516
2517    #[test]
2518    fn resolve_upgrade_targets_all_bails_on_empty_registry() {
2519        let registry = Registry::default();
2520        assert!(resolve_upgrade_targets(&registry, &[], true).is_err());
2521    }
2522
2523    #[test]
2524    fn resolve_upgrade_targets_named_returns_those_hosts() {
2525        let mut registry = Registry::default();
2526        registry.upsert(test_host("nas", false));
2527        registry.upsert(test_host("ec2", true));
2528
2529        let names = vec!["ec2".to_string()];
2530        let targets = resolve_upgrade_targets(&registry, &names, false).unwrap();
2531        assert_eq!(targets.len(), 1);
2532        assert_eq!(targets[0].name, "ec2");
2533    }
2534
2535    #[test]
2536    fn resolve_upgrade_targets_bails_on_unknown_name() {
2537        let mut registry = Registry::default();
2538        registry.upsert(test_host("nas", false));
2539
2540        let names = vec!["nope".to_string()];
2541        let err = resolve_upgrade_targets(&registry, &names, false).unwrap_err();
2542        assert!(err.to_string().contains("nope"));
2543    }
2544
2545    #[test]
2546    fn resolve_upgrade_targets_bails_when_neither_all_nor_names() {
2547        let mut registry = Registry::default();
2548        registry.upsert(test_host("nas", false));
2549
2550        let err = resolve_upgrade_targets(&registry, &[], false).unwrap_err();
2551        assert!(err.to_string().contains("--all"));
2552    }
2553
2554    #[test]
2555    fn resolve_upgrade_targets_all_wins_over_names() {
2556        let mut registry = Registry::default();
2557        registry.upsert(test_host("nas", false));
2558        registry.upsert(test_host("ec2", true));
2559
2560        // An unknown name would normally bail — proves `all` short-circuits
2561        // past name resolution entirely rather than validating both.
2562        let names = vec!["nope".to_string()];
2563        let targets = resolve_upgrade_targets(&registry, &names, true).unwrap();
2564        assert_eq!(targets.len(), 2);
2565    }
2566
2567    // -----------------------------------------------------------------
2568    // host setup-key
2569    // -----------------------------------------------------------------
2570
2571    #[test]
2572    fn pick_or_plan_keygen_reuses_existing_ed25519() {
2573        let dir = PathBuf::from("/home/op/.ssh");
2574        let existing = vec!["id_ed25519".to_string()];
2575        assert_eq!(
2576            pick_or_plan_keygen(&dir, &existing),
2577            KeyPlan::Reuse(dir.join("id_ed25519"))
2578        );
2579    }
2580
2581    #[test]
2582    fn pick_or_plan_keygen_reuses_rsa_when_thats_all_there_is() {
2583        let dir = PathBuf::from("/home/op/.ssh");
2584        let existing = vec!["id_rsa".to_string()];
2585        assert_eq!(
2586            pick_or_plan_keygen(&dir, &existing),
2587            KeyPlan::Reuse(dir.join("id_rsa"))
2588        );
2589    }
2590
2591    #[test]
2592    fn pick_or_plan_keygen_prefers_ecdsa_over_rsa() {
2593        let dir = PathBuf::from("/home/op/.ssh");
2594        let existing = vec!["id_rsa".to_string(), "id_ecdsa".to_string()];
2595        assert_eq!(
2596            pick_or_plan_keygen(&dir, &existing),
2597            KeyPlan::Reuse(dir.join("id_ecdsa"))
2598        );
2599    }
2600
2601    #[test]
2602    fn pick_or_plan_keygen_generates_ed25519_when_none_exist() {
2603        let dir = PathBuf::from("/home/op/.ssh");
2604        assert_eq!(
2605            pick_or_plan_keygen(&dir, &[]),
2606            KeyPlan::Generate(dir.join("id_ed25519"))
2607        );
2608    }
2609}