1use 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#[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
48pub 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
61fn 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
88pub 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
105fn 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
135fn 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
149pub 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
157fn 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
186pub 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
202fn 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
213fn 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
223fn 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 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
255pub 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
264fn 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 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
297pub 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
313pub 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
326fn 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
334pub 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 pub binary: Option<PathBuf>,
351 pub mirror: bool,
355}
356
357fn 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
375pub fn host_add(args: &HostAddArgs) -> Result<()> {
380 validate_host_name(&args.name)?;
381
382 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 let probe_out = ssh_run(&spec, PROBE_SCRIPT)
410 .inspect_err(|e| {
411 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 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 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", ""])
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 ssh_mkdir_p(
493 &spec,
494 &[
495 format!("{}/bin", plan.install_dir),
496 format!("{}/repos", plan.install_dir),
497 ],
498 )?;
499
500 ssh_pipe_file(
502 &spec,
503 &format!("{}/bin/git-ark", plan.install_dir),
504 &binary_bytes,
505 0o755,
506 )?;
507
508 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 let line = forced_command_line(&plan.install_dir, &pubkey);
541 ssh_append_unique(&spec, "~/.ssh/authorized_keys", &line, &plan.install_dir)?;
542
543 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 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 let registry_path = registry_path()?;
580 let mut registry = Registry::load(®istry_path)?;
581 registry.upsert(Host {
582 name: args.name.clone(),
583 ssh_target: args.target.clone(),
584 port,
585 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(®istry_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 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
639pub fn host_list() -> Result<()> {
648 let registry = Registry::load(®istry_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
657pub fn host_remove(name: &str) -> Result<()> {
661 let registry_path = registry_path()?;
662 let mut registry = Registry::load(®istry_path)?;
663 if !registry.remove(name) {
664 println!("no such host: {name}");
665 return Ok(());
666 }
667 registry.save(®istry_path)?;
668
669 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
683fn base_prefix(ip: std::net::Ipv4Addr) -> String {
689 let o = ip.octets();
690 format!("{}.{}.{}", o[0], o[1], o[2])
691}
692
693pub 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 let reg = Registry::load(®istry_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#[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
747pub 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 Err(_) => match ssh_run(&spec, "true") {
783 Ok(_) => HostStatus::Broken("selfcheck failed".to_string()),
784 Err(_) => HostStatus::Unreachable,
785 },
786 }
787}
788
789pub 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
820pub fn status() -> Result<()> {
825 let registry = Registry::load(®istry_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
839fn 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
876fn 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
901pub fn upgrade(names: &[String], all: bool, binary: Option<&Path>) -> Result<()> {
909 let registry = Registry::load(®istry_path()?)?;
910 let targets = resolve_upgrade_targets(®istry, names, all)?;
911
912 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
971fn 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
988fn 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
1044fn 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 registry.save(®istry_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(®istry_path()?)?;
1096 Ok(())
1097}
1098
1099pub fn mirror_set(name: &str) -> Result<()> {
1103 let mut registry = Registry::load(®istry_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
1119pub fn mirror_show() -> Result<()> {
1121 let registry = Registry::load(®istry_path()?)?;
1122 match registry.mirror_host() {
1123 Some(h) => println!("{}", h.name),
1124 None => println!("none"),
1125 }
1126 Ok(())
1127}
1128
1129fn 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
1143fn 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
1163pub 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(®istry_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 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 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
1220fn 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
1233fn 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
1279pub 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#[derive(Debug, Clone, PartialEq)]
1328pub enum KeyPlan {
1329 Reuse(PathBuf),
1330 Generate(PathBuf),
1331}
1332
1333const KEY_CANDIDATES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"];
1336
1337pub 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
1351fn 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
1365pub fn host_setup_key(target: &str, port: Option<u16>) -> Result<()> {
1376 let ssh_dir = home_dir()?.join(".ssh");
1377 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(©_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 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 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 #[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 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 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 #[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(®istry, "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(®istry, "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(®istry, "ec2");
1811 assert_eq!(demote, None);
1812 assert_eq!(promote, "ec2");
1813 }
1814
1815 #[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 #[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 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 #[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(®istry, &[], 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(®istry, &[], 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(®istry, &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(®istry, &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(®istry, &[], 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 let names = vec!["nope".to_string()];
2145 let targets = resolve_upgrade_targets(®istry, &names, true).unwrap();
2146 assert_eq!(targets.len(), 2);
2147 }
2148
2149 #[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}