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