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::repo_policy::read_repo_policy;
20use crate::sshdiag::{classify_ssh_error, diagnosis_message, SshDiagnosis};
21use anyhow::{bail, Context, Result};
22use std::io::Write;
23use std::path::{Path, PathBuf};
24use std::process::{Command, Stdio};
25
26#[derive(Debug, Clone)]
33pub struct SshSpec {
34 pub user: String,
35 pub host: String,
36 pub port: Option<u16>,
37 pub identity: Option<PathBuf>,
38}
39
40impl SshSpec {
41 fn display(&self) -> String {
42 format!("{}@{}", self.user, self.host)
43 }
44}
45
46pub fn parse_target(target: &str) -> Result<(String, String)> {
50 let (user, host) = target
51 .split_once('@')
52 .ok_or_else(|| anyhow::anyhow!("target must be `user@host` (got {target:?})"))?;
53 if user.is_empty() || host.is_empty() {
54 bail!("target must be `user@host` (got {target:?})");
55 }
56 Ok((user.to_string(), host.to_string()))
57}
58
59fn ssh_args(spec: &SshSpec) -> Vec<String> {
65 let mut args = Vec::new();
66 if let Some(port) = spec.port {
67 args.push("-p".to_string());
68 args.push(port.to_string());
69 }
70 if let Some(identity) = &spec.identity {
71 args.push("-i".to_string());
72 args.push(identity.display().to_string());
73 }
74 args.push("-o".to_string());
75 args.push("StrictHostKeyChecking=accept-new".to_string());
76 args.push(spec.display());
77 args
78}
79
80fn ssh_command(spec: &SshSpec) -> Command {
81 let mut cmd = Command::new("ssh");
82 cmd.args(ssh_args(spec));
83 cmd
84}
85
86pub fn ssh_run(spec: &SshSpec, cmd: &str) -> Result<String> {
89 let out = ssh_command(spec)
90 .arg(cmd)
91 .output()
92 .with_context(|| format!("spawning ssh to {}", spec.display()))?;
93 if !out.status.success() {
94 bail!(
95 "ssh {} failed: {}",
96 spec.display(),
97 String::from_utf8_lossy(&out.stderr).trim()
98 );
99 }
100 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
101}
102
103fn ssh_run_with_stdin(spec: &SshSpec, cmd: &str, input: &[u8]) -> Result<String> {
107 let mut child = ssh_command(spec)
108 .arg(cmd)
109 .stdin(Stdio::piped())
110 .stdout(Stdio::piped())
111 .stderr(Stdio::piped())
112 .spawn()
113 .with_context(|| format!("spawning ssh to {}", spec.display()))?;
114 child
115 .stdin
116 .take()
117 .expect("stdin was piped")
118 .write_all(input)
119 .context("writing to ssh stdin")?;
120 let out = child
121 .wait_with_output()
122 .with_context(|| format!("waiting on ssh to {}", spec.display()))?;
123 if !out.status.success() {
124 bail!(
125 "ssh {} failed: {}",
126 spec.display(),
127 String::from_utf8_lossy(&out.stderr).trim()
128 );
129 }
130 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
131}
132
133fn pipe_file_command(remote_path: &str, mode: u32) -> String {
141 let tmp = format!("{remote_path}.uploading");
142 let remote_q = shell_words::quote(remote_path);
143 let tmp_q = shell_words::quote(&tmp);
144 format!("umask 077; cat > {tmp_q} && chmod {mode:o} {tmp_q} && mv {tmp_q} {remote_q}")
145}
146
147pub fn ssh_pipe_file(spec: &SshSpec, remote_path: &str, bytes: &[u8], mode: u32) -> Result<()> {
151 ssh_run_with_stdin(spec, &pipe_file_command(remote_path, mode), bytes)?;
152 Ok(())
153}
154
155fn append_unique_script(remote_file: &str, marker: &str) -> String {
175 let marker_q = shell_words::quote(marker);
176 format!(
177 "f={remote_file}; d=$(dirname \"$f\"); mkdir -p \"$d\" && touch \"$f\" && \
178 line=\"$(cat)\" && {{ [ -f \"$f.bak\" ] || cp \"$f\" \"$f.bak\"; }} && \
179 {{ grep -vF {marker_q} \"$f\" > \"$f.tmp\" 2>/dev/null || true; }} && \
180 printf '%s\\n' \"$line\" >> \"$f.tmp\" && mv \"$f.tmp\" \"$f\""
181 )
182}
183
184pub fn ssh_append_unique(
187 spec: &SshSpec,
188 remote_file: &str,
189 line: &str,
190 marker: &str,
191) -> Result<()> {
192 ssh_run_with_stdin(
193 spec,
194 &append_unique_script(remote_file, marker),
195 line.as_bytes(),
196 )?;
197 Ok(())
198}
199
200fn selfcheck_command(install_dir: &str) -> String {
206 let bin_q = shell_words::quote(&format!("{install_dir}/bin/git-ark")).into_owned();
207 let cfg_q = shell_words::quote(&format!("{install_dir}/config.toml")).into_owned();
208 format!("{bin_q} selfcheck --config {cfg_q}")
209}
210
211fn ssh_mkdir_p(spec: &SshSpec, dirs: &[String]) -> Result<()> {
213 let quoted: Vec<String> = dirs
214 .iter()
215 .map(|d| shell_words::quote(d).into_owned())
216 .collect();
217 ssh_run(spec, &format!("mkdir -p {}", quoted.join(" ")))?;
218 Ok(())
219}
220
221fn client_dir(home: &Path) -> PathBuf {
231 home.join(".config").join("git-ark")
232}
233
234fn home_dir() -> Result<PathBuf> {
235 if let Ok(home) = std::env::var("HOME") {
236 return Ok(PathBuf::from(home));
237 }
238 std::env::var("USERPROFILE")
241 .map(PathBuf::from)
242 .context("neither HOME nor USERPROFILE is set")
243}
244
245pub fn client_keydir() -> Result<PathBuf> {
246 Ok(client_dir(&home_dir()?))
247}
248
249pub fn client_ssh_config_path() -> Result<PathBuf> {
250 Ok(home_dir()?.join(".ssh").join("config"))
251}
252
253pub fn registry_path() -> Result<PathBuf> {
256 if let Ok(p) = std::env::var("GIT_ARK_HOSTS") {
257 return Ok(PathBuf::from(p));
258 }
259 Ok(client_keydir()?.join("hosts.toml"))
260}
261
262fn drop_ssh_config_block<'a>(existing: &'a str, name: &str) -> Vec<&'a str> {
268 let marker = format!("Host git-ark-{name}");
269 let lines: Vec<&str> = existing.lines().collect();
270 let mut kept: Vec<&str> = Vec::new();
271 let mut i = 0;
272 while i < lines.len() {
273 if lines[i].trim() == marker {
274 i += 1;
275 while i < lines.len()
279 && !lines[i].trim_start().starts_with("Host ")
280 && !lines[i].trim_start().starts_with("Match ")
281 {
282 i += 1;
283 }
284 continue;
285 }
286 kept.push(lines[i]);
287 i += 1;
288 }
289 while matches!(kept.last(), Some(l) if l.trim().is_empty()) {
290 kept.pop();
291 }
292 kept
293}
294
295pub fn upsert_ssh_config_block(existing: &str, name: &str, block: &str) -> String {
301 let kept = drop_ssh_config_block(existing, name);
302 let mut out = kept.join("\n");
303 if !out.is_empty() {
304 out.push_str("\n\n");
305 }
306 out.push_str(block.trim_end());
307 out.push('\n');
308 out
309}
310
311pub fn remove_ssh_config_block(existing: &str, name: &str) -> String {
315 let kept = drop_ssh_config_block(existing, name);
316 if kept.is_empty() {
317 return String::new();
318 }
319 let mut out = kept.join("\n");
320 out.push('\n');
321 out
322}
323
324fn parse_kv(text: &str) -> std::collections::HashMap<String, String> {
326 text.lines()
327 .filter_map(|l| l.split_once('='))
328 .map(|(k, v)| (k.to_string(), v.to_string()))
329 .collect()
330}
331
332pub struct HostAddArgs {
337 pub name: String,
338 pub target: String,
339 pub port: Option<u16>,
340 pub identity: Option<PathBuf>,
341 pub bucket: String,
342 pub region: String,
343 pub prefix: String,
344 pub endpoint: Option<String>,
345 pub recipient: String,
346 pub binary: PathBuf,
347 pub mirror: bool,
351}
352
353fn validate_host_name(name: &str) -> Result<()> {
359 let ok = !name.is_empty()
360 && name != "."
361 && name != ".."
362 && name
363 .chars()
364 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
365 if !ok {
366 bail!("host name {name:?} must match [A-Za-z0-9._-]+ (and not be `.` or `..`)");
367 }
368 Ok(())
369}
370
371pub fn host_add(args: &HostAddArgs) -> Result<()> {
376 validate_host_name(&args.name)?;
377
378 let key_id = std::env::var("GIT_ARK_HOST_S3_KEY_ID")
382 .ok()
383 .filter(|s| !s.is_empty());
384 let secret = std::env::var("GIT_ARK_HOST_S3_SECRET")
385 .ok()
386 .filter(|s| !s.is_empty());
387 let (key_id, secret) = match (key_id, secret) {
388 (Some(k), Some(s)) => (k, s),
389 _ => bail!(
390 "GIT_ARK_HOST_S3_KEY_ID and GIT_ARK_HOST_S3_SECRET must both be set \
391 (the host's write-only S3 credential); nothing was written to the host"
392 ),
393 };
394
395 let (user, host) = parse_target(&args.target)?;
396 let spec = SshSpec {
397 user: user.clone(),
398 host: host.clone(),
399 port: args.port,
400 identity: args.identity.clone(),
401 };
402
403 let probe_out = ssh_run(&spec, PROBE_SCRIPT)
406 .inspect_err(|e| {
407 let diagnosis = classify_ssh_error(&e.to_string());
411 if diagnosis != SshDiagnosis::Other {
412 eprintln!(
413 "{}",
414 diagnosis_message(diagnosis, &spec.display(), args.port.unwrap_or(22))
415 );
416 }
417 })
418 .with_context(|| format!("probing {}", spec.display()))?;
419 let facts = parse_probe(&probe_out);
420 let plan = match assess(&facts) {
421 Ok(plan) => plan,
422 Err(reasons) => {
423 eprintln!("✗ {} is not ready for git-ark:", spec.display());
424 for r in &reasons {
425 eprintln!(" - {r}");
426 }
427 bail!(
428 "preflight probe failed ({} reason(s)); nothing was written to the host",
429 reasons.len()
430 );
431 }
432 };
433
434 let binary_bytes = std::fs::read(&args.binary)
438 .with_context(|| format!("reading --binary {}", args.binary.display()))?;
439 let binary_name = args.binary.to_string_lossy();
440 if !binary_name.contains(&plan.triple) {
441 eprintln!(
442 "⚠ --binary {} doesn't look like it's built for {} (the probed host's triple) — continuing anyway",
443 args.binary.display(),
444 plan.triple
445 );
446 }
447
448 let keydir = client_keydir()?;
452 std::fs::create_dir_all(&keydir).with_context(|| format!("creating {}", keydir.display()))?;
453 let key_path = keydir.join(&args.name);
454 let pub_path = keydir.join(format!("{}.pub", args.name));
455 if !key_path.exists() {
456 let status = Command::new("ssh-keygen")
457 .args(["-t", "ed25519", "-N", ""])
458 .arg("-f")
459 .arg(&key_path)
460 .arg("-C")
461 .arg(format!("git-ark-{}", args.name))
462 .status()
463 .context("spawning ssh-keygen")?;
464 if !status.success() {
465 bail!("ssh-keygen failed generating {}", key_path.display());
466 }
467 }
468 let pubkey = std::fs::read_to_string(&pub_path)
469 .with_context(|| format!("reading {}", pub_path.display()))?
470 .trim()
471 .to_string();
472
473 ssh_mkdir_p(
475 &spec,
476 &[
477 format!("{}/bin", plan.install_dir),
478 format!("{}/repos", plan.install_dir),
479 ],
480 )?;
481
482 ssh_pipe_file(
484 &spec,
485 &format!("{}/bin/git-ark", plan.install_dir),
486 &binary_bytes,
487 0o755,
488 )?;
489
490 let s3 = S3Config {
498 bucket: args.bucket.clone(),
499 region: args.region.clone(),
500 prefix: per_host_prefix(&args.prefix, &args.name),
501 endpoint: args.endpoint.clone(),
502 };
503 let config_text = render_config(&plan.install_dir, &args.recipient, &s3, false);
504 ssh_pipe_file(
505 &spec,
506 &format!("{}/config.toml", plan.install_dir),
507 config_text.as_bytes(),
508 0o644,
509 )?;
510 let secrets_text = render_secrets(&key_id, &secret, None);
511 ssh_pipe_file(
512 &spec,
513 &format!("{}/secrets.toml", plan.install_dir),
514 secrets_text.as_bytes(),
515 0o600,
516 )?;
517
518 let line = forced_command_line(&plan.install_dir, &pubkey);
523 ssh_append_unique(&spec, "~/.ssh/authorized_keys", &line, &plan.install_dir)?;
524
525 let selfcheck_out = ssh_run(&spec, &selfcheck_command(&plan.install_dir))?;
528 let selfcheck = parse_kv(&selfcheck_out);
529 let config_valid = selfcheck.get("config_valid").map(String::as_str) == Some("true");
530 let version = selfcheck.get("git_ark_version");
531 if !config_valid || version.is_none() {
532 bail!(
533 "selfcheck did not report a healthy host (config_valid={:?}, git_ark_version={:?}); \
534 the host has been wired, but `host add {}` should be re-run once fixed",
535 selfcheck.get("config_valid"),
536 version,
537 args.name
538 );
539 }
540
541 let port = args.port.unwrap_or(22);
543 let block = ssh_config_block(
544 &args.name,
545 &host,
546 port,
547 &user,
548 &key_path.display().to_string(),
549 );
550 let ssh_config_path = client_ssh_config_path()?;
551 if let Some(parent) = ssh_config_path.parent() {
552 std::fs::create_dir_all(parent)
553 .with_context(|| format!("creating {}", parent.display()))?;
554 }
555 let existing = std::fs::read_to_string(&ssh_config_path).unwrap_or_default();
556 let updated = upsert_ssh_config_block(&existing, &args.name, &block);
557 std::fs::write(&ssh_config_path, updated)
558 .with_context(|| format!("writing {}", ssh_config_path.display()))?;
559
560 let registry_path = registry_path()?;
562 let mut registry = Registry::load(®istry_path)?;
563 registry.upsert(Host {
564 name: args.name.clone(),
565 ssh_target: args.target.clone(),
566 port,
567 identity: args.identity.clone(),
573 triple: plan.triple.clone(),
574 install_dir: plan.install_dir.clone(),
575 recipient: args.recipient.clone(),
576 bucket: args.bucket.clone(),
577 region: args.region.clone(),
578 prefix: args.prefix.clone(),
579 endpoint: args.endpoint.clone(),
580 mirror: false,
581 });
582 registry.save(®istry_path)?;
583
584 println!(
585 "✓ {} ready — git push git-ark-{}:<repo>",
586 args.name, args.name
587 );
588 if let (Some(pct), Some(bytes)) = (
589 selfcheck.get("disk_free_percent"),
590 selfcheck.get("disk_free_bytes"),
591 ) {
592 println!(" disk: {pct}% free ({bytes} bytes)");
593 }
594
595 if args.mirror {
601 let token = match std::env::var("GIT_ARK_GITHUB_TOKEN")
602 .ok()
603 .filter(|s| !s.is_empty())
604 {
605 Some(t) => t,
606 None => bail!(
607 "GIT_ARK_GITHUB_TOKEN must be set to add {} as the GitHub mirror (--mirror); \
608 the host was still added, just not designated the mirror \
609 (run `git-ark mirror set {}` once the token is set)",
610 args.name,
611 args.name
612 ),
613 };
614 designate_mirror(&mut registry, &args.name, &token)?;
615 println!("✓ {} designated the GitHub mirror", args.name);
616 }
617
618 Ok(())
619}
620
621pub fn host_list() -> Result<()> {
630 let registry = Registry::load(®istry_path()?)?;
631 let mut hosts: Vec<&Host> = registry.list().iter().collect();
632 hosts.sort_by(|a, b| a.name.cmp(&b.name));
633 for h in hosts {
634 println!("{} {} {}", h.name, h.ssh_target, h.triple);
635 }
636 Ok(())
637}
638
639pub fn host_remove(name: &str) -> Result<()> {
643 let registry_path = registry_path()?;
644 let mut registry = Registry::load(®istry_path)?;
645 if !registry.remove(name) {
646 println!("no such host: {name}");
647 return Ok(());
648 }
649 registry.save(®istry_path)?;
650
651 let ssh_config_path = client_ssh_config_path()?;
655 if let Ok(existing) = std::fs::read_to_string(&ssh_config_path) {
656 let updated = remove_ssh_config_block(&existing, name);
657 std::fs::write(&ssh_config_path, updated)
658 .with_context(|| format!("writing {}", ssh_config_path.display()))?;
659 }
660
661 println!("removed {name}");
662 Ok(())
663}
664
665fn base_prefix(ip: std::net::Ipv4Addr) -> String {
671 let o = ip.octets();
672 format!("{}.{}.{}", o[0], o[1], o[2])
673}
674
675pub fn host_discover(port: u16, timeout_ms: u64, subnet: Option<std::net::Ipv4Addr>) -> Result<()> {
680 let base = match subnet {
681 Some(b) => b,
682 None => crate::subnet::local_ipv4()
683 .context("could not determine the local IPv4 address; pass --subnet a.b.c.0")?,
684 };
685 let ips = crate::subnet::hosts_in_slash24(base);
686 let found =
687 crate::scan::scan_port(&ips, port, std::time::Duration::from_millis(timeout_ms), 64);
688
689 let reg = Registry::load(®istry_path()?).unwrap_or_default();
691 if found.is_empty() {
692 println!(
693 "no hosts responding on port {port} in {}.0/24",
694 base_prefix(base)
695 );
696 return Ok(());
697 }
698 for ip in found {
699 let s = ip.to_string();
700 match reg.list().iter().find(|h| h.ssh_target.contains(&s)) {
701 Some(h) => println!("{s}:{port} (known: {})", h.name),
702 None => println!("{s}:{port}"),
703 }
704 }
705 Ok(())
706}
707
708#[derive(Debug, Clone, PartialEq)]
718pub enum HostStatus {
719 Reachable {
720 version: String,
721 disk_free: u64,
722 disk_low: bool,
723 config_valid: bool,
724 },
725 Broken(String),
726 Unreachable,
727}
728
729pub fn probe_status(host: &Host) -> HostStatus {
734 let (user, hostname) = match parse_target(&host.ssh_target) {
735 Ok(v) => v,
736 Err(e) => return HostStatus::Broken(e.to_string()),
737 };
738 let spec = SshSpec {
739 user,
740 host: hostname,
741 port: Some(host.port),
742 identity: host.identity.clone(),
743 };
744
745 match ssh_run(&spec, &selfcheck_command(&host.install_dir)) {
746 Ok(out) => {
747 let kv = parse_kv(&out);
748 let version = kv.get("git_ark_version").cloned().unwrap_or_default();
749 let disk_free = kv
750 .get("disk_free_bytes")
751 .and_then(|s| s.parse::<u64>().ok())
752 .unwrap_or(0);
753 let disk_low = kv.get("disk_low").map(String::as_str) == Some("true");
754 let config_valid = kv.get("config_valid").map(String::as_str) == Some("true");
755 HostStatus::Reachable {
756 version,
757 disk_free,
758 disk_low,
759 config_valid,
760 }
761 }
762 Err(_) => match ssh_run(&spec, "true") {
765 Ok(_) => HostStatus::Broken("selfcheck failed".to_string()),
766 Err(_) => HostStatus::Unreachable,
767 },
768 }
769}
770
771pub fn format_status_row(name: &str, mirror: bool, s: &HostStatus) -> String {
775 let mirror_marker = if mirror { " ★ mirror" } else { "" };
776 match s {
777 HostStatus::Reachable {
778 version,
779 disk_free,
780 disk_low,
781 config_valid,
782 } => {
783 let mark = if *disk_low { "⚠" } else { "✓" };
784 let mut line = format!(
785 "{mark} {name} v{version} {} free",
786 crate::backup::human_bytes(*disk_free)
787 );
788 if *disk_low {
789 line.push_str(" disk low");
790 }
791 if !config_valid {
792 line.push_str(" config invalid");
793 }
794 line.push_str(mirror_marker);
795 line
796 }
797 HostStatus::Broken(reason) => format!("✗ {name} broken: {reason}{mirror_marker}"),
798 HostStatus::Unreachable => format!("✗ {name} unreachable{mirror_marker}"),
799 }
800}
801
802pub fn status() -> Result<()> {
807 let registry = Registry::load(®istry_path()?)?;
808 let mut hosts: Vec<&Host> = registry.list().iter().collect();
809 if hosts.is_empty() {
810 println!("no hosts — run `git-ark host add`");
811 return Ok(());
812 }
813 hosts.sort_by(|a, b| a.name.cmp(&b.name));
814 for h in hosts {
815 let s = probe_status(h);
816 println!("{}", format_status_row(&h.name, h.mirror, &s));
817 }
818 Ok(())
819}
820
821fn resolve_upgrade_targets(registry: &Registry, names: &[String], all: bool) -> Result<Vec<Host>> {
832 if all {
833 if registry.list().is_empty() {
834 bail!("no hosts registered — nothing to upgrade");
835 }
836 return Ok(registry.list().to_vec());
837 }
838 if names.is_empty() {
839 bail!("name a host or pass --all");
840 }
841 names
842 .iter()
843 .map(|name| {
844 registry
845 .list()
846 .iter()
847 .find(|h| &h.name == name)
848 .cloned()
849 .ok_or_else(|| {
850 anyhow::anyhow!(
851 "no such host: {name} (run `git-ark host list` to see registered hosts)"
852 )
853 })
854 })
855 .collect()
856}
857
858fn upgrade_one(host: &Host, bytes: &[u8]) -> Result<()> {
863 let (user, hostname) = parse_target(&host.ssh_target)?;
864 let spec = SshSpec {
865 user,
866 host: hostname,
867 port: Some(host.port),
868 identity: host.identity.clone(),
869 };
870 ssh_pipe_file(
871 &spec,
872 &format!("{}/bin/git-ark", host.install_dir),
873 bytes,
874 0o755,
875 )?;
876 let out = ssh_run(&spec, &selfcheck_command(&host.install_dir))?;
877 let kv = parse_kv(&out);
878 let version = kv.get("git_ark_version").cloned().unwrap_or_default();
879 println!("✓ {} upgraded → {version}", host.name);
880 Ok(())
881}
882
883pub fn upgrade(names: &[String], all: bool, binary: &Path) -> Result<()> {
891 let registry = Registry::load(®istry_path()?)?;
892 let targets = resolve_upgrade_targets(®istry, names, all)?;
893
894 let bytes =
895 std::fs::read(binary).with_context(|| format!("reading --binary {}", binary.display()))?;
896 let binary_name = binary.to_string_lossy();
897
898 let mut any_failed = false;
899 for host in &targets {
900 if !binary_name.contains(&host.triple) {
901 eprintln!(
902 "⚠ --binary {} doesn't look like it's built for {}'s triple ({}) — continuing anyway",
903 binary.display(),
904 host.name,
905 host.triple
906 );
907 }
908 if let Err(e) = upgrade_one(host, &bytes) {
909 println!("✗ {}: {e}", host.name);
910 any_failed = true;
911 }
912 }
913
914 if any_failed {
915 bail!("one or more hosts failed to upgrade — see above");
916 }
917 Ok(())
918}
919
920fn mirror_transition(registry: &Registry, target: &str) -> (Option<String>, String) {
930 let demote = registry
931 .mirror_host()
932 .filter(|h| h.name != target)
933 .map(|h| h.name.clone());
934 (demote, target.to_string())
935}
936
937fn apply_host_config(host: &Host, mirror: bool, github_token: Option<&str>) -> Result<()> {
946 let key_id = std::env::var("GIT_ARK_HOST_S3_KEY_ID")
947 .ok()
948 .filter(|s| !s.is_empty());
949 let secret = std::env::var("GIT_ARK_HOST_S3_SECRET")
950 .ok()
951 .filter(|s| !s.is_empty());
952 let (key_id, secret) = match (key_id, secret) {
953 (Some(k), Some(s)) => (k, s),
954 _ => bail!(
955 "GIT_ARK_HOST_S3_KEY_ID and GIT_ARK_HOST_S3_SECRET must both be set to re-apply \
956 {}'s config (changing the mirror re-renders secrets.toml, which needs the host's \
957 write-only S3 credential, not just the GitHub token)",
958 host.name
959 ),
960 };
961
962 let (user, hostname) = parse_target(&host.ssh_target)?;
963 let spec = SshSpec {
964 user,
965 host: hostname,
966 port: Some(host.port),
967 identity: host.identity.clone(),
968 };
969
970 let s3 = S3Config {
971 bucket: host.bucket.clone(),
972 region: host.region.clone(),
973 prefix: per_host_prefix(&host.prefix, &host.name),
974 endpoint: host.endpoint.clone(),
975 };
976 let config_text = render_config(&host.install_dir, &host.recipient, &s3, mirror);
977 ssh_pipe_file(
978 &spec,
979 &format!("{}/config.toml", host.install_dir),
980 config_text.as_bytes(),
981 0o644,
982 )?;
983 let secrets_text = render_secrets(&key_id, &secret, github_token);
984 ssh_pipe_file(
985 &spec,
986 &format!("{}/secrets.toml", host.install_dir),
987 secrets_text.as_bytes(),
988 0o600,
989 )?;
990 Ok(())
991}
992
993fn designate_mirror(registry: &mut Registry, name: &str, github_token: &str) -> Result<()> {
1000 if !registry.list().iter().any(|h| h.name == name) {
1001 bail!("no such host: {name} (run `git-ark host list` to see registered hosts)");
1002 }
1003 let (demote, promote) = mirror_transition(registry, name);
1004
1005 if let Some(old_name) = &demote {
1006 let old = registry
1007 .list()
1008 .iter()
1009 .find(|h| &h.name == old_name)
1010 .expect("mirror_transition only names a host present in the registry")
1011 .clone();
1012 apply_host_config(&old, false, None)?;
1013 registry
1014 .hosts
1015 .iter_mut()
1016 .find(|h| h.name == old.name)
1017 .expect("just found above")
1018 .mirror = false;
1019 registry.save(®istry_path()?)?;
1023 }
1024
1025 let target = registry
1026 .list()
1027 .iter()
1028 .find(|h| h.name == promote)
1029 .expect("checked at the top of this fn")
1030 .clone();
1031 apply_host_config(&target, true, Some(github_token)).with_context(|| {
1032 format!(
1033 "demoted the previous mirror but could not promote {promote} — the fleet has no \
1034 mirror right now; re-run `git-ark mirror set {promote}` once it is reachable"
1035 )
1036 })?;
1037 registry
1038 .hosts
1039 .iter_mut()
1040 .find(|h| h.name == promote)
1041 .expect("just found above")
1042 .mirror = true;
1043
1044 registry.save(®istry_path()?)?;
1045 Ok(())
1046}
1047
1048pub fn mirror_set(name: &str) -> Result<()> {
1052 let mut registry = Registry::load(®istry_path()?)?;
1053 if !registry.list().iter().any(|h| h.name == name) {
1054 bail!("no such host: {name} (run `git-ark host list` to see registered hosts)");
1055 }
1056 let token = match std::env::var("GIT_ARK_GITHUB_TOKEN")
1057 .ok()
1058 .filter(|s| !s.is_empty())
1059 {
1060 Some(t) => t,
1061 None => bail!("GIT_ARK_GITHUB_TOKEN must be set to designate a GitHub mirror"),
1062 };
1063 designate_mirror(&mut registry, name, &token)?;
1064 println!("✓ {name} is now the GitHub mirror");
1065 Ok(())
1066}
1067
1068pub fn mirror_show() -> Result<()> {
1070 let registry = Registry::load(®istry_path()?)?;
1071 match registry.mirror_host() {
1072 Some(h) => println!("{}", h.name),
1073 None => println!("none"),
1074 }
1075 Ok(())
1076}
1077
1078fn push_urls(host_names: &[String], repo: &str) -> Vec<String> {
1086 host_names
1087 .iter()
1088 .map(|name| format!("git-ark-{name}:{repo}"))
1089 .collect()
1090}
1091
1092fn git_remote(repo_path: &Path, args: &[&str]) -> Result<()> {
1095 let out = Command::new("git")
1096 .arg("-C")
1097 .arg(repo_path)
1098 .arg("remote")
1099 .args(args)
1100 .output()
1101 .with_context(|| format!("spawning git remote {}", args.join(" ")))?;
1102 if !out.status.success() {
1103 bail!(
1104 "git remote {} failed: {}",
1105 args.join(" "),
1106 String::from_utf8_lossy(&out.stderr).trim()
1107 );
1108 }
1109 Ok(())
1110}
1111
1112pub fn route(repo_path: &Path, host_names: &[String]) -> Result<()> {
1119 if host_names.is_empty() {
1120 bail!("--to <names> is required (comma-separated host names)");
1121 }
1122
1123 let registry = Registry::load(®istry_path()?)?;
1124 let unknown: Vec<&String> = host_names
1125 .iter()
1126 .filter(|n| !registry.list().iter().any(|h| &h.name == *n))
1127 .collect();
1128 if !unknown.is_empty() {
1129 let names: Vec<&str> = unknown.iter().map(|s| s.as_str()).collect();
1130 bail!(
1131 "unknown host(s): {} (run `git-ark host list` to see registered hosts)",
1132 names.join(", ")
1133 );
1134 }
1135
1136 let abs = repo_path
1139 .canonicalize()
1140 .with_context(|| format!("resolving {}", repo_path.display()))?;
1141 let repo = abs
1142 .file_name()
1143 .and_then(|n| n.to_str())
1144 .ok_or_else(|| anyhow::anyhow!("{} has no usable directory name", repo_path.display()))?
1145 .to_string();
1146 let urls = push_urls(host_names, &repo);
1147
1148 let _ = Command::new("git")
1152 .arg("-C")
1153 .arg(repo_path)
1154 .args(["remote", "remove", "git-ark"])
1155 .output();
1156
1157 git_remote(repo_path, &["add", "git-ark", &urls[0]])?;
1158 for url in &urls {
1159 git_remote(repo_path, &["set-url", "--add", "--push", "git-ark", url])?;
1160 }
1161
1162 println!(
1163 "✓ {repo} → git push git-ark fans to: {}",
1164 host_names.join(",")
1165 );
1166 Ok(())
1167}
1168
1169fn needs_workflow(repo_path: &Path) -> bool {
1177 std::fs::read_dir(repo_path.join(".github").join("workflows"))
1178 .map(|mut entries| entries.next().is_some())
1179 .unwrap_or(false)
1180}
1181
1182fn format_check(c: &github::TokenCheck, owner: &str, repo: &str) -> String {
1185 let mut lines = Vec::new();
1186
1187 lines.push(if c.valid {
1188 format!(
1189 "✓ authenticates (login: {})",
1190 c.login.as_deref().unwrap_or("?")
1191 )
1192 } else {
1193 "✗ token does not authenticate".to_string()
1194 });
1195
1196 lines.push(match &c.repo {
1197 github::RepoAccess::Exists { admin: true, .. } => {
1198 format!("✓ can administer {owner}/{repo}")
1199 }
1200 github::RepoAccess::Exists {
1201 push: true,
1202 admin: false,
1203 } => format!("✓ can push {owner}/{repo} (no admin — ok if it already exists)"),
1204 github::RepoAccess::Exists { push: false, .. } => {
1205 format!("✗ no push access to {owner}/{repo}")
1206 }
1207 github::RepoAccess::Absent => format!(
1208 "⚠ {owner}/{repo}: 404 — absent (created on first mirror) OR the token can't see it \
1209 (missing org access / unauthorized SSO PAT). GitHub returns 404 either way — verify \
1210 before relying on it"
1211 ),
1212 github::RepoAccess::Error(e) => format!("✗ {owner}/{repo}: {e}"),
1213 });
1214
1215 if c.needs_workflow {
1216 lines.push(match c.has_workflow_scope() {
1217 Some(true) => "✓ workflow scope".to_string(),
1218 Some(false) => {
1219 "✗ missing 'workflow' scope (needed to push .github/workflows/)".to_string()
1220 }
1221 None => "⚠ repo ships workflows; a fine-grained PAT needs Workflows:write (can't verify here)".to_string(),
1222 });
1223 }
1224
1225 lines.join("\n")
1226}
1227
1228pub fn mirror_check(repo_path: &Path) -> Result<()> {
1233 let policy = read_repo_policy(repo_path)?;
1234 let g = policy
1235 .as_ref()
1236 .and_then(|p| p.github.as_ref())
1237 .filter(|g| g.enabled)
1238 .ok_or_else(|| {
1239 anyhow::anyhow!(
1240 "{} has no enabled `github:` block in .git-ark.yml — nothing to check",
1241 repo_path.display()
1242 )
1243 })?;
1244 let repo_name = g.repo.clone().unwrap_or_else(|| {
1245 repo_path
1246 .canonicalize()
1247 .ok()
1248 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
1249 .unwrap_or_default()
1250 });
1251
1252 let token = std::env::var("GIT_ARK_GITHUB_TOKEN")
1253 .ok()
1254 .filter(|s| !s.is_empty())
1255 .ok_or_else(|| anyhow::anyhow!("GIT_ARK_GITHUB_TOKEN must be set to run `mirror check`"))?;
1256
1257 let workflow = needs_workflow(repo_path);
1258 let check = github::check_token(&token, &g.owner, &repo_name, workflow);
1259 let report = format_check(&check, &g.owner, &repo_name);
1260 println!("{report}");
1261
1262 if report.lines().any(|l| l.starts_with('✗')) {
1263 bail!("mirror check found hard failures — see above");
1264 }
1265 Ok(())
1266}
1267
1268#[derive(Debug, Clone, PartialEq)]
1277pub enum KeyPlan {
1278 Reuse(PathBuf),
1279 Generate(PathBuf),
1280}
1281
1282const KEY_CANDIDATES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"];
1285
1286pub fn pick_or_plan_keygen(ssh_dir: &Path, existing: &[String]) -> KeyPlan {
1292 for candidate in KEY_CANDIDATES {
1293 if existing.iter().any(|e| e == candidate) {
1294 return KeyPlan::Reuse(ssh_dir.join(candidate));
1295 }
1296 }
1297 KeyPlan::Generate(ssh_dir.join("id_ed25519"))
1298}
1299
1300fn print_manual_fallback(target: &str, pub_path: &Path) -> Result<()> {
1304 let pubkey = std::fs::read_to_string(pub_path)
1305 .with_context(|| format!("reading {}", pub_path.display()))?;
1306 println!("{}", pubkey.trim());
1307 println!(
1308 "add this line to {target}:~/.ssh/authorized_keys (e.g. via console access), \
1309 then re-run git-ark host add"
1310 );
1311 Ok(())
1312}
1313
1314pub fn host_setup_key(target: &str, port: Option<u16>) -> Result<()> {
1325 let ssh_dir = home_dir()?.join(".ssh");
1326 std::fs::create_dir_all(&ssh_dir).with_context(|| format!("creating {}", ssh_dir.display()))?;
1331
1332 let existing: Vec<String> = KEY_CANDIDATES
1333 .iter()
1334 .filter(|name| ssh_dir.join(name).exists())
1335 .map(|s| s.to_string())
1336 .collect();
1337
1338 let priv_path = match pick_or_plan_keygen(&ssh_dir, &existing) {
1339 KeyPlan::Reuse(p) => {
1340 println!("using existing key {}", p.display());
1341 p
1342 }
1343 KeyPlan::Generate(p) => {
1344 println!(
1345 "no SSH key found in {} — generating {}",
1346 ssh_dir.display(),
1347 p.display()
1348 );
1349 let status = Command::new("ssh-keygen")
1350 .args(["-t", "ed25519", "-f"])
1351 .arg(&p)
1352 .args(["-N", ""])
1353 .status()
1354 .context("spawning ssh-keygen")?;
1355 if !status.success() {
1356 bail!("ssh-keygen failed generating {}", p.display());
1357 }
1358 p
1359 }
1360 };
1361 let pub_path = PathBuf::from(format!("{}.pub", priv_path.display()));
1362
1363 let mut copy_args: Vec<String> = vec!["-i".to_string(), pub_path.display().to_string()];
1364 if let Some(port) = port {
1365 copy_args.push("-p".to_string());
1366 copy_args.push(port.to_string());
1367 }
1368 copy_args.push(target.to_string());
1369 let copy_ok = Command::new("ssh-copy-id")
1370 .args(©_args)
1371 .status()
1372 .map(|s| s.success())
1373 .unwrap_or(false);
1374 if !copy_ok {
1375 print_manual_fallback(target, &pub_path)?;
1376 bail!("ssh-copy-id to {target} did not succeed");
1377 }
1378
1379 let mut verify_args: Vec<String> = vec![
1382 "-o".to_string(),
1383 "BatchMode=yes".to_string(),
1384 "-o".to_string(),
1385 "ConnectTimeout=5".to_string(),
1386 ];
1387 if let Some(port) = port {
1388 verify_args.push("-p".to_string());
1389 verify_args.push(port.to_string());
1390 }
1391 verify_args.push("-i".to_string());
1392 verify_args.push(priv_path.display().to_string());
1393 verify_args.push(target.to_string());
1394 verify_args.push("true".to_string());
1395 let verified = Command::new("ssh")
1396 .args(&verify_args)
1397 .status()
1398 .map(|s| s.success())
1399 .unwrap_or(false);
1400
1401 if !verified {
1402 print_manual_fallback(target, &pub_path)?;
1403 bail!("key auth to {target} still doesn't work after setup-key");
1404 }
1405
1406 match port {
1407 Some(p) => println!(
1408 "✓ key auth to {target} works — now run: git-ark host add <name> {target} \
1409 --identity {} --port {p} --bucket … --recipient … --binary …",
1410 priv_path.display()
1411 ),
1412 None => println!(
1413 "✓ key auth to {target} works — now run: git-ark host add <name> {target} \
1414 --identity {} --bucket … --recipient … --binary …",
1415 priv_path.display()
1416 ),
1417 }
1418 Ok(())
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423 use super::*;
1424
1425 #[test]
1426 fn parse_target_splits_user_and_host() {
1427 let (user, host) = parse_target("ark@example.com").unwrap();
1428 assert_eq!(user, "ark");
1429 assert_eq!(host, "example.com");
1430 }
1431
1432 #[test]
1433 fn parse_target_rejects_bare_host() {
1434 assert!(parse_target("example.com").is_err());
1435 }
1436
1437 #[test]
1438 fn parse_target_rejects_empty_user_or_host() {
1439 assert!(parse_target("@example.com").is_err());
1440 assert!(parse_target("ark@").is_err());
1441 }
1442
1443 #[test]
1444 fn ssh_args_includes_port_and_identity_when_set() {
1445 let spec = SshSpec {
1446 user: "ark".to_string(),
1447 host: "example.com".to_string(),
1448 port: Some(2222),
1449 identity: Some(PathBuf::from("/home/op/.ssh/id_ed25519")),
1450 };
1451 let args = ssh_args(&spec);
1452 assert_eq!(
1453 args,
1454 vec![
1455 "-p".to_string(),
1456 "2222".to_string(),
1457 "-i".to_string(),
1458 "/home/op/.ssh/id_ed25519".to_string(),
1459 "-o".to_string(),
1460 "StrictHostKeyChecking=accept-new".to_string(),
1461 "ark@example.com".to_string(),
1462 ]
1463 );
1464 }
1465
1466 #[test]
1467 fn ssh_args_omits_port_and_identity_when_unset() {
1468 let spec = SshSpec {
1469 user: "ark".to_string(),
1470 host: "example.com".to_string(),
1471 port: None,
1472 identity: None,
1473 };
1474 let args = ssh_args(&spec);
1475 assert_eq!(
1476 args,
1477 vec![
1478 "-o".to_string(),
1479 "StrictHostKeyChecking=accept-new".to_string(),
1480 "ark@example.com".to_string(),
1481 ]
1482 );
1483 }
1484
1485 #[test]
1486 fn pipe_file_command_stages_then_atomically_moves() {
1487 let cmd = pipe_file_command("/home/ark/git-ark/bin/git-ark", 0o755);
1488 assert!(cmd.starts_with("umask 077; cat > "));
1489 assert!(cmd.contains("chmod 755"));
1490 assert!(cmd.contains("mv "));
1491 assert!(cmd.contains("/home/ark/git-ark/bin/git-ark.uploading"));
1492 assert!(cmd.ends_with("/home/ark/git-ark/bin/git-ark"));
1493 }
1494
1495 #[test]
1496 fn append_unique_script_filters_marker_before_appending() {
1497 let script = append_unique_script("~/.ssh/authorized_keys", "/home/ark/git-ark");
1498 assert!(script.starts_with("f=~/.ssh/authorized_keys;"));
1499 assert!(script.contains("grep -vF"));
1503 assert!(!script.contains("grep -qF"));
1504 assert!(script.contains("/home/ark/git-ark"));
1505 assert!(script.contains(".bak"));
1506 }
1507
1508 #[test]
1514 #[cfg(unix)]
1515 fn append_unique_script_replaces_stale_line_on_key_rotation() {
1516 let dir = tempfile::tempdir().unwrap();
1517 let f = dir.path().join("authorized_keys");
1518 std::fs::write(&f, "ssh-rsa UNRELATED someone-else@laptop\n").unwrap();
1519
1520 let run = |line: &str| {
1521 let script = append_unique_script(&f.display().to_string(), "/home/ark/git-ark");
1522 let mut child = Command::new("sh")
1523 .arg("-c")
1524 .arg(&script)
1525 .stdin(Stdio::piped())
1526 .spawn()
1527 .unwrap();
1528 child
1529 .stdin
1530 .take()
1531 .unwrap()
1532 .write_all(line.as_bytes())
1533 .unwrap();
1534 assert!(child.wait().unwrap().success());
1535 };
1536
1537 let old_line = "command=\"/home/ark/git-ark/bin/git-ark shell --config /home/ark/git-ark/config.toml\",no-pty ssh-ed25519 OLDKEY git-ark-testbox";
1538 let new_line = "command=\"/home/ark/git-ark/bin/git-ark shell --config /home/ark/git-ark/config.toml\",no-pty ssh-ed25519 NEWKEY git-ark-testbox";
1539
1540 run(old_line);
1541 let after_first = std::fs::read_to_string(&f).unwrap();
1542 assert!(after_first.contains("OLDKEY"));
1543 assert!(after_first.contains("UNRELATED"));
1544
1545 run(new_line);
1547 let after_rotation = std::fs::read_to_string(&f).unwrap();
1548 assert!(
1549 after_rotation.contains("NEWKEY"),
1550 "rotated key must be authorized"
1551 );
1552 assert!(
1553 !after_rotation.contains("OLDKEY"),
1554 "stale key must be evicted, not left authorized"
1555 );
1556 assert!(
1557 after_rotation.contains("UNRELATED"),
1558 "unrelated authorized_keys lines must be preserved"
1559 );
1560 assert_eq!(
1561 after_rotation.matches("git-ark-testbox").count(),
1562 1,
1563 "must not duplicate the forced-command line"
1564 );
1565 }
1566
1567 #[test]
1568 fn selfcheck_command_quotes_install_dir() {
1569 let cmd = selfcheck_command("/home/ark/git ark");
1570 assert!(cmd.contains("'/home/ark/git ark/bin/git-ark'"));
1571 assert!(cmd.contains("--config"));
1572 assert!(cmd.contains("'/home/ark/git ark/config.toml'"));
1573 }
1574
1575 #[test]
1576 fn selfcheck_command_has_expected_shape_without_special_chars() {
1577 let cmd = selfcheck_command("/home/ark/git-ark");
1578 assert_eq!(
1579 cmd,
1580 "/home/ark/git-ark/bin/git-ark selfcheck --config /home/ark/git-ark/config.toml"
1581 );
1582 }
1583
1584 #[test]
1585 fn parse_kv_reads_selfcheck_shaped_output() {
1586 let out = "git_ark_version=0.1.0\nrepos_root=/home/ark/git-ark/repos\nconfig_valid=true\n";
1587 let kv = parse_kv(out);
1588 assert_eq!(kv.get("git_ark_version").map(String::as_str), Some("0.1.0"));
1589 assert_eq!(kv.get("config_valid").map(String::as_str), Some("true"));
1590 }
1591
1592 #[test]
1593 fn client_dir_is_under_config() {
1594 assert_eq!(
1595 client_dir(Path::new("/home/op")),
1596 PathBuf::from("/home/op/.config/git-ark")
1597 );
1598 }
1599
1600 #[test]
1601 fn registry_path_honors_git_ark_hosts_override() {
1602 std::env::set_var("GIT_ARK_HOSTS", "/tmp/git-ark-test-hosts.toml");
1603 let p = registry_path().unwrap();
1604 std::env::remove_var("GIT_ARK_HOSTS");
1605 assert_eq!(p, PathBuf::from("/tmp/git-ark-test-hosts.toml"));
1606 }
1607
1608 #[test]
1609 fn upsert_ssh_config_block_appends_when_absent() {
1610 let existing = "Host other\n HostName elsewhere.example.com\n";
1611 let block = "Host git-ark-testbox\n HostName example.com\n Port 2222\n User ark\n IdentityFile /k\n IdentitiesOnly yes\n";
1612 let out = upsert_ssh_config_block(existing, "testbox", block);
1613 assert!(out.contains("Host other"));
1614 assert!(out.contains("Host git-ark-testbox"));
1615 assert!(out.contains("HostName example.com"));
1616 }
1617
1618 #[test]
1619 fn upsert_ssh_config_block_replaces_in_place_and_preserves_siblings() {
1620 let existing = "Host before\n HostName a\n\nHost git-ark-testbox\n HostName old.example.com\n Port 22\n\nHost after\n HostName b\n";
1621 let block = "Host git-ark-testbox\n HostName new.example.com\n Port 2222\n User ark\n IdentityFile /k\n IdentitiesOnly yes\n";
1622 let out = upsert_ssh_config_block(existing, "testbox", block);
1623
1624 assert!(out.contains("Host before"));
1625 assert!(out.contains("Host after"));
1626 assert!(out.contains("HostName new.example.com"));
1627 assert!(!out.contains("old.example.com"));
1628 assert_eq!(out.matches("Host git-ark-testbox").count(), 1);
1630 }
1631
1632 #[test]
1633 fn upsert_ssh_config_block_is_idempotent() {
1634 let block = "Host git-ark-testbox\n HostName example.com\n Port 2222\n User ark\n IdentityFile /k\n IdentitiesOnly yes\n";
1635 let once = upsert_ssh_config_block("", "testbox", block);
1636 let twice = upsert_ssh_config_block(&once, "testbox", block);
1637 assert_eq!(once, twice);
1638 assert_eq!(once.matches("Host git-ark-testbox").count(), 1);
1639 }
1640
1641 #[test]
1642 fn remove_ssh_config_block_drops_only_the_named_block() {
1643 let existing = "Host before\n HostName a\n\nHost git-ark-testbox\n HostName old.example.com\n Port 22\n\nHost after\n HostName b\n";
1644 let out = remove_ssh_config_block(existing, "testbox");
1645 assert!(out.contains("Host before"));
1646 assert!(out.contains("Host after"));
1647 assert!(!out.contains("git-ark-testbox"));
1648 assert!(!out.contains("old.example.com"));
1649 }
1650
1651 #[test]
1652 fn remove_ssh_config_block_absent_is_a_no_op() {
1653 let existing = "Host other\n HostName elsewhere.example.com\n";
1654 let out = remove_ssh_config_block(existing, "testbox");
1655 assert_eq!(out, "Host other\n HostName elsewhere.example.com\n");
1656 }
1657
1658 #[test]
1659 fn remove_ssh_config_block_of_only_block_leaves_empty_file() {
1660 let existing = "Host git-ark-testbox\n HostName example.com\n Port 2222\n";
1661 let out = remove_ssh_config_block(existing, "testbox");
1662 assert_eq!(out, "");
1663 }
1664
1665 #[test]
1669 fn remove_ssh_config_block_preserves_trailing_match_block() {
1670 let existing = "Host git-ark-box\n HostName example.com\n Port 22\n User ark\n IdentityFile /k\n IdentitiesOnly yes\n\nMatch host *.corp\n User alice\n";
1671 let out = remove_ssh_config_block(existing, "box");
1672 assert!(!out.contains("git-ark-box"));
1673 assert!(out.contains("Match host *.corp"));
1674 assert!(out.contains("User alice"));
1675 }
1676
1677 #[test]
1678 fn validate_host_name_accepts_ordinary_names() {
1679 assert!(validate_host_name("testbox").is_ok());
1680 assert!(validate_host_name("box-1.prod_east").is_ok());
1681 }
1682
1683 #[test]
1684 fn validate_host_name_rejects_path_traversal() {
1685 assert!(validate_host_name("../../etc/passwd").is_err());
1686 assert!(validate_host_name("..").is_err());
1687 assert!(validate_host_name(".").is_err());
1688 }
1689
1690 #[test]
1691 fn validate_host_name_rejects_empty_and_unsafe_chars() {
1692 assert!(validate_host_name("").is_err());
1693 assert!(validate_host_name("box/name").is_err());
1694 assert!(validate_host_name("box name").is_err());
1695 assert!(validate_host_name("box$(rm)").is_err());
1696 }
1697
1698 #[test]
1699 fn home_dir_falls_back_to_userprofile_when_home_unset() {
1700 let saved_home = std::env::var("HOME").ok();
1701 std::env::remove_var("HOME");
1702 std::env::set_var("USERPROFILE", "/Users/win");
1703
1704 let result = home_dir();
1705
1706 std::env::remove_var("USERPROFILE");
1707 if let Some(home) = saved_home {
1708 std::env::set_var("HOME", home);
1709 }
1710
1711 assert_eq!(result.unwrap(), PathBuf::from("/Users/win"));
1712 }
1713
1714 fn test_host(name: &str, mirror: bool) -> Host {
1715 Host {
1716 name: name.to_string(),
1717 ssh_target: format!("ark@{name}.example.com"),
1718 port: 22,
1719 identity: None,
1720 triple: "x86_64-unknown-linux-musl".to_string(),
1721 install_dir: "/home/ark/git-ark".to_string(),
1722 recipient: "age1abc".to_string(),
1723 bucket: "b".to_string(),
1724 region: "us-east-1".to_string(),
1725 prefix: "git-ark".to_string(),
1726 endpoint: None,
1727 mirror,
1728 }
1729 }
1730
1731 #[test]
1732 fn mirror_transition_promotes_target_when_no_current_mirror() {
1733 let mut registry = Registry::default();
1734 registry.upsert(test_host("nas", false));
1735 registry.upsert(test_host("ec2", false));
1736
1737 let (demote, promote) = mirror_transition(®istry, "ec2");
1738 assert_eq!(demote, None);
1739 assert_eq!(promote, "ec2");
1740 }
1741
1742 #[test]
1743 fn mirror_transition_demotes_a_different_current_mirror() {
1744 let mut registry = Registry::default();
1745 registry.upsert(test_host("nas", true));
1746 registry.upsert(test_host("ec2", false));
1747
1748 let (demote, promote) = mirror_transition(®istry, "ec2");
1749 assert_eq!(demote, Some("nas".to_string()));
1750 assert_eq!(promote, "ec2");
1751 }
1752
1753 #[test]
1754 fn mirror_transition_is_a_no_op_demote_when_target_already_mirror() {
1755 let mut registry = Registry::default();
1756 registry.upsert(test_host("nas", false));
1757 registry.upsert(test_host("ec2", true));
1758
1759 let (demote, promote) = mirror_transition(®istry, "ec2");
1760 assert_eq!(demote, None);
1761 assert_eq!(promote, "ec2");
1762 }
1763
1764 #[test]
1769 fn format_status_row_reachable_healthy() {
1770 let s = HostStatus::Reachable {
1771 version: "0.1.0".to_string(),
1772 disk_free: 60_000_000_000_000,
1773 disk_low: false,
1774 config_valid: true,
1775 };
1776 let row = format_status_row("nas", false, &s);
1777 assert!(row.contains("nas"));
1778 assert!(row.contains('✓'));
1779 assert!(row.contains("0.1.0"));
1780 assert!(
1781 row.contains("54.6 TB"),
1782 "expected a human free size, got: {row}"
1783 );
1784 assert!(!row.contains('★'));
1785 }
1786
1787 #[test]
1788 fn format_status_row_reachable_marks_mirror() {
1789 let s = HostStatus::Reachable {
1790 version: "0.1.0".to_string(),
1791 disk_free: 60_000_000_000_000,
1792 disk_low: false,
1793 config_valid: true,
1794 };
1795 let row = format_status_row("nas", true, &s);
1796 assert!(row.contains('★'));
1797 assert!(row.to_lowercase().contains("mirror"));
1798 }
1799
1800 #[test]
1801 fn format_status_row_reachable_disk_low_warns() {
1802 let s = HostStatus::Reachable {
1803 version: "0.1.0".to_string(),
1804 disk_free: 1_000_000_000,
1805 disk_low: true,
1806 config_valid: true,
1807 };
1808 let row = format_status_row("nas", false, &s);
1809 assert!(row.contains('⚠'));
1810 }
1811
1812 #[test]
1813 fn format_status_row_broken_shows_reason() {
1814 let s = HostStatus::Broken("selfcheck: config invalid".to_string());
1815 let row = format_status_row("nas", false, &s);
1816 assert!(row.starts_with('✗'));
1817 assert!(row.to_lowercase().contains("broken"));
1818 assert!(row.contains("selfcheck: config invalid"));
1819 }
1820
1821 #[test]
1822 fn format_status_row_unreachable() {
1823 let s = HostStatus::Unreachable;
1824 let row = format_status_row("nas", false, &s);
1825 assert!(row.starts_with('✗'));
1826 assert!(row.to_lowercase().contains("unreachable"));
1827 }
1828
1829 #[test]
1830 fn push_urls_formats_one_git_ark_alias_url_per_host() {
1831 let names = vec!["nas".to_string(), "ec2".to_string()];
1832 assert_eq!(
1833 push_urls(&names, "myrepo"),
1834 vec![
1835 "git-ark-nas:myrepo".to_string(),
1836 "git-ark-ec2:myrepo".to_string(),
1837 ]
1838 );
1839 }
1840
1841 #[test]
1846 fn needs_workflow_true_when_workflows_dir_has_entries() {
1847 let dir = tempfile::tempdir().unwrap();
1848 let workflows = dir.path().join(".github").join("workflows");
1849 std::fs::create_dir_all(&workflows).unwrap();
1850 std::fs::write(workflows.join("ci.yml"), "name: ci\n").unwrap();
1851 assert!(needs_workflow(dir.path()));
1852 }
1853
1854 #[test]
1855 fn needs_workflow_false_when_workflows_dir_empty() {
1856 let dir = tempfile::tempdir().unwrap();
1857 std::fs::create_dir_all(dir.path().join(".github").join("workflows")).unwrap();
1858 assert!(!needs_workflow(dir.path()));
1859 }
1860
1861 #[test]
1862 fn needs_workflow_false_when_no_github_dir() {
1863 let dir = tempfile::tempdir().unwrap();
1864 assert!(!needs_workflow(dir.path()));
1865 }
1866
1867 fn token_check(
1868 valid: bool,
1869 repo: github::RepoAccess,
1870 scopes: Option<Vec<&str>>,
1871 needs_workflow: bool,
1872 ) -> github::TokenCheck {
1873 github::TokenCheck {
1874 valid,
1875 login: if valid { Some("me".to_string()) } else { None },
1876 scopes: scopes.map(|s| s.into_iter().map(str::to_string).collect()),
1877 repo,
1878 needs_workflow,
1879 }
1880 }
1881
1882 #[test]
1883 fn format_check_reports_failed_auth() {
1884 let c = token_check(false, github::RepoAccess::Absent, None, false);
1885 let out = format_check(&c, "acme", "app");
1886 assert!(out.contains("✗ token does not authenticate"));
1887 }
1888
1889 #[test]
1890 fn format_check_reports_successful_auth_with_login() {
1891 let c = token_check(
1892 true,
1893 github::RepoAccess::Exists {
1894 push: true,
1895 admin: true,
1896 },
1897 None,
1898 false,
1899 );
1900 let out = format_check(&c, "acme", "app");
1901 assert!(out.contains("✓ authenticates (login: me)"));
1902 }
1903
1904 #[test]
1905 fn format_check_repo_admin() {
1906 let c = token_check(
1907 true,
1908 github::RepoAccess::Exists {
1909 push: true,
1910 admin: true,
1911 },
1912 None,
1913 false,
1914 );
1915 let out = format_check(&c, "acme", "app");
1916 assert!(out.contains("✓ can administer acme/app"));
1917 }
1918
1919 #[test]
1920 fn format_check_repo_push_without_admin() {
1921 let c = token_check(
1922 true,
1923 github::RepoAccess::Exists {
1924 push: true,
1925 admin: false,
1926 },
1927 None,
1928 false,
1929 );
1930 let out = format_check(&c, "acme", "app");
1931 assert!(out.contains("✓ can push acme/app"));
1932 assert!(out.contains("no admin"));
1933 }
1934
1935 #[test]
1936 fn format_check_repo_no_push() {
1937 let c = token_check(
1938 true,
1939 github::RepoAccess::Exists {
1940 push: false,
1941 admin: false,
1942 },
1943 None,
1944 false,
1945 );
1946 let out = format_check(&c, "acme", "app");
1947 assert!(out.contains("✗ no push access to acme/app"));
1948 }
1949
1950 #[test]
1951 fn format_check_repo_absent() {
1952 let c = token_check(true, github::RepoAccess::Absent, None, false);
1953 let out = format_check(&c, "acme", "app");
1954 assert!(out.contains("⚠ acme/app"));
1957 assert!(out.contains("404") && out.contains("org access"));
1958 }
1959
1960 #[test]
1961 fn format_check_repo_error() {
1962 let c = token_check(
1963 true,
1964 github::RepoAccess::Error("HTTP 403".to_string()),
1965 None,
1966 false,
1967 );
1968 let out = format_check(&c, "acme", "app");
1969 assert!(out.contains("✗ acme/app: HTTP 403"));
1970 }
1971
1972 #[test]
1973 fn format_check_omits_workflow_line_when_not_needed() {
1974 let c = token_check(
1975 true,
1976 github::RepoAccess::Exists {
1977 push: true,
1978 admin: true,
1979 },
1980 Some(vec!["repo"]),
1981 false,
1982 );
1983 let out = format_check(&c, "acme", "app");
1984 assert!(!out.contains("workflow"));
1985 }
1986
1987 #[test]
1988 fn format_check_workflow_scope_present() {
1989 let c = token_check(
1990 true,
1991 github::RepoAccess::Exists {
1992 push: true,
1993 admin: true,
1994 },
1995 Some(vec!["repo", "workflow"]),
1996 true,
1997 );
1998 let out = format_check(&c, "acme", "app");
1999 assert!(out.contains("✓ workflow scope"));
2000 }
2001
2002 #[test]
2003 fn format_check_workflow_scope_missing() {
2004 let c = token_check(
2005 true,
2006 github::RepoAccess::Exists {
2007 push: true,
2008 admin: true,
2009 },
2010 Some(vec!["repo"]),
2011 true,
2012 );
2013 let out = format_check(&c, "acme", "app");
2014 assert!(out.contains("✗ missing 'workflow' scope"));
2015 }
2016
2017 #[test]
2018 fn format_check_workflow_scope_uninspectable() {
2019 let c = token_check(
2020 true,
2021 github::RepoAccess::Exists {
2022 push: true,
2023 admin: true,
2024 },
2025 None,
2026 true,
2027 );
2028 let out = format_check(&c, "acme", "app");
2029 assert!(out.contains("⚠ repo ships workflows"));
2030 }
2031
2032 #[test]
2037 fn resolve_upgrade_targets_all_returns_every_host() {
2038 let mut registry = Registry::default();
2039 registry.upsert(test_host("nas", false));
2040 registry.upsert(test_host("ec2", true));
2041
2042 let targets = resolve_upgrade_targets(®istry, &[], true).unwrap();
2043 let mut names: Vec<&str> = targets.iter().map(|h| h.name.as_str()).collect();
2044 names.sort();
2045 assert_eq!(names, vec!["ec2", "nas"]);
2046 }
2047
2048 #[test]
2049 fn resolve_upgrade_targets_all_bails_on_empty_registry() {
2050 let registry = Registry::default();
2051 assert!(resolve_upgrade_targets(®istry, &[], true).is_err());
2052 }
2053
2054 #[test]
2055 fn resolve_upgrade_targets_named_returns_those_hosts() {
2056 let mut registry = Registry::default();
2057 registry.upsert(test_host("nas", false));
2058 registry.upsert(test_host("ec2", true));
2059
2060 let names = vec!["ec2".to_string()];
2061 let targets = resolve_upgrade_targets(®istry, &names, false).unwrap();
2062 assert_eq!(targets.len(), 1);
2063 assert_eq!(targets[0].name, "ec2");
2064 }
2065
2066 #[test]
2067 fn resolve_upgrade_targets_bails_on_unknown_name() {
2068 let mut registry = Registry::default();
2069 registry.upsert(test_host("nas", false));
2070
2071 let names = vec!["nope".to_string()];
2072 let err = resolve_upgrade_targets(®istry, &names, false).unwrap_err();
2073 assert!(err.to_string().contains("nope"));
2074 }
2075
2076 #[test]
2077 fn resolve_upgrade_targets_bails_when_neither_all_nor_names() {
2078 let mut registry = Registry::default();
2079 registry.upsert(test_host("nas", false));
2080
2081 let err = resolve_upgrade_targets(®istry, &[], false).unwrap_err();
2082 assert!(err.to_string().contains("--all"));
2083 }
2084
2085 #[test]
2086 fn resolve_upgrade_targets_all_wins_over_names() {
2087 let mut registry = Registry::default();
2088 registry.upsert(test_host("nas", false));
2089 registry.upsert(test_host("ec2", true));
2090
2091 let names = vec!["nope".to_string()];
2094 let targets = resolve_upgrade_targets(®istry, &names, true).unwrap();
2095 assert_eq!(targets.len(), 2);
2096 }
2097
2098 #[test]
2103 fn pick_or_plan_keygen_reuses_existing_ed25519() {
2104 let dir = PathBuf::from("/home/op/.ssh");
2105 let existing = vec!["id_ed25519".to_string()];
2106 assert_eq!(
2107 pick_or_plan_keygen(&dir, &existing),
2108 KeyPlan::Reuse(dir.join("id_ed25519"))
2109 );
2110 }
2111
2112 #[test]
2113 fn pick_or_plan_keygen_reuses_rsa_when_thats_all_there_is() {
2114 let dir = PathBuf::from("/home/op/.ssh");
2115 let existing = vec!["id_rsa".to_string()];
2116 assert_eq!(
2117 pick_or_plan_keygen(&dir, &existing),
2118 KeyPlan::Reuse(dir.join("id_rsa"))
2119 );
2120 }
2121
2122 #[test]
2123 fn pick_or_plan_keygen_prefers_ecdsa_over_rsa() {
2124 let dir = PathBuf::from("/home/op/.ssh");
2125 let existing = vec!["id_rsa".to_string(), "id_ecdsa".to_string()];
2126 assert_eq!(
2127 pick_or_plan_keygen(&dir, &existing),
2128 KeyPlan::Reuse(dir.join("id_ecdsa"))
2129 );
2130 }
2131
2132 #[test]
2133 fn pick_or_plan_keygen_generates_ed25519_when_none_exist() {
2134 let dir = PathBuf::from("/home/op/.ssh");
2135 assert_eq!(
2136 pick_or_plan_keygen(&dir, &[]),
2137 KeyPlan::Generate(dir.join("id_ed25519"))
2138 );
2139 }
2140}