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