use crate::config::S3Config;
use crate::github;
use crate::hostspec::{
assess, forced_command_line, parse_probe, per_host_prefix, render_config, render_secrets,
ssh_config_block, PROBE_SCRIPT,
};
use crate::registry::{Host, Registry};
use crate::release;
use crate::repo_policy::read_repo_policy;
use crate::sshdiag::{classify_ssh_error, diagnosis_message, SshDiagnosis};
use anyhow::{anyhow, bail, Context, Result};
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Clone)]
pub struct SshSpec {
pub user: String,
pub host: String,
pub port: Option<u16>,
pub identity: Option<PathBuf>,
}
impl SshSpec {
fn display(&self) -> String {
format!("{}@{}", self.user, self.host)
}
}
pub fn parse_target(target: &str) -> Result<(String, String)> {
let (user, host) = target
.split_once('@')
.ok_or_else(|| anyhow::anyhow!("target must be `user@host` (got {target:?})"))?;
if user.is_empty() || host.is_empty() {
bail!("target must be `user@host` (got {target:?})");
}
Ok((user.to_string(), host.to_string()))
}
fn ssh_args(spec: &SshSpec) -> Vec<String> {
let mut args = Vec::new();
if let Some(port) = spec.port {
args.push("-p".to_string());
args.push(port.to_string());
}
if let Some(identity) = &spec.identity {
args.push("-i".to_string());
args.push(identity.display().to_string());
}
args.push("-o".to_string());
args.push("StrictHostKeyChecking=accept-new".to_string());
args.push(spec.display());
args
}
fn ssh_command(spec: &SshSpec) -> Command {
let mut cmd = Command::new("ssh");
cmd.args(ssh_args(spec));
cmd
}
pub fn ssh_run(spec: &SshSpec, cmd: &str) -> Result<String> {
let out = ssh_command(spec)
.arg(cmd)
.output()
.with_context(|| format!("spawning ssh to {}", spec.display()))?;
if !out.status.success() {
bail!(
"ssh {} failed: {}",
spec.display(),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn ssh_run_with_stdin(spec: &SshSpec, cmd: &str, input: &[u8]) -> Result<String> {
let mut child = ssh_command(spec)
.arg(cmd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("spawning ssh to {}", spec.display()))?;
child
.stdin
.take()
.expect("stdin was piped")
.write_all(input)
.context("writing to ssh stdin")?;
let out = child
.wait_with_output()
.with_context(|| format!("waiting on ssh to {}", spec.display()))?;
if !out.status.success() {
bail!(
"ssh {} failed: {}",
spec.display(),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn pipe_file_command(remote_path: &str, mode: u32) -> String {
let tmp = format!("{remote_path}.uploading");
let remote_q = shell_words::quote(remote_path);
let tmp_q = shell_words::quote(&tmp);
format!("umask 077; cat > {tmp_q} && chmod {mode:o} {tmp_q} && mv {tmp_q} {remote_q}")
}
pub fn ssh_pipe_file(spec: &SshSpec, remote_path: &str, bytes: &[u8], mode: u32) -> Result<()> {
ssh_run_with_stdin(spec, &pipe_file_command(remote_path, mode), bytes)?;
Ok(())
}
fn append_unique_script(remote_file: &str, marker: &str) -> String {
let marker_q = shell_words::quote(marker);
format!(
"f={remote_file}; d=$(dirname \"$f\"); mkdir -p \"$d\" && touch \"$f\" && \
line=\"$(cat)\" && {{ [ -f \"$f.bak\" ] || cp \"$f\" \"$f.bak\"; }} && \
{{ grep -vF {marker_q} \"$f\" > \"$f.tmp\" 2>/dev/null || true; }} && \
printf '%s\\n' \"$line\" >> \"$f.tmp\" && mv \"$f.tmp\" \"$f\""
)
}
pub fn ssh_append_unique(
spec: &SshSpec,
remote_file: &str,
line: &str,
marker: &str,
) -> Result<()> {
ssh_run_with_stdin(
spec,
&append_unique_script(remote_file, marker),
line.as_bytes(),
)?;
Ok(())
}
fn selfcheck_command(install_dir: &str) -> String {
let bin_q = shell_words::quote(&format!("{install_dir}/bin/git-ark")).into_owned();
let cfg_q = shell_words::quote(&format!("{install_dir}/config.toml")).into_owned();
format!("{bin_q} selfcheck --config {cfg_q}")
}
fn ssh_mkdir_p(spec: &SshSpec, dirs: &[String]) -> Result<()> {
let quoted: Vec<String> = dirs
.iter()
.map(|d| shell_words::quote(d).into_owned())
.collect();
ssh_run(spec, &format!("mkdir -p {}", quoted.join(" ")))?;
Ok(())
}
fn client_dir(home: &Path) -> PathBuf {
home.join(".config").join("git-ark")
}
fn home_dir() -> Result<PathBuf> {
if let Ok(home) = std::env::var("HOME") {
return Ok(PathBuf::from(home));
}
std::env::var("USERPROFILE")
.map(PathBuf::from)
.context("neither HOME nor USERPROFILE is set")
}
pub fn client_keydir() -> Result<PathBuf> {
Ok(client_dir(&home_dir()?))
}
pub fn client_ssh_config_path() -> Result<PathBuf> {
Ok(home_dir()?.join(".ssh").join("config"))
}
pub fn registry_path() -> Result<PathBuf> {
if let Ok(p) = std::env::var("GIT_ARK_HOSTS") {
return Ok(PathBuf::from(p));
}
Ok(client_keydir()?.join("hosts.toml"))
}
fn drop_ssh_config_block<'a>(existing: &'a str, name: &str) -> Vec<&'a str> {
let marker = format!("Host git-ark-{name}");
let lines: Vec<&str> = existing.lines().collect();
let mut kept: Vec<&str> = Vec::new();
let mut i = 0;
while i < lines.len() {
if lines[i].trim() == marker {
i += 1;
while i < lines.len()
&& !lines[i].trim_start().starts_with("Host ")
&& !lines[i].trim_start().starts_with("Match ")
{
i += 1;
}
continue;
}
kept.push(lines[i]);
i += 1;
}
while matches!(kept.last(), Some(l) if l.trim().is_empty()) {
kept.pop();
}
kept
}
pub fn upsert_ssh_config_block(existing: &str, name: &str, block: &str) -> String {
let kept = drop_ssh_config_block(existing, name);
let mut out = kept.join("\n");
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(block.trim_end());
out.push('\n');
out
}
pub fn remove_ssh_config_block(existing: &str, name: &str) -> String {
let kept = drop_ssh_config_block(existing, name);
if kept.is_empty() {
return String::new();
}
let mut out = kept.join("\n");
out.push('\n');
out
}
fn find_alias_for_target(
ssh_config: &str,
host: &str,
user: Option<&str>,
name: &str,
) -> Option<String> {
let lines: Vec<&str> = ssh_config.lines().collect();
let mut candidates: Vec<String> = Vec::new();
let mut i = 0;
while i < lines.len() {
let mut head = lines[i].split_whitespace();
if !matches!(head.next(), Some(k) if k.eq_ignore_ascii_case("Host")) {
i += 1;
continue;
}
let aliases: Vec<String> = head.map(|s| s.to_string()).collect();
i += 1;
let mut hostname: Option<&str> = None;
let mut block_user: Option<&str> = None;
while i < lines.len() {
let mut bt = lines[i].split_whitespace();
match bt.next() {
Some(k) if k.eq_ignore_ascii_case("Host") || k.eq_ignore_ascii_case("Match") => {
break
}
Some(k) if k.eq_ignore_ascii_case("HostName") => hostname = bt.next(),
Some(k) if k.eq_ignore_ascii_case("User") => block_user = bt.next(),
_ => {}
}
i += 1;
}
let host_matches = hostname == Some(host);
let user_matches = match (user, block_user) {
(None, _) | (Some(_), None) => true,
(Some(u), Some(bu)) => u == bu,
};
if host_matches && user_matches {
candidates.extend(aliases);
}
}
let exact = format!("git-ark-{name}");
candidates
.iter()
.find(|a| **a == exact)
.or_else(|| candidates.iter().find(|a| a.starts_with("git-ark")))
.or_else(|| candidates.first())
.cloned()
}
fn parse_kv(text: &str) -> std::collections::HashMap<String, String> {
text.lines()
.filter_map(|l| l.split_once('='))
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
pub struct HostAddArgs {
pub name: String,
pub target: String,
pub port: Option<u16>,
pub identity: Option<PathBuf>,
pub bucket: String,
pub region: String,
pub prefix: String,
pub endpoint: Option<String>,
pub recipient: String,
pub binary: Option<PathBuf>,
pub mirror: bool,
}
fn validate_host_name(name: &str) -> Result<()> {
let ok = !name.is_empty()
&& name != "."
&& name != ".."
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
if !ok {
bail!("host name {name:?} must match [A-Za-z0-9._-]+ (and not be `.` or `..`)");
}
Ok(())
}
pub fn host_add(args: &HostAddArgs) -> Result<()> {
validate_host_name(&args.name)?;
let key_id = std::env::var("GIT_ARK_HOST_S3_KEY_ID")
.ok()
.filter(|s| !s.is_empty());
let secret = std::env::var("GIT_ARK_HOST_S3_SECRET")
.ok()
.filter(|s| !s.is_empty());
let (key_id, secret) = match (key_id, secret) {
(Some(k), Some(s)) => (k, s),
_ => bail!(
"GIT_ARK_HOST_S3_KEY_ID and GIT_ARK_HOST_S3_SECRET must both be set \
(the host's write-only S3 credential); nothing was written to the host"
),
};
let (user, host) = parse_target(&args.target)?;
let spec = SshSpec {
user: user.clone(),
host: host.clone(),
port: args.port,
identity: args.identity.clone(),
};
let probe_out = ssh_run(&spec, PROBE_SCRIPT)
.inspect_err(|e| {
let diagnosis = classify_ssh_error(&e.to_string());
if diagnosis != SshDiagnosis::Other {
eprintln!(
"{}",
diagnosis_message(diagnosis, &spec.display(), args.port.unwrap_or(22))
);
}
})
.with_context(|| format!("probing {}", spec.display()))?;
let facts = parse_probe(&probe_out);
let plan = match assess(&facts) {
Ok(plan) => plan,
Err(reasons) => {
eprintln!("✗ {} is not ready for git-ark:", spec.display());
for r in &reasons {
eprintln!(" - {r}");
}
bail!(
"preflight probe failed ({} reason(s)); nothing was written to the host",
reasons.len()
);
}
};
let binary_bytes = match &args.binary {
Some(path) => {
let bytes = std::fs::read(path)
.with_context(|| format!("reading --binary {}", path.display()))?;
if !path.to_string_lossy().contains(&plan.triple) {
eprintln!(
"⚠ --binary {} doesn't look like it's built for {} (the probed host's triple) — continuing anyway",
path.display(),
plan.triple
);
}
bytes
}
None => {
println!(
"• fetching git-ark v{} for {} from the release…",
release::version(),
plan.triple
);
release::fetch_host_binary(&plan.triple)?
}
};
let keydir = client_keydir()?;
std::fs::create_dir_all(&keydir).with_context(|| format!("creating {}", keydir.display()))?;
let key_path = keydir.join(&args.name);
let pub_path = keydir.join(format!("{}.pub", args.name));
if !key_path.exists() {
let status = Command::new("ssh-keygen")
.args(["-t", "ed25519", "-N", "", "-q"])
.arg("-f")
.arg(&key_path)
.arg("-C")
.arg(format!("git-ark-{}", args.name))
.status()
.context("spawning ssh-keygen")?;
if !status.success() {
bail!("ssh-keygen failed generating {}", key_path.display());
}
}
let pubkey = std::fs::read_to_string(&pub_path)
.with_context(|| format!("reading {}", pub_path.display()))?
.trim()
.to_string();
ssh_mkdir_p(
&spec,
&[
format!("{}/bin", plan.install_dir),
format!("{}/repos", plan.install_dir),
],
)?;
ssh_pipe_file(
&spec,
&format!("{}/bin/git-ark", plan.install_dir),
&binary_bytes,
0o755,
)?;
let s3 = S3Config {
bucket: args.bucket.clone(),
region: args.region.clone(),
prefix: per_host_prefix(&args.prefix, &args.name),
endpoint: args.endpoint.clone(),
};
let config_text = render_config(&plan.install_dir, &args.recipient, &s3, false);
ssh_pipe_file(
&spec,
&format!("{}/config.toml", plan.install_dir),
config_text.as_bytes(),
0o644,
)?;
let secrets_text = render_secrets(&key_id, &secret, None);
ssh_pipe_file(
&spec,
&format!("{}/secrets.toml", plan.install_dir),
secrets_text.as_bytes(),
0o600,
)?;
let line = forced_command_line(&plan.install_dir, &pubkey);
ssh_append_unique(&spec, "~/.ssh/authorized_keys", &line, &plan.install_dir)?;
let selfcheck_out = ssh_run(&spec, &selfcheck_command(&plan.install_dir))?;
let selfcheck = parse_kv(&selfcheck_out);
let config_valid = selfcheck.get("config_valid").map(String::as_str) == Some("true");
let version = selfcheck.get("git_ark_version");
if !config_valid || version.is_none() {
bail!(
"selfcheck did not report a healthy host (config_valid={:?}, git_ark_version={:?}); \
the host has been wired, but `host add {}` should be re-run once fixed",
selfcheck.get("config_valid"),
version,
args.name
);
}
let port = args.port.unwrap_or(22);
let block = ssh_config_block(
&args.name,
&host,
port,
&user,
&key_path.display().to_string(),
);
let ssh_config_path = client_ssh_config_path()?;
if let Some(parent) = ssh_config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let existing = std::fs::read_to_string(&ssh_config_path).unwrap_or_default();
let updated = upsert_ssh_config_block(&existing, &args.name, &block);
std::fs::write(&ssh_config_path, updated)
.with_context(|| format!("writing {}", ssh_config_path.display()))?;
let registry_path = registry_path()?;
let mut registry = Registry::load(®istry_path)?;
registry.upsert(Host {
name: args.name.clone(),
ssh_target: args.target.clone(),
port,
identity: args.identity.clone(),
triple: plan.triple.clone(),
install_dir: plan.install_dir.clone(),
recipient: args.recipient.clone(),
bucket: args.bucket.clone(),
region: args.region.clone(),
prefix: args.prefix.clone(),
endpoint: args.endpoint.clone(),
mirror: false,
push_alias: None,
});
registry.save(®istry_path)?;
println!(
"✓ {} ready — git push git-ark-{}:<repo>",
args.name, args.name
);
if let (Some(pct), Some(bytes)) = (
selfcheck.get("disk_free_percent"),
selfcheck.get("disk_free_bytes"),
) {
println!(" disk: {pct}% free ({bytes} bytes)");
}
if args.mirror {
let token = match std::env::var("GIT_ARK_GITHUB_TOKEN")
.ok()
.filter(|s| !s.is_empty())
{
Some(t) => t,
None => bail!(
"GIT_ARK_GITHUB_TOKEN must be set to add {} as the GitHub mirror (--mirror); \
the host was still added, just not designated the mirror \
(run `git-ark mirror set {}` once the token is set)",
args.name,
args.name
),
};
designate_mirror(&mut registry, &args.name, &token)?;
println!("✓ {} designated the GitHub mirror", args.name);
}
Ok(())
}
pub struct HostAdoptArgs {
pub name: String,
pub target: String,
pub port: Option<u16>,
pub identity: Option<PathBuf>,
pub config: Option<String>,
pub push_alias: Option<String>,
}
pub fn host_adopt(args: &HostAdoptArgs) -> Result<()> {
validate_host_name(&args.name)?;
let (user, host) = parse_target(&args.target)?;
let spec = SshSpec {
user,
host,
port: args.port,
identity: args.identity.clone(),
};
let config_path = match &args.config {
Some(c) => c.clone(),
None => {
let ak = ssh_run(&spec, "cat ~/.ssh/authorized_keys 2>/dev/null || true")?;
parse_forced_command_config(&ak).ok_or_else(|| {
anyhow!(
"no git-ark forced-command line found in {}'s authorized_keys — \
pass --config <path-to-config.toml> to point me at it",
args.target
)
})?
}
};
let install_dir = std::path::Path::new(&config_path)
.parent()
.and_then(|p| p.to_str())
.ok_or_else(|| anyhow!("can't derive the install dir from {config_path}"))?
.to_string();
let triple = triple_from_uname(&ssh_run(&spec, "uname -s; uname -m")?)?;
let cfg_text = ssh_run(&spec, &format!("cat {}", shell_words::quote(&config_path)))?;
let cfg: toml::Value =
toml::from_str(&cfg_text).map_err(|e| anyhow!("parsing the host's config.toml: {e}"))?;
let str_at = |v: Option<&toml::Value>| v.and_then(|x| x.as_str()).unwrap_or("").to_string();
let s3 = cfg.get("s3");
let recipient = str_at(cfg.get("age_recipient"));
let bucket = str_at(s3.and_then(|v| v.get("bucket")));
let region = str_at(s3.and_then(|v| v.get("region")));
let prefix = str_at(s3.and_then(|v| v.get("prefix")));
let endpoint = s3
.and_then(|v| v.get("endpoint"))
.and_then(|v| v.as_str())
.map(str::to_string);
let mirror = cfg.get("mirror").and_then(|v| v.as_bool()).unwrap_or(false);
let bin_q = shell_words::quote(&format!("{install_dir}/bin/git-ark")).into_owned();
let cfg_q = shell_words::quote(&config_path).into_owned();
let selfcheck = ssh_run(&spec, &format!("{bin_q} selfcheck --config {cfg_q}"))
.context("running the host's git-ark selfcheck")?;
if !selfcheck.contains("config_valid=true") {
bail!(
"selfcheck didn't confirm a working git-ark at {install_dir} on {}:\n{}",
args.target,
selfcheck.trim()
);
}
let version = parse_kv(&selfcheck)
.get("git_ark_version")
.cloned()
.unwrap_or_else(|| "?".into());
let push_alias = args.push_alias.clone().or_else(|| {
client_ssh_config_path()
.ok()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|cfg| find_alias_for_target(&cfg, &spec.host, Some(&spec.user), &args.name))
});
let registry_path = registry_path()?;
let mut registry = Registry::load(®istry_path)?;
registry.upsert(Host {
name: args.name.clone(),
ssh_target: args.target.clone(),
port: args.port.unwrap_or(22),
identity: args.identity.clone(),
triple,
install_dir,
recipient,
bucket,
region,
prefix,
endpoint,
mirror,
push_alias: push_alias.clone(),
});
registry.save(®istry_path)?;
println!(
"✓ adopted '{}' (git-ark {version}) — registered from its deployed config; \
nothing on the host or in your ssh config was touched",
args.name
);
println!(" it now appears in `git-ark status` and can be `git-ark upgrade`d.");
if mirror {
println!(" (it holds the GitHub mirror.)");
}
match &push_alias {
Some(alias) => println!(" push alias: {alias} — `git-ark route` and pushes will use it."),
None => println!(
" no push alias found in ~/.ssh/config — status/upgrade work; set one with \
`git-ark host adopt {} {} --push-alias <alias>` to enable push/route.",
args.name, args.target
),
}
Ok(())
}
pub fn parse_forced_command_config(authorized_keys: &str) -> Option<String> {
for line in authorized_keys.lines() {
if !line.contains("git-ark") {
continue;
}
if let Some(idx) = line.find("--config ") {
let rest = &line[idx + "--config ".len()..];
let end = rest
.find(|c: char| c == '"' || c.is_whitespace())
.unwrap_or(rest.len());
let path = &rest[..end];
if !path.is_empty() {
return Some(path.to_string());
}
}
}
None
}
pub fn triple_from_uname(uname: &str) -> Result<String> {
let mut lines = uname.lines().map(str::trim);
let os = lines.next().unwrap_or("");
let arch = lines.next().unwrap_or("");
match (os, arch) {
("Linux", "x86_64") => Ok("x86_64-unknown-linux-musl".to_string()),
("Linux", "aarch64" | "arm64") => Ok("aarch64-unknown-linux-musl".to_string()),
_ => bail!("unsupported host {os}/{arch} — git-ark hosts are Linux x86_64 or aarch64"),
}
}
pub fn host_list() -> Result<()> {
let registry = Registry::load(®istry_path()?)?;
let mut hosts: Vec<&Host> = registry.list().iter().collect();
hosts.sort_by(|a, b| a.name.cmp(&b.name));
for h in hosts {
println!("{} {} {}", h.name, h.ssh_target, h.triple);
}
Ok(())
}
#[derive(Debug, serde::Serialize)]
struct HostRow<'a> {
name: &'a str,
alias: String,
ssh_target: &'a str,
port: u16,
triple: &'a str,
bucket: &'a str,
region: &'a str,
prefix: &'a str,
endpoint: Option<&'a str>,
mirror: bool,
}
fn host_rows(registry: &Registry) -> Vec<HostRow<'_>> {
let mut hosts: Vec<&Host> = registry.list().iter().collect();
hosts.sort_by(|a, b| a.name.cmp(&b.name));
hosts
.into_iter()
.map(|h| HostRow {
name: &h.name,
alias: h.push_alias(),
ssh_target: &h.ssh_target,
port: h.port,
triple: &h.triple,
bucket: &h.bucket,
region: &h.region,
prefix: &h.prefix,
endpoint: h.endpoint.as_deref(),
mirror: h.mirror,
})
.collect()
}
pub fn host_list_json() -> Result<()> {
let registry = Registry::load(®istry_path()?)?;
let rows = host_rows(®istry);
println!("{}", serde_json::to_string_pretty(&rows)?);
Ok(())
}
pub fn host_remove(name: &str) -> Result<()> {
let registry_path = registry_path()?;
let mut registry = Registry::load(®istry_path)?;
if !registry.remove(name) {
println!("no such host: {name}");
return Ok(());
}
registry.save(®istry_path)?;
let ssh_config_path = client_ssh_config_path()?;
if let Ok(existing) = std::fs::read_to_string(&ssh_config_path) {
let updated = remove_ssh_config_block(&existing, name);
std::fs::write(&ssh_config_path, updated)
.with_context(|| format!("writing {}", ssh_config_path.display()))?;
}
println!("removed {name}");
Ok(())
}
fn base_prefix(ip: std::net::Ipv4Addr) -> String {
let o = ip.octets();
format!("{}.{}.{}", o[0], o[1], o[2])
}
pub fn host_discover(port: u16, timeout_ms: u64, subnet: Option<std::net::Ipv4Addr>) -> Result<()> {
let base = match subnet {
Some(b) => b,
None => crate::subnet::local_ipv4()
.context("could not determine the local IPv4 address; pass --subnet a.b.c.0")?,
};
let ips = crate::subnet::hosts_in_slash24(base);
let found =
crate::scan::scan_port(&ips, port, std::time::Duration::from_millis(timeout_ms), 64);
let reg = Registry::load(®istry_path()?).unwrap_or_default();
if found.is_empty() {
println!(
"no hosts responding on port {port} in {}.0/24",
base_prefix(base)
);
return Ok(());
}
for ip in found {
let s = ip.to_string();
match reg.list().iter().find(|h| h.ssh_target.contains(&s)) {
Some(h) => println!("{s}:{port} (known: {})", h.name),
None => println!("{s}:{port}"),
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub enum HostStatus {
Reachable {
version: String,
disk_free: u64,
disk_low: bool,
config_valid: bool,
},
Broken(String),
Unreachable,
}
pub fn probe_status(host: &Host) -> HostStatus {
let (user, hostname) = match parse_target(&host.ssh_target) {
Ok(v) => v,
Err(e) => return HostStatus::Broken(e.to_string()),
};
let spec = SshSpec {
user,
host: hostname,
port: Some(host.port),
identity: host.identity.clone(),
};
match ssh_run(&spec, &selfcheck_command(&host.install_dir)) {
Ok(out) => {
let kv = parse_kv(&out);
let version = kv.get("git_ark_version").cloned().unwrap_or_default();
let disk_free = kv
.get("disk_free_bytes")
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let disk_low = kv.get("disk_low").map(String::as_str) == Some("true");
let config_valid = kv.get("config_valid").map(String::as_str) == Some("true");
HostStatus::Reachable {
version,
disk_free,
disk_low,
config_valid,
}
}
Err(_) => match ssh_run(&spec, "true") {
Ok(_) => HostStatus::Broken("selfcheck failed".to_string()),
Err(_) => HostStatus::Unreachable,
},
}
}
pub fn format_status_row(name: &str, mirror: bool, s: &HostStatus) -> String {
let mirror_marker = if mirror { " ★ mirror" } else { "" };
match s {
HostStatus::Reachable {
version,
disk_free,
disk_low,
config_valid,
} => {
let mark = if *disk_low { "⚠" } else { "✓" };
let mut line = format!(
"{mark} {name} v{version} {} free",
crate::backup::human_bytes(*disk_free)
);
if *disk_low {
line.push_str(" disk low");
}
if !config_valid {
line.push_str(" config invalid");
}
line.push_str(mirror_marker);
line
}
HostStatus::Broken(reason) => format!("✗ {name} broken: {reason}{mirror_marker}"),
HostStatus::Unreachable => format!("✗ {name} unreachable{mirror_marker}"),
}
}
pub fn status() -> Result<()> {
let registry = Registry::load(®istry_path()?)?;
let mut hosts: Vec<&Host> = registry.list().iter().collect();
if hosts.is_empty() {
println!("no hosts — run `git-ark host add`");
return Ok(());
}
hosts.sort_by(|a, b| a.name.cmp(&b.name));
for h in hosts {
let s = probe_status(h);
println!("{}", format_status_row(&h.name, h.mirror, &s));
}
Ok(())
}
fn resolve_upgrade_targets(registry: &Registry, names: &[String], all: bool) -> Result<Vec<Host>> {
if all {
if registry.list().is_empty() {
bail!("no hosts registered — nothing to upgrade");
}
return Ok(registry.list().to_vec());
}
if names.is_empty() {
bail!("name a host or pass --all");
}
names
.iter()
.map(|name| {
registry
.list()
.iter()
.find(|h| &h.name == name)
.cloned()
.ok_or_else(|| {
anyhow::anyhow!(
"no such host: {name} (run `git-ark host list` to see registered hosts)"
)
})
})
.collect()
}
fn upgrade_one(host: &Host, bytes: &[u8]) -> Result<()> {
let (user, hostname) = parse_target(&host.ssh_target)?;
let spec = SshSpec {
user,
host: hostname,
port: Some(host.port),
identity: host.identity.clone(),
};
ssh_pipe_file(
&spec,
&format!("{}/bin/git-ark", host.install_dir),
bytes,
0o755,
)?;
let out = ssh_run(&spec, &selfcheck_command(&host.install_dir))?;
let kv = parse_kv(&out);
let version = kv.get("git_ark_version").cloned().unwrap_or_default();
println!("✓ {} upgraded → {version}", host.name);
Ok(())
}
pub fn upgrade(names: &[String], all: bool, binary: Option<&Path>) -> Result<()> {
let registry = Registry::load(®istry_path()?)?;
let targets = resolve_upgrade_targets(®istry, names, all)?;
let explicit = match binary {
Some(path) => Some(
std::fs::read(path).with_context(|| format!("reading --binary {}", path.display()))?,
),
None => None,
};
let mut fetched: HashMap<String, Vec<u8>> = HashMap::new();
let mut any_failed = false;
for host in &targets {
let bytes: &[u8] = if let Some(explicit) = &explicit {
if let Some(path) = binary {
if !path.to_string_lossy().contains(&host.triple) {
eprintln!(
"⚠ --binary {} doesn't look like it's built for {}'s triple ({}) — continuing anyway",
path.display(),
host.name,
host.triple
);
}
}
explicit
} else {
if !fetched.contains_key(&host.triple) {
println!(
"• fetching git-ark v{} for {} ({})…",
release::version(),
host.name,
host.triple
);
match release::fetch_host_binary(&host.triple) {
Ok(b) => {
fetched.insert(host.triple.clone(), b);
}
Err(e) => {
println!("✗ {}: {e}", host.name);
any_failed = true;
continue;
}
}
}
&fetched[&host.triple]
};
if let Err(e) = upgrade_one(host, bytes) {
println!("✗ {}: {e}", host.name);
any_failed = true;
}
}
if any_failed {
bail!("one or more hosts failed to upgrade — see above");
}
Ok(())
}
fn mirror_transition(registry: &Registry, target: &str) -> (Option<String>, String) {
let demote = registry
.mirror_host()
.filter(|h| h.name != target)
.map(|h| h.name.clone());
(demote, target.to_string())
}
fn apply_host_config(host: &Host, mirror: bool, github_token: Option<&str>) -> Result<()> {
let key_id = std::env::var("GIT_ARK_HOST_S3_KEY_ID")
.ok()
.filter(|s| !s.is_empty());
let secret = std::env::var("GIT_ARK_HOST_S3_SECRET")
.ok()
.filter(|s| !s.is_empty());
let (key_id, secret) = match (key_id, secret) {
(Some(k), Some(s)) => (k, s),
_ => bail!(
"GIT_ARK_HOST_S3_KEY_ID and GIT_ARK_HOST_S3_SECRET must both be set to re-apply \
{}'s config (changing the mirror re-renders secrets.toml, which needs the host's \
write-only S3 credential, not just the GitHub token)",
host.name
),
};
let (user, hostname) = parse_target(&host.ssh_target)?;
let spec = SshSpec {
user,
host: hostname,
port: Some(host.port),
identity: host.identity.clone(),
};
let s3 = S3Config {
bucket: host.bucket.clone(),
region: host.region.clone(),
prefix: per_host_prefix(&host.prefix, &host.name),
endpoint: host.endpoint.clone(),
};
let config_text = render_config(&host.install_dir, &host.recipient, &s3, mirror);
ssh_pipe_file(
&spec,
&format!("{}/config.toml", host.install_dir),
config_text.as_bytes(),
0o644,
)?;
let secrets_text = render_secrets(&key_id, &secret, github_token);
ssh_pipe_file(
&spec,
&format!("{}/secrets.toml", host.install_dir),
secrets_text.as_bytes(),
0o600,
)?;
Ok(())
}
fn designate_mirror(registry: &mut Registry, name: &str, github_token: &str) -> Result<()> {
if !registry.list().iter().any(|h| h.name == name) {
bail!("no such host: {name} (run `git-ark host list` to see registered hosts)");
}
let (demote, promote) = mirror_transition(registry, name);
if let Some(old_name) = &demote {
let old = registry
.list()
.iter()
.find(|h| &h.name == old_name)
.expect("mirror_transition only names a host present in the registry")
.clone();
apply_host_config(&old, false, None)?;
registry
.hosts
.iter_mut()
.find(|h| h.name == old.name)
.expect("just found above")
.mirror = false;
registry.save(®istry_path()?)?;
}
let target = registry
.list()
.iter()
.find(|h| h.name == promote)
.expect("checked at the top of this fn")
.clone();
apply_host_config(&target, true, Some(github_token)).with_context(|| {
format!(
"demoted the previous mirror but could not promote {promote} — the fleet has no \
mirror right now; re-run `git-ark mirror set {promote}` once it is reachable"
)
})?;
registry
.hosts
.iter_mut()
.find(|h| h.name == promote)
.expect("just found above")
.mirror = true;
registry.save(®istry_path()?)?;
Ok(())
}
pub fn mirror_set(name: &str) -> Result<()> {
let mut registry = Registry::load(®istry_path()?)?;
if !registry.list().iter().any(|h| h.name == name) {
bail!("no such host: {name} (run `git-ark host list` to see registered hosts)");
}
let token = match std::env::var("GIT_ARK_GITHUB_TOKEN")
.ok()
.filter(|s| !s.is_empty())
{
Some(t) => t,
None => bail!("GIT_ARK_GITHUB_TOKEN must be set to designate a GitHub mirror"),
};
designate_mirror(&mut registry, name, &token)?;
println!("✓ {name} is now the GitHub mirror");
Ok(())
}
pub fn mirror_show() -> Result<()> {
let registry = Registry::load(®istry_path()?)?;
match registry.mirror_host() {
Some(h) => println!("{}", h.name),
None => println!("none"),
}
Ok(())
}
fn push_urls(registry: &Registry, host_names: &[String], repo: &str) -> Vec<String> {
host_names
.iter()
.map(|name| {
let alias = registry
.list()
.iter()
.find(|h| &h.name == name)
.map(|h| h.push_alias())
.unwrap_or_else(|| format!("git-ark-{name}"));
format!("{alias}:{repo}")
})
.collect()
}
fn git_remote(repo_path: &Path, args: &[&str]) -> Result<()> {
let out = Command::new("git")
.arg("-C")
.arg(repo_path)
.arg("remote")
.args(args)
.output()
.with_context(|| format!("spawning git remote {}", args.join(" ")))?;
if !out.status.success() {
bail!(
"git remote {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
pub fn route(repo_path: &Path, host_names: &[String]) -> Result<()> {
if host_names.is_empty() {
bail!("--to <names> is required (comma-separated host names)");
}
let registry = Registry::load(®istry_path()?)?;
let unknown: Vec<&String> = host_names
.iter()
.filter(|n| !registry.list().iter().any(|h| &h.name == *n))
.collect();
if !unknown.is_empty() {
let names: Vec<&str> = unknown.iter().map(|s| s.as_str()).collect();
bail!(
"unknown host(s): {} (run `git-ark host list` to see registered hosts)",
names.join(", ")
);
}
let abs = repo_path
.canonicalize()
.with_context(|| format!("resolving {}", repo_path.display()))?;
let repo = abs
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow::anyhow!("{} has no usable directory name", repo_path.display()))?
.to_string();
let urls = push_urls(®istry, host_names, &repo);
let _ = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["remote", "remove", "git-ark"])
.output();
git_remote(repo_path, &["add", "git-ark", &urls[0]])?;
for url in &urls {
git_remote(repo_path, &["set-url", "--add", "--push", "git-ark", url])?;
}
println!(
"✓ {repo} → git push git-ark fans to: {}",
host_names.join(",")
);
Ok(())
}
fn needs_workflow(repo_path: &Path) -> bool {
std::fs::read_dir(repo_path.join(".github").join("workflows"))
.map(|mut entries| entries.next().is_some())
.unwrap_or(false)
}
fn format_check(c: &github::TokenCheck, owner: &str, repo: &str) -> String {
let mut lines = Vec::new();
lines.push(if c.valid {
format!(
"✓ authenticates (login: {})",
c.login.as_deref().unwrap_or("?")
)
} else {
"✗ token does not authenticate".to_string()
});
lines.push(match &c.repo {
github::RepoAccess::Exists { admin: true, .. } => {
format!("✓ can administer {owner}/{repo}")
}
github::RepoAccess::Exists {
push: true,
admin: false,
} => format!("✓ can push {owner}/{repo} (no admin — ok if it already exists)"),
github::RepoAccess::Exists { push: false, .. } => {
format!("✗ no push access to {owner}/{repo}")
}
github::RepoAccess::Absent => format!(
"⚠ {owner}/{repo}: 404 — absent (created on first mirror) OR the token can't see it \
(missing org access / unauthorized SSO PAT). GitHub returns 404 either way — verify \
before relying on it"
),
github::RepoAccess::Error(e) => format!("✗ {owner}/{repo}: {e}"),
});
if c.needs_workflow {
lines.push(match c.has_workflow_scope() {
Some(true) => "✓ workflow scope".to_string(),
Some(false) => {
"✗ missing 'workflow' scope (needed to push .github/workflows/)".to_string()
}
None => "⚠ repo ships workflows; a fine-grained PAT needs Workflows:write (can't verify here)".to_string(),
});
}
lines.join("\n")
}
pub fn mirror_check(repo_path: &Path) -> Result<()> {
let policy = read_repo_policy(repo_path)?;
let g = policy
.as_ref()
.and_then(|p| p.github.as_ref())
.filter(|g| g.enabled)
.ok_or_else(|| {
anyhow::anyhow!(
"{} has no enabled `github:` block in .git-ark.yml — nothing to check",
repo_path.display()
)
})?;
let repo_name = g.repo.clone().unwrap_or_else(|| {
repo_path
.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_default()
});
let token = std::env::var("GIT_ARK_GITHUB_TOKEN")
.ok()
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("GIT_ARK_GITHUB_TOKEN must be set to run `mirror check`"))?;
let workflow = needs_workflow(repo_path);
let check = github::check_token(&token, &g.owner, &repo_name, workflow);
let report = format_check(&check, &g.owner, &repo_name);
println!("{report}");
if report.lines().any(|l| l.starts_with('✗')) {
bail!("mirror check found hard failures — see above");
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub enum KeyPlan {
Reuse(PathBuf),
Generate(PathBuf),
}
const KEY_CANDIDATES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"];
pub fn pick_or_plan_keygen(ssh_dir: &Path, existing: &[String]) -> KeyPlan {
for candidate in KEY_CANDIDATES {
if existing.iter().any(|e| e == candidate) {
return KeyPlan::Reuse(ssh_dir.join(candidate));
}
}
KeyPlan::Generate(ssh_dir.join("id_ed25519"))
}
fn print_manual_fallback(target: &str, pub_path: &Path) -> Result<()> {
let pubkey = std::fs::read_to_string(pub_path)
.with_context(|| format!("reading {}", pub_path.display()))?;
println!("{}", pubkey.trim());
println!(
"add this line to {target}:~/.ssh/authorized_keys (e.g. via console access), \
then re-run git-ark host add"
);
Ok(())
}
pub fn host_setup_key(target: &str, port: Option<u16>) -> Result<()> {
let ssh_dir = home_dir()?.join(".ssh");
std::fs::create_dir_all(&ssh_dir).with_context(|| format!("creating {}", ssh_dir.display()))?;
let existing: Vec<String> = KEY_CANDIDATES
.iter()
.filter(|name| ssh_dir.join(name).exists())
.map(|s| s.to_string())
.collect();
let priv_path = match pick_or_plan_keygen(&ssh_dir, &existing) {
KeyPlan::Reuse(p) => {
println!("using existing key {}", p.display());
p
}
KeyPlan::Generate(p) => {
println!(
"no SSH key found in {} — generating {}",
ssh_dir.display(),
p.display()
);
let status = Command::new("ssh-keygen")
.args(["-t", "ed25519", "-f"])
.arg(&p)
.args(["-N", ""])
.status()
.context("spawning ssh-keygen")?;
if !status.success() {
bail!("ssh-keygen failed generating {}", p.display());
}
p
}
};
let pub_path = PathBuf::from(format!("{}.pub", priv_path.display()));
let mut copy_args: Vec<String> = vec!["-i".to_string(), pub_path.display().to_string()];
if let Some(port) = port {
copy_args.push("-p".to_string());
copy_args.push(port.to_string());
}
copy_args.push(target.to_string());
let copy_ok = Command::new("ssh-copy-id")
.args(©_args)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !copy_ok {
print_manual_fallback(target, &pub_path)?;
bail!("ssh-copy-id to {target} did not succeed");
}
let mut verify_args: Vec<String> = vec![
"-o".to_string(),
"BatchMode=yes".to_string(),
"-o".to_string(),
"ConnectTimeout=5".to_string(),
];
if let Some(port) = port {
verify_args.push("-p".to_string());
verify_args.push(port.to_string());
}
verify_args.push("-i".to_string());
verify_args.push(priv_path.display().to_string());
verify_args.push(target.to_string());
verify_args.push("true".to_string());
let verified = Command::new("ssh")
.args(&verify_args)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !verified {
print_manual_fallback(target, &pub_path)?;
bail!("key auth to {target} still doesn't work after setup-key");
}
match port {
Some(p) => println!(
"✓ key auth to {target} works — now run: git-ark host add <name> {target} \
--identity {} --port {p} --bucket … --recipient … --binary …",
priv_path.display()
),
None => println!(
"✓ key auth to {target} works — now run: git-ark host add <name> {target} \
--identity {} --bucket … --recipient … --binary …",
priv_path.display()
),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_target_splits_user_and_host() {
let (user, host) = parse_target("ark@example.com").unwrap();
assert_eq!(user, "ark");
assert_eq!(host, "example.com");
}
#[test]
fn parse_target_rejects_bare_host() {
assert!(parse_target("example.com").is_err());
}
#[test]
fn parse_target_rejects_empty_user_or_host() {
assert!(parse_target("@example.com").is_err());
assert!(parse_target("ark@").is_err());
}
#[test]
fn find_alias_discovers_the_nas_style_manual_block() {
let cfg = "Host git-ark\n HostName store.lan\n User pfugate\n \
IdentityFile ~/.ssh/git-ark\n IdentitiesOnly yes\n";
assert_eq!(
find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
Some("git-ark".to_string())
);
}
#[test]
fn find_alias_prefers_exact_name_then_git_ark_then_first() {
let cfg = "Host git-ark\n HostName store.lan\n User pfugate\n\n\
Host git-ark-nas\n HostName store.lan\n User pfugate\n";
assert_eq!(
find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
Some("git-ark-nas".to_string())
);
let cfg2 = "Host box\n HostName store.lan\n User pfugate\n\n\
Host git-ark\n HostName store.lan\n User pfugate\n";
assert_eq!(
find_alias_for_target(cfg2, "store.lan", Some("pfugate"), "nas"),
Some("git-ark".to_string())
);
}
#[test]
fn find_alias_filters_by_user_but_tolerates_a_userless_stanza() {
let cfg = "Host wrong\n HostName store.lan\n User someoneelse\n\n\
Host right\n HostName store.lan\n";
assert_eq!(
find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
Some("right".to_string())
);
}
#[test]
fn find_alias_none_when_no_stanza_reaches_the_target() {
let cfg = "Host other\n HostName elsewhere.example\n User pfugate\n";
assert_eq!(
find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
None
);
}
#[test]
fn find_alias_handles_multiple_aliases_on_one_host_line() {
let cfg = "Host shorty git-ark-nas\n HostName store.lan\n User pfugate\n";
assert_eq!(
find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
Some("git-ark-nas".to_string())
);
}
#[test]
fn adopt_parses_config_path_from_forced_command() {
let ak = "ssh-rsa AAAA someone@key\n\
command=\"/home/op/git-ark/bin/git-ark shell --config /home/op/git-ark/config.toml\",no-pty,no-port-forwarding ssh-ed25519 AAAA git-ark\n";
assert_eq!(
parse_forced_command_config(ak).as_deref(),
Some("/home/op/git-ark/config.toml")
);
}
#[test]
fn adopt_config_path_none_without_a_git_ark_line() {
assert_eq!(
parse_forced_command_config("ssh-ed25519 AAAA me@host\n"),
None
);
}
#[test]
fn adopt_maps_uname_to_triple() {
assert_eq!(
triple_from_uname("Linux\nx86_64").unwrap(),
"x86_64-unknown-linux-musl"
);
assert_eq!(
triple_from_uname("Linux\naarch64").unwrap(),
"aarch64-unknown-linux-musl"
);
assert!(triple_from_uname("Darwin\narm64").is_err());
}
#[test]
fn ssh_args_includes_port_and_identity_when_set() {
let spec = SshSpec {
user: "ark".to_string(),
host: "example.com".to_string(),
port: Some(2222),
identity: Some(PathBuf::from("/home/op/.ssh/id_ed25519")),
};
let args = ssh_args(&spec);
assert_eq!(
args,
vec![
"-p".to_string(),
"2222".to_string(),
"-i".to_string(),
"/home/op/.ssh/id_ed25519".to_string(),
"-o".to_string(),
"StrictHostKeyChecking=accept-new".to_string(),
"ark@example.com".to_string(),
]
);
}
#[test]
fn ssh_args_omits_port_and_identity_when_unset() {
let spec = SshSpec {
user: "ark".to_string(),
host: "example.com".to_string(),
port: None,
identity: None,
};
let args = ssh_args(&spec);
assert_eq!(
args,
vec![
"-o".to_string(),
"StrictHostKeyChecking=accept-new".to_string(),
"ark@example.com".to_string(),
]
);
}
#[test]
fn pipe_file_command_stages_then_atomically_moves() {
let cmd = pipe_file_command("/home/ark/git-ark/bin/git-ark", 0o755);
assert!(cmd.starts_with("umask 077; cat > "));
assert!(cmd.contains("chmod 755"));
assert!(cmd.contains("mv "));
assert!(cmd.contains("/home/ark/git-ark/bin/git-ark.uploading"));
assert!(cmd.ends_with("/home/ark/git-ark/bin/git-ark"));
}
#[test]
fn append_unique_script_filters_marker_before_appending() {
let script = append_unique_script("~/.ssh/authorized_keys", "/home/ark/git-ark");
assert!(script.starts_with("f=~/.ssh/authorized_keys;"));
assert!(script.contains("grep -vF"));
assert!(!script.contains("grep -qF"));
assert!(script.contains("/home/ark/git-ark"));
assert!(script.contains(".bak"));
}
#[test]
#[cfg(unix)]
fn append_unique_script_replaces_stale_line_on_key_rotation() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("authorized_keys");
std::fs::write(&f, "ssh-rsa UNRELATED someone-else@laptop\n").unwrap();
let run = |line: &str| {
let script = append_unique_script(&f.display().to_string(), "/home/ark/git-ark");
let mut child = Command::new("sh")
.arg("-c")
.arg(&script)
.stdin(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(line.as_bytes())
.unwrap();
assert!(child.wait().unwrap().success());
};
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";
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";
run(old_line);
let after_first = std::fs::read_to_string(&f).unwrap();
assert!(after_first.contains("OLDKEY"));
assert!(after_first.contains("UNRELATED"));
run(new_line);
let after_rotation = std::fs::read_to_string(&f).unwrap();
assert!(
after_rotation.contains("NEWKEY"),
"rotated key must be authorized"
);
assert!(
!after_rotation.contains("OLDKEY"),
"stale key must be evicted, not left authorized"
);
assert!(
after_rotation.contains("UNRELATED"),
"unrelated authorized_keys lines must be preserved"
);
assert_eq!(
after_rotation.matches("git-ark-testbox").count(),
1,
"must not duplicate the forced-command line"
);
}
#[test]
fn selfcheck_command_quotes_install_dir() {
let cmd = selfcheck_command("/home/ark/git ark");
assert!(cmd.contains("'/home/ark/git ark/bin/git-ark'"));
assert!(cmd.contains("--config"));
assert!(cmd.contains("'/home/ark/git ark/config.toml'"));
}
#[test]
fn selfcheck_command_has_expected_shape_without_special_chars() {
let cmd = selfcheck_command("/home/ark/git-ark");
assert_eq!(
cmd,
"/home/ark/git-ark/bin/git-ark selfcheck --config /home/ark/git-ark/config.toml"
);
}
#[test]
fn parse_kv_reads_selfcheck_shaped_output() {
let out = "git_ark_version=0.1.0\nrepos_root=/home/ark/git-ark/repos\nconfig_valid=true\n";
let kv = parse_kv(out);
assert_eq!(kv.get("git_ark_version").map(String::as_str), Some("0.1.0"));
assert_eq!(kv.get("config_valid").map(String::as_str), Some("true"));
}
#[test]
fn client_dir_is_under_config() {
assert_eq!(
client_dir(Path::new("/home/op")),
PathBuf::from("/home/op/.config/git-ark")
);
}
#[test]
fn registry_path_honors_git_ark_hosts_override() {
std::env::set_var("GIT_ARK_HOSTS", "/tmp/git-ark-test-hosts.toml");
let p = registry_path().unwrap();
std::env::remove_var("GIT_ARK_HOSTS");
assert_eq!(p, PathBuf::from("/tmp/git-ark-test-hosts.toml"));
}
#[test]
fn upsert_ssh_config_block_appends_when_absent() {
let existing = "Host other\n HostName elsewhere.example.com\n";
let block = "Host git-ark-testbox\n HostName example.com\n Port 2222\n User ark\n IdentityFile /k\n IdentitiesOnly yes\n";
let out = upsert_ssh_config_block(existing, "testbox", block);
assert!(out.contains("Host other"));
assert!(out.contains("Host git-ark-testbox"));
assert!(out.contains("HostName example.com"));
}
#[test]
fn upsert_ssh_config_block_replaces_in_place_and_preserves_siblings() {
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";
let block = "Host git-ark-testbox\n HostName new.example.com\n Port 2222\n User ark\n IdentityFile /k\n IdentitiesOnly yes\n";
let out = upsert_ssh_config_block(existing, "testbox", block);
assert!(out.contains("Host before"));
assert!(out.contains("Host after"));
assert!(out.contains("HostName new.example.com"));
assert!(!out.contains("old.example.com"));
assert_eq!(out.matches("Host git-ark-testbox").count(), 1);
}
#[test]
fn upsert_ssh_config_block_is_idempotent() {
let block = "Host git-ark-testbox\n HostName example.com\n Port 2222\n User ark\n IdentityFile /k\n IdentitiesOnly yes\n";
let once = upsert_ssh_config_block("", "testbox", block);
let twice = upsert_ssh_config_block(&once, "testbox", block);
assert_eq!(once, twice);
assert_eq!(once.matches("Host git-ark-testbox").count(), 1);
}
#[test]
fn remove_ssh_config_block_drops_only_the_named_block() {
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";
let out = remove_ssh_config_block(existing, "testbox");
assert!(out.contains("Host before"));
assert!(out.contains("Host after"));
assert!(!out.contains("git-ark-testbox"));
assert!(!out.contains("old.example.com"));
}
#[test]
fn remove_ssh_config_block_absent_is_a_no_op() {
let existing = "Host other\n HostName elsewhere.example.com\n";
let out = remove_ssh_config_block(existing, "testbox");
assert_eq!(out, "Host other\n HostName elsewhere.example.com\n");
}
#[test]
fn remove_ssh_config_block_of_only_block_leaves_empty_file() {
let existing = "Host git-ark-testbox\n HostName example.com\n Port 2222\n";
let out = remove_ssh_config_block(existing, "testbox");
assert_eq!(out, "");
}
#[test]
fn remove_ssh_config_block_preserves_trailing_match_block() {
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";
let out = remove_ssh_config_block(existing, "box");
assert!(!out.contains("git-ark-box"));
assert!(out.contains("Match host *.corp"));
assert!(out.contains("User alice"));
}
#[test]
fn validate_host_name_accepts_ordinary_names() {
assert!(validate_host_name("testbox").is_ok());
assert!(validate_host_name("box-1.prod_east").is_ok());
}
#[test]
fn validate_host_name_rejects_path_traversal() {
assert!(validate_host_name("../../etc/passwd").is_err());
assert!(validate_host_name("..").is_err());
assert!(validate_host_name(".").is_err());
}
#[test]
fn validate_host_name_rejects_empty_and_unsafe_chars() {
assert!(validate_host_name("").is_err());
assert!(validate_host_name("box/name").is_err());
assert!(validate_host_name("box name").is_err());
assert!(validate_host_name("box$(rm)").is_err());
}
#[test]
fn home_dir_falls_back_to_userprofile_when_home_unset() {
let saved_home = std::env::var("HOME").ok();
std::env::remove_var("HOME");
std::env::set_var("USERPROFILE", "/Users/win");
let result = home_dir();
std::env::remove_var("USERPROFILE");
if let Some(home) = saved_home {
std::env::set_var("HOME", home);
}
assert_eq!(result.unwrap(), PathBuf::from("/Users/win"));
}
fn test_host(name: &str, mirror: bool) -> Host {
Host {
name: name.to_string(),
ssh_target: format!("ark@{name}.example.com"),
port: 22,
identity: None,
triple: "x86_64-unknown-linux-musl".to_string(),
install_dir: "/home/ark/git-ark".to_string(),
recipient: "age1abc".to_string(),
bucket: "b".to_string(),
region: "us-east-1".to_string(),
prefix: "git-ark".to_string(),
endpoint: None,
mirror,
push_alias: None,
}
}
#[test]
fn mirror_transition_promotes_target_when_no_current_mirror() {
let mut registry = Registry::default();
registry.upsert(test_host("nas", false));
registry.upsert(test_host("ec2", false));
let (demote, promote) = mirror_transition(®istry, "ec2");
assert_eq!(demote, None);
assert_eq!(promote, "ec2");
}
#[test]
fn mirror_transition_demotes_a_different_current_mirror() {
let mut registry = Registry::default();
registry.upsert(test_host("nas", true));
registry.upsert(test_host("ec2", false));
let (demote, promote) = mirror_transition(®istry, "ec2");
assert_eq!(demote, Some("nas".to_string()));
assert_eq!(promote, "ec2");
}
#[test]
fn mirror_transition_is_a_no_op_demote_when_target_already_mirror() {
let mut registry = Registry::default();
registry.upsert(test_host("nas", false));
registry.upsert(test_host("ec2", true));
let (demote, promote) = mirror_transition(®istry, "ec2");
assert_eq!(demote, None);
assert_eq!(promote, "ec2");
}
#[test]
fn format_status_row_reachable_healthy() {
let s = HostStatus::Reachable {
version: "0.1.0".to_string(),
disk_free: 60_000_000_000_000,
disk_low: false,
config_valid: true,
};
let row = format_status_row("nas", false, &s);
assert!(row.contains("nas"));
assert!(row.contains('✓'));
assert!(row.contains("0.1.0"));
assert!(
row.contains("54.6 TB"),
"expected a human free size, got: {row}"
);
assert!(!row.contains('★'));
}
#[test]
fn format_status_row_reachable_marks_mirror() {
let s = HostStatus::Reachable {
version: "0.1.0".to_string(),
disk_free: 60_000_000_000_000,
disk_low: false,
config_valid: true,
};
let row = format_status_row("nas", true, &s);
assert!(row.contains('★'));
assert!(row.to_lowercase().contains("mirror"));
}
#[test]
fn format_status_row_reachable_disk_low_warns() {
let s = HostStatus::Reachable {
version: "0.1.0".to_string(),
disk_free: 1_000_000_000,
disk_low: true,
config_valid: true,
};
let row = format_status_row("nas", false, &s);
assert!(row.contains('⚠'));
}
#[test]
fn format_status_row_broken_shows_reason() {
let s = HostStatus::Broken("selfcheck: config invalid".to_string());
let row = format_status_row("nas", false, &s);
assert!(row.starts_with('✗'));
assert!(row.to_lowercase().contains("broken"));
assert!(row.contains("selfcheck: config invalid"));
}
#[test]
fn format_status_row_unreachable() {
let s = HostStatus::Unreachable;
let row = format_status_row("nas", false, &s);
assert!(row.starts_with('✗'));
assert!(row.to_lowercase().contains("unreachable"));
}
#[test]
fn push_urls_uses_the_registrys_recorded_alias() {
let mut registry = Registry::default();
registry.upsert(test_host("ec2", false));
let mut nas = test_host("nas", false);
nas.push_alias = Some("git-ark".to_string());
registry.upsert(nas);
let names = vec!["nas".to_string(), "ec2".to_string(), "ghost".to_string()];
assert_eq!(
push_urls(®istry, &names, "myrepo"),
vec![
"git-ark:myrepo".to_string(), "git-ark-ec2:myrepo".to_string(), "git-ark-ghost:myrepo".to_string(), ]
);
}
#[test]
fn host_rows_resolve_alias_and_sort_by_name() {
let mut registry = Registry::default();
registry.upsert(test_host("zeta", false));
let mut nas = test_host("nas", true);
nas.push_alias = Some("git-ark".to_string());
registry.upsert(nas);
let rows = host_rows(®istry);
assert_eq!(rows[0].name, "nas"); assert_eq!(rows[0].alias, "git-ark"); assert!(rows[0].mirror);
assert_eq!(rows[1].name, "zeta");
assert_eq!(rows[1].alias, "git-ark-zeta");
let json = serde_json::to_string(&rows).unwrap();
assert!(json.contains("\"alias\":\"git-ark\""));
assert!(json.contains("\"name\":\"nas\""));
}
#[test]
fn needs_workflow_true_when_workflows_dir_has_entries() {
let dir = tempfile::tempdir().unwrap();
let workflows = dir.path().join(".github").join("workflows");
std::fs::create_dir_all(&workflows).unwrap();
std::fs::write(workflows.join("ci.yml"), "name: ci\n").unwrap();
assert!(needs_workflow(dir.path()));
}
#[test]
fn needs_workflow_false_when_workflows_dir_empty() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".github").join("workflows")).unwrap();
assert!(!needs_workflow(dir.path()));
}
#[test]
fn needs_workflow_false_when_no_github_dir() {
let dir = tempfile::tempdir().unwrap();
assert!(!needs_workflow(dir.path()));
}
fn token_check(
valid: bool,
repo: github::RepoAccess,
scopes: Option<Vec<&str>>,
needs_workflow: bool,
) -> github::TokenCheck {
github::TokenCheck {
valid,
login: if valid { Some("me".to_string()) } else { None },
scopes: scopes.map(|s| s.into_iter().map(str::to_string).collect()),
repo,
needs_workflow,
}
}
#[test]
fn format_check_reports_failed_auth() {
let c = token_check(false, github::RepoAccess::Absent, None, false);
let out = format_check(&c, "acme", "app");
assert!(out.contains("✗ token does not authenticate"));
}
#[test]
fn format_check_reports_successful_auth_with_login() {
let c = token_check(
true,
github::RepoAccess::Exists {
push: true,
admin: true,
},
None,
false,
);
let out = format_check(&c, "acme", "app");
assert!(out.contains("✓ authenticates (login: me)"));
}
#[test]
fn format_check_repo_admin() {
let c = token_check(
true,
github::RepoAccess::Exists {
push: true,
admin: true,
},
None,
false,
);
let out = format_check(&c, "acme", "app");
assert!(out.contains("✓ can administer acme/app"));
}
#[test]
fn format_check_repo_push_without_admin() {
let c = token_check(
true,
github::RepoAccess::Exists {
push: true,
admin: false,
},
None,
false,
);
let out = format_check(&c, "acme", "app");
assert!(out.contains("✓ can push acme/app"));
assert!(out.contains("no admin"));
}
#[test]
fn format_check_repo_no_push() {
let c = token_check(
true,
github::RepoAccess::Exists {
push: false,
admin: false,
},
None,
false,
);
let out = format_check(&c, "acme", "app");
assert!(out.contains("✗ no push access to acme/app"));
}
#[test]
fn format_check_repo_absent() {
let c = token_check(true, github::RepoAccess::Absent, None, false);
let out = format_check(&c, "acme", "app");
assert!(out.contains("⚠ acme/app"));
assert!(out.contains("404") && out.contains("org access"));
}
#[test]
fn format_check_repo_error() {
let c = token_check(
true,
github::RepoAccess::Error("HTTP 403".to_string()),
None,
false,
);
let out = format_check(&c, "acme", "app");
assert!(out.contains("✗ acme/app: HTTP 403"));
}
#[test]
fn format_check_omits_workflow_line_when_not_needed() {
let c = token_check(
true,
github::RepoAccess::Exists {
push: true,
admin: true,
},
Some(vec!["repo"]),
false,
);
let out = format_check(&c, "acme", "app");
assert!(!out.contains("workflow"));
}
#[test]
fn format_check_workflow_scope_present() {
let c = token_check(
true,
github::RepoAccess::Exists {
push: true,
admin: true,
},
Some(vec!["repo", "workflow"]),
true,
);
let out = format_check(&c, "acme", "app");
assert!(out.contains("✓ workflow scope"));
}
#[test]
fn format_check_workflow_scope_missing() {
let c = token_check(
true,
github::RepoAccess::Exists {
push: true,
admin: true,
},
Some(vec!["repo"]),
true,
);
let out = format_check(&c, "acme", "app");
assert!(out.contains("✗ missing 'workflow' scope"));
}
#[test]
fn format_check_workflow_scope_uninspectable() {
let c = token_check(
true,
github::RepoAccess::Exists {
push: true,
admin: true,
},
None,
true,
);
let out = format_check(&c, "acme", "app");
assert!(out.contains("⚠ repo ships workflows"));
}
#[test]
fn resolve_upgrade_targets_all_returns_every_host() {
let mut registry = Registry::default();
registry.upsert(test_host("nas", false));
registry.upsert(test_host("ec2", true));
let targets = resolve_upgrade_targets(®istry, &[], true).unwrap();
let mut names: Vec<&str> = targets.iter().map(|h| h.name.as_str()).collect();
names.sort();
assert_eq!(names, vec!["ec2", "nas"]);
}
#[test]
fn resolve_upgrade_targets_all_bails_on_empty_registry() {
let registry = Registry::default();
assert!(resolve_upgrade_targets(®istry, &[], true).is_err());
}
#[test]
fn resolve_upgrade_targets_named_returns_those_hosts() {
let mut registry = Registry::default();
registry.upsert(test_host("nas", false));
registry.upsert(test_host("ec2", true));
let names = vec!["ec2".to_string()];
let targets = resolve_upgrade_targets(®istry, &names, false).unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].name, "ec2");
}
#[test]
fn resolve_upgrade_targets_bails_on_unknown_name() {
let mut registry = Registry::default();
registry.upsert(test_host("nas", false));
let names = vec!["nope".to_string()];
let err = resolve_upgrade_targets(®istry, &names, false).unwrap_err();
assert!(err.to_string().contains("nope"));
}
#[test]
fn resolve_upgrade_targets_bails_when_neither_all_nor_names() {
let mut registry = Registry::default();
registry.upsert(test_host("nas", false));
let err = resolve_upgrade_targets(®istry, &[], false).unwrap_err();
assert!(err.to_string().contains("--all"));
}
#[test]
fn resolve_upgrade_targets_all_wins_over_names() {
let mut registry = Registry::default();
registry.upsert(test_host("nas", false));
registry.upsert(test_host("ec2", true));
let names = vec!["nope".to_string()];
let targets = resolve_upgrade_targets(®istry, &names, true).unwrap();
assert_eq!(targets.len(), 2);
}
#[test]
fn pick_or_plan_keygen_reuses_existing_ed25519() {
let dir = PathBuf::from("/home/op/.ssh");
let existing = vec!["id_ed25519".to_string()];
assert_eq!(
pick_or_plan_keygen(&dir, &existing),
KeyPlan::Reuse(dir.join("id_ed25519"))
);
}
#[test]
fn pick_or_plan_keygen_reuses_rsa_when_thats_all_there_is() {
let dir = PathBuf::from("/home/op/.ssh");
let existing = vec!["id_rsa".to_string()];
assert_eq!(
pick_or_plan_keygen(&dir, &existing),
KeyPlan::Reuse(dir.join("id_rsa"))
);
}
#[test]
fn pick_or_plan_keygen_prefers_ecdsa_over_rsa() {
let dir = PathBuf::from("/home/op/.ssh");
let existing = vec!["id_rsa".to_string(), "id_ecdsa".to_string()];
assert_eq!(
pick_or_plan_keygen(&dir, &existing),
KeyPlan::Reuse(dir.join("id_ecdsa"))
);
}
#[test]
fn pick_or_plan_keygen_generates_ed25519_when_none_exist() {
let dir = PathBuf::from("/home/op/.ssh");
assert_eq!(
pick_or_plan_keygen(&dir, &[]),
KeyPlan::Generate(dir.join("id_ed25519"))
);
}
}