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::{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 find_alias_for_target(
334 ssh_config: &str,
335 host: &str,
336 user: Option<&str>,
337 name: &str,
338) -> Option<String> {
339 let lines: Vec<&str> = ssh_config.lines().collect();
340 let mut candidates: Vec<String> = Vec::new();
341 let mut i = 0;
342 while i < lines.len() {
343 let mut head = lines[i].split_whitespace();
344 if !matches!(head.next(), Some(k) if k.eq_ignore_ascii_case("Host")) {
345 i += 1;
346 continue;
347 }
348 let aliases: Vec<String> = head.map(|s| s.to_string()).collect();
349 i += 1;
351 let mut hostname: Option<&str> = None;
352 let mut block_user: Option<&str> = None;
353 while i < lines.len() {
354 let mut bt = lines[i].split_whitespace();
355 match bt.next() {
356 Some(k) if k.eq_ignore_ascii_case("Host") || k.eq_ignore_ascii_case("Match") => {
357 break
358 }
359 Some(k) if k.eq_ignore_ascii_case("HostName") => hostname = bt.next(),
360 Some(k) if k.eq_ignore_ascii_case("User") => block_user = bt.next(),
361 _ => {}
362 }
363 i += 1;
364 }
365 let host_matches = hostname == Some(host);
366 let user_matches = match (user, block_user) {
367 (None, _) | (Some(_), None) => true,
368 (Some(u), Some(bu)) => u == bu,
369 };
370 if host_matches && user_matches {
371 candidates.extend(aliases);
372 }
373 }
374 let exact = format!("git-ark-{name}");
375 candidates
376 .iter()
377 .find(|a| **a == exact)
378 .or_else(|| candidates.iter().find(|a| a.starts_with("git-ark")))
379 .or_else(|| candidates.first())
380 .cloned()
381}
382
383fn parse_kv(text: &str) -> std::collections::HashMap<String, String> {
385 text.lines()
386 .filter_map(|l| l.split_once('='))
387 .map(|(k, v)| (k.to_string(), v.to_string()))
388 .collect()
389}
390
391pub struct HostAddArgs {
396 pub name: String,
397 pub target: String,
398 pub port: Option<u16>,
399 pub identity: Option<PathBuf>,
400 pub bucket: String,
401 pub region: String,
402 pub prefix: String,
403 pub endpoint: Option<String>,
404 pub recipient: String,
405 pub binary: Option<PathBuf>,
408 pub mirror: bool,
412}
413
414fn validate_host_name(name: &str) -> Result<()> {
420 let ok = !name.is_empty()
421 && name != "."
422 && name != ".."
423 && name
424 .chars()
425 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
426 if !ok {
427 bail!("host name {name:?} must match [A-Za-z0-9._-]+ (and not be `.` or `..`)");
428 }
429 Ok(())
430}
431
432pub fn host_add(args: &HostAddArgs) -> Result<()> {
437 validate_host_name(&args.name)?;
438
439 let key_id = std::env::var("GIT_ARK_HOST_S3_KEY_ID")
443 .ok()
444 .filter(|s| !s.is_empty());
445 let secret = std::env::var("GIT_ARK_HOST_S3_SECRET")
446 .ok()
447 .filter(|s| !s.is_empty());
448 let (key_id, secret) = match (key_id, secret) {
449 (Some(k), Some(s)) => (k, s),
450 _ => bail!(
451 "GIT_ARK_HOST_S3_KEY_ID and GIT_ARK_HOST_S3_SECRET must both be set \
452 (the host's write-only S3 credential); nothing was written to the host"
453 ),
454 };
455
456 let (user, host) = parse_target(&args.target)?;
457 let spec = SshSpec {
458 user: user.clone(),
459 host: host.clone(),
460 port: args.port,
461 identity: args.identity.clone(),
462 };
463
464 let probe_out = ssh_run(&spec, PROBE_SCRIPT)
467 .inspect_err(|e| {
468 let diagnosis = classify_ssh_error(&e.to_string());
472 if diagnosis != SshDiagnosis::Other {
473 eprintln!(
474 "{}",
475 diagnosis_message(diagnosis, &spec.display(), args.port.unwrap_or(22))
476 );
477 }
478 })
479 .with_context(|| format!("probing {}", spec.display()))?;
480 let facts = parse_probe(&probe_out);
481 let plan = match assess(&facts) {
482 Ok(plan) => plan,
483 Err(reasons) => {
484 eprintln!("✗ {} is not ready for git-ark:", spec.display());
485 for r in &reasons {
486 eprintln!(" - {r}");
487 }
488 bail!(
489 "preflight probe failed ({} reason(s)); nothing was written to the host",
490 reasons.len()
491 );
492 }
493 };
494
495 let binary_bytes = match &args.binary {
501 Some(path) => {
502 let bytes = std::fs::read(path)
503 .with_context(|| format!("reading --binary {}", path.display()))?;
504 if !path.to_string_lossy().contains(&plan.triple) {
505 eprintln!(
506 "⚠ --binary {} doesn't look like it's built for {} (the probed host's triple) — continuing anyway",
507 path.display(),
508 plan.triple
509 );
510 }
511 bytes
512 }
513 None => {
514 println!(
515 "• fetching git-ark v{} for {} from the release…",
516 release::version(),
517 plan.triple
518 );
519 release::fetch_host_binary(&plan.triple)?
520 }
521 };
522
523 let keydir = client_keydir()?;
527 std::fs::create_dir_all(&keydir).with_context(|| format!("creating {}", keydir.display()))?;
528 let key_path = keydir.join(&args.name);
529 let pub_path = keydir.join(format!("{}.pub", args.name));
530 if !key_path.exists() {
531 let status = Command::new("ssh-keygen")
532 .args(["-t", "ed25519", "-N", "", "-q"])
533 .arg("-f")
534 .arg(&key_path)
535 .arg("-C")
536 .arg(format!("git-ark-{}", args.name))
537 .status()
538 .context("spawning ssh-keygen")?;
539 if !status.success() {
540 bail!("ssh-keygen failed generating {}", key_path.display());
541 }
542 }
543 let pubkey = std::fs::read_to_string(&pub_path)
544 .with_context(|| format!("reading {}", pub_path.display()))?
545 .trim()
546 .to_string();
547
548 ssh_mkdir_p(
550 &spec,
551 &[
552 format!("{}/bin", plan.install_dir),
553 format!("{}/repos", plan.install_dir),
554 ],
555 )?;
556
557 ssh_pipe_file(
559 &spec,
560 &format!("{}/bin/git-ark", plan.install_dir),
561 &binary_bytes,
562 0o755,
563 )?;
564
565 let s3 = S3Config {
573 bucket: args.bucket.clone(),
574 region: args.region.clone(),
575 prefix: per_host_prefix(&args.prefix, &args.name),
576 endpoint: args.endpoint.clone(),
577 };
578 let config_text = render_config(&plan.install_dir, &args.recipient, &s3, false);
579 ssh_pipe_file(
580 &spec,
581 &format!("{}/config.toml", plan.install_dir),
582 config_text.as_bytes(),
583 0o644,
584 )?;
585 let secrets_text = render_secrets(&key_id, &secret, None);
586 ssh_pipe_file(
587 &spec,
588 &format!("{}/secrets.toml", plan.install_dir),
589 secrets_text.as_bytes(),
590 0o600,
591 )?;
592
593 let line = forced_command_line(&plan.install_dir, &pubkey);
598 ssh_append_unique(&spec, "~/.ssh/authorized_keys", &line, &plan.install_dir)?;
599
600 let selfcheck_out = ssh_run(&spec, &selfcheck_command(&plan.install_dir))?;
603 let selfcheck = parse_kv(&selfcheck_out);
604 let config_valid = selfcheck.get("config_valid").map(String::as_str) == Some("true");
605 let version = selfcheck.get("git_ark_version");
606 if !config_valid || version.is_none() {
607 bail!(
608 "selfcheck did not report a healthy host (config_valid={:?}, git_ark_version={:?}); \
609 the host has been wired, but `host add {}` should be re-run once fixed",
610 selfcheck.get("config_valid"),
611 version,
612 args.name
613 );
614 }
615
616 let port = args.port.unwrap_or(22);
618 let block = ssh_config_block(
619 &args.name,
620 &host,
621 port,
622 &user,
623 &key_path.display().to_string(),
624 );
625 let ssh_config_path = client_ssh_config_path()?;
626 if let Some(parent) = ssh_config_path.parent() {
627 std::fs::create_dir_all(parent)
628 .with_context(|| format!("creating {}", parent.display()))?;
629 }
630 let existing = std::fs::read_to_string(&ssh_config_path).unwrap_or_default();
631 let updated = upsert_ssh_config_block(&existing, &args.name, &block);
632 std::fs::write(&ssh_config_path, updated)
633 .with_context(|| format!("writing {}", ssh_config_path.display()))?;
634
635 let registry_path = registry_path()?;
637 let mut registry = Registry::load(®istry_path)?;
638 registry.upsert(Host {
639 name: args.name.clone(),
640 ssh_target: args.target.clone(),
641 port,
642 identity: args.identity.clone(),
648 triple: plan.triple.clone(),
649 install_dir: plan.install_dir.clone(),
650 recipient: args.recipient.clone(),
651 bucket: args.bucket.clone(),
652 region: args.region.clone(),
653 prefix: args.prefix.clone(),
654 endpoint: args.endpoint.clone(),
655 mirror: false,
656 push_alias: None,
659 });
660 registry.save(®istry_path)?;
661
662 println!(
663 "✓ {} ready — git push git-ark-{}:<repo>",
664 args.name, args.name
665 );
666 if let (Some(pct), Some(bytes)) = (
667 selfcheck.get("disk_free_percent"),
668 selfcheck.get("disk_free_bytes"),
669 ) {
670 println!(" disk: {pct}% free ({bytes} bytes)");
671 }
672
673 if args.mirror {
679 let token = match std::env::var("GIT_ARK_GITHUB_TOKEN")
680 .ok()
681 .filter(|s| !s.is_empty())
682 {
683 Some(t) => t,
684 None => bail!(
685 "GIT_ARK_GITHUB_TOKEN must be set to add {} as the GitHub mirror (--mirror); \
686 the host was still added, just not designated the mirror \
687 (run `git-ark mirror set {}` once the token is set)",
688 args.name,
689 args.name
690 ),
691 };
692 designate_mirror(&mut registry, &args.name, &token)?;
693 println!("✓ {} designated the GitHub mirror", args.name);
694 }
695
696 Ok(())
697}
698
699pub struct HostAdoptArgs {
705 pub name: String,
706 pub target: String,
707 pub port: Option<u16>,
708 pub identity: Option<PathBuf>,
709 pub config: Option<String>,
712 pub push_alias: Option<String>,
715}
716
717pub fn host_adopt(args: &HostAdoptArgs) -> Result<()> {
726 validate_host_name(&args.name)?;
727 let (user, host) = parse_target(&args.target)?;
728 let spec = SshSpec {
729 user,
730 host,
731 port: args.port,
732 identity: args.identity.clone(),
733 };
734
735 let config_path = match &args.config {
738 Some(c) => c.clone(),
739 None => {
740 let ak = ssh_run(&spec, "cat ~/.ssh/authorized_keys 2>/dev/null || true")?;
741 parse_forced_command_config(&ak).ok_or_else(|| {
742 anyhow!(
743 "no git-ark forced-command line found in {}'s authorized_keys — \
744 pass --config <path-to-config.toml> to point me at it",
745 args.target
746 )
747 })?
748 }
749 };
750 let install_dir = std::path::Path::new(&config_path)
751 .parent()
752 .and_then(|p| p.to_str())
753 .ok_or_else(|| anyhow!("can't derive the install dir from {config_path}"))?
754 .to_string();
755
756 let triple = triple_from_uname(&ssh_run(&spec, "uname -s; uname -m")?)?;
758
759 let cfg_text = ssh_run(&spec, &format!("cat {}", shell_words::quote(&config_path)))?;
761 let cfg: toml::Value =
762 toml::from_str(&cfg_text).map_err(|e| anyhow!("parsing the host's config.toml: {e}"))?;
763 let str_at = |v: Option<&toml::Value>| v.and_then(|x| x.as_str()).unwrap_or("").to_string();
764 let s3 = cfg.get("s3");
765 let recipient = str_at(cfg.get("age_recipient"));
766 let bucket = str_at(s3.and_then(|v| v.get("bucket")));
767 let region = str_at(s3.and_then(|v| v.get("region")));
768 let prefix = str_at(s3.and_then(|v| v.get("prefix")));
769 let endpoint = s3
770 .and_then(|v| v.get("endpoint"))
771 .and_then(|v| v.as_str())
772 .map(str::to_string);
773 let mirror = cfg.get("mirror").and_then(|v| v.as_bool()).unwrap_or(false);
774
775 let bin_q = shell_words::quote(&format!("{install_dir}/bin/git-ark")).into_owned();
777 let cfg_q = shell_words::quote(&config_path).into_owned();
778 let selfcheck = ssh_run(&spec, &format!("{bin_q} selfcheck --config {cfg_q}"))
779 .context("running the host's git-ark selfcheck")?;
780 if !selfcheck.contains("config_valid=true") {
781 bail!(
782 "selfcheck didn't confirm a working git-ark at {install_dir} on {}:\n{}",
783 args.target,
784 selfcheck.trim()
785 );
786 }
787 let version = parse_kv(&selfcheck)
788 .get("git_ark_version")
789 .cloned()
790 .unwrap_or_else(|| "?".into());
791
792 let push_alias = args.push_alias.clone().or_else(|| {
797 client_ssh_config_path()
798 .ok()
799 .and_then(|p| std::fs::read_to_string(p).ok())
800 .and_then(|cfg| find_alias_for_target(&cfg, &spec.host, Some(&spec.user), &args.name))
801 });
802
803 let registry_path = registry_path()?;
805 let mut registry = Registry::load(®istry_path)?;
806 registry.upsert(Host {
807 name: args.name.clone(),
808 ssh_target: args.target.clone(),
809 port: args.port.unwrap_or(22),
810 identity: args.identity.clone(),
811 triple,
812 install_dir,
813 recipient,
814 bucket,
815 region,
816 prefix,
817 endpoint,
818 mirror,
819 push_alias: push_alias.clone(),
820 });
821 registry.save(®istry_path)?;
822
823 println!(
824 "✓ adopted '{}' (git-ark {version}) — registered from its deployed config; \
825 nothing on the host or in your ssh config was touched",
826 args.name
827 );
828 println!(" it now appears in `git-ark status` and can be `git-ark upgrade`d.");
829 if mirror {
830 println!(" (it holds the GitHub mirror.)");
831 }
832 match &push_alias {
833 Some(alias) => println!(" push alias: {alias} — `git-ark route` and pushes will use it."),
834 None => println!(
835 " no push alias found in ~/.ssh/config — status/upgrade work; set one with \
836 `git-ark host adopt {} {} --push-alias <alias>` to enable push/route.",
837 args.name, args.target
838 ),
839 }
840 Ok(())
841}
842
843pub fn parse_forced_command_config(authorized_keys: &str) -> Option<String> {
846 for line in authorized_keys.lines() {
847 if !line.contains("git-ark") {
848 continue;
849 }
850 if let Some(idx) = line.find("--config ") {
851 let rest = &line[idx + "--config ".len()..];
852 let end = rest
853 .find(|c: char| c == '"' || c.is_whitespace())
854 .unwrap_or(rest.len());
855 let path = &rest[..end];
856 if !path.is_empty() {
857 return Some(path.to_string());
858 }
859 }
860 }
861 None
862}
863
864pub fn triple_from_uname(uname: &str) -> Result<String> {
866 let mut lines = uname.lines().map(str::trim);
867 let os = lines.next().unwrap_or("");
868 let arch = lines.next().unwrap_or("");
869 match (os, arch) {
870 ("Linux", "x86_64") => Ok("x86_64-unknown-linux-musl".to_string()),
871 ("Linux", "aarch64" | "arm64") => Ok("aarch64-unknown-linux-musl".to_string()),
872 _ => bail!("unsupported host {os}/{arch} — git-ark hosts are Linux x86_64 or aarch64"),
873 }
874}
875
876pub fn host_list() -> Result<()> {
885 let registry = Registry::load(®istry_path()?)?;
886 let mut hosts: Vec<&Host> = registry.list().iter().collect();
887 hosts.sort_by(|a, b| a.name.cmp(&b.name));
888 for h in hosts {
889 println!("{} {} {}", h.name, h.ssh_target, h.triple);
890 }
891 Ok(())
892}
893
894#[derive(Debug, serde::Serialize)]
899struct HostRow<'a> {
900 name: &'a str,
901 alias: String,
902 ssh_target: &'a str,
903 port: u16,
904 triple: &'a str,
905 bucket: &'a str,
906 region: &'a str,
907 prefix: &'a str,
908 endpoint: Option<&'a str>,
909 mirror: bool,
910}
911
912fn host_rows(registry: &Registry) -> Vec<HostRow<'_>> {
915 let mut hosts: Vec<&Host> = registry.list().iter().collect();
916 hosts.sort_by(|a, b| a.name.cmp(&b.name));
917 hosts
918 .into_iter()
919 .map(|h| HostRow {
920 name: &h.name,
921 alias: h.push_alias(),
922 ssh_target: &h.ssh_target,
923 port: h.port,
924 triple: &h.triple,
925 bucket: &h.bucket,
926 region: &h.region,
927 prefix: &h.prefix,
928 endpoint: h.endpoint.as_deref(),
929 mirror: h.mirror,
930 })
931 .collect()
932}
933
934pub fn host_list_json() -> Result<()> {
937 let registry = Registry::load(®istry_path()?)?;
938 let rows = host_rows(®istry);
939 println!("{}", serde_json::to_string_pretty(&rows)?);
940 Ok(())
941}
942
943pub fn host_remove(name: &str) -> Result<()> {
947 let registry_path = registry_path()?;
948 let mut registry = Registry::load(®istry_path)?;
949 if !registry.remove(name) {
950 println!("no such host: {name}");
951 return Ok(());
952 }
953 registry.save(®istry_path)?;
954
955 let ssh_config_path = client_ssh_config_path()?;
959 if let Ok(existing) = std::fs::read_to_string(&ssh_config_path) {
960 let updated = remove_ssh_config_block(&existing, name);
961 std::fs::write(&ssh_config_path, updated)
962 .with_context(|| format!("writing {}", ssh_config_path.display()))?;
963 }
964
965 println!("removed {name}");
966 Ok(())
967}
968
969fn base_prefix(ip: std::net::Ipv4Addr) -> String {
975 let o = ip.octets();
976 format!("{}.{}.{}", o[0], o[1], o[2])
977}
978
979pub fn host_discover(port: u16, timeout_ms: u64, subnet: Option<std::net::Ipv4Addr>) -> Result<()> {
984 let base = match subnet {
985 Some(b) => b,
986 None => crate::subnet::local_ipv4()
987 .context("could not determine the local IPv4 address; pass --subnet a.b.c.0")?,
988 };
989 let ips = crate::subnet::hosts_in_slash24(base);
990 let found =
991 crate::scan::scan_port(&ips, port, std::time::Duration::from_millis(timeout_ms), 64);
992
993 let reg = Registry::load(®istry_path()?).unwrap_or_default();
995 if found.is_empty() {
996 println!(
997 "no hosts responding on port {port} in {}.0/24",
998 base_prefix(base)
999 );
1000 return Ok(());
1001 }
1002 for ip in found {
1003 let s = ip.to_string();
1004 match reg.list().iter().find(|h| h.ssh_target.contains(&s)) {
1005 Some(h) => println!("{s}:{port} (known: {})", h.name),
1006 None => println!("{s}:{port}"),
1007 }
1008 }
1009 Ok(())
1010}
1011
1012#[derive(Debug, Clone, PartialEq)]
1022pub enum HostStatus {
1023 Reachable {
1024 version: String,
1025 disk_free: u64,
1026 disk_low: bool,
1027 config_valid: bool,
1028 },
1029 Broken(String),
1030 Unreachable,
1031}
1032
1033pub fn probe_status(host: &Host) -> HostStatus {
1038 let (user, hostname) = match parse_target(&host.ssh_target) {
1039 Ok(v) => v,
1040 Err(e) => return HostStatus::Broken(e.to_string()),
1041 };
1042 let spec = SshSpec {
1043 user,
1044 host: hostname,
1045 port: Some(host.port),
1046 identity: host.identity.clone(),
1047 };
1048
1049 match ssh_run(&spec, &selfcheck_command(&host.install_dir)) {
1050 Ok(out) => {
1051 let kv = parse_kv(&out);
1052 let version = kv.get("git_ark_version").cloned().unwrap_or_default();
1053 let disk_free = kv
1054 .get("disk_free_bytes")
1055 .and_then(|s| s.parse::<u64>().ok())
1056 .unwrap_or(0);
1057 let disk_low = kv.get("disk_low").map(String::as_str) == Some("true");
1058 let config_valid = kv.get("config_valid").map(String::as_str) == Some("true");
1059 HostStatus::Reachable {
1060 version,
1061 disk_free,
1062 disk_low,
1063 config_valid,
1064 }
1065 }
1066 Err(_) => match ssh_run(&spec, "true") {
1069 Ok(_) => HostStatus::Broken("selfcheck failed".to_string()),
1070 Err(_) => HostStatus::Unreachable,
1071 },
1072 }
1073}
1074
1075pub fn format_status_row(name: &str, mirror: bool, s: &HostStatus) -> String {
1079 let mirror_marker = if mirror { " ★ mirror" } else { "" };
1080 match s {
1081 HostStatus::Reachable {
1082 version,
1083 disk_free,
1084 disk_low,
1085 config_valid,
1086 } => {
1087 let mark = if *disk_low { "⚠" } else { "✓" };
1088 let mut line = format!(
1089 "{mark} {name} v{version} {} free",
1090 crate::backup::human_bytes(*disk_free)
1091 );
1092 if *disk_low {
1093 line.push_str(" disk low");
1094 }
1095 if !config_valid {
1096 line.push_str(" config invalid");
1097 }
1098 line.push_str(mirror_marker);
1099 line
1100 }
1101 HostStatus::Broken(reason) => format!("✗ {name} broken: {reason}{mirror_marker}"),
1102 HostStatus::Unreachable => format!("✗ {name} unreachable{mirror_marker}"),
1103 }
1104}
1105
1106pub fn status() -> Result<()> {
1111 let registry = Registry::load(®istry_path()?)?;
1112 let mut hosts: Vec<&Host> = registry.list().iter().collect();
1113 if hosts.is_empty() {
1114 println!("no hosts — run `git-ark host add`");
1115 return Ok(());
1116 }
1117 hosts.sort_by(|a, b| a.name.cmp(&b.name));
1118 for h in hosts {
1119 let s = probe_status(h);
1120 println!("{}", format_status_row(&h.name, h.mirror, &s));
1121 }
1122 Ok(())
1123}
1124
1125fn resolve_upgrade_targets(registry: &Registry, names: &[String], all: bool) -> Result<Vec<Host>> {
1136 if all {
1137 if registry.list().is_empty() {
1138 bail!("no hosts registered — nothing to upgrade");
1139 }
1140 return Ok(registry.list().to_vec());
1141 }
1142 if names.is_empty() {
1143 bail!("name a host or pass --all");
1144 }
1145 names
1146 .iter()
1147 .map(|name| {
1148 registry
1149 .list()
1150 .iter()
1151 .find(|h| &h.name == name)
1152 .cloned()
1153 .ok_or_else(|| {
1154 anyhow::anyhow!(
1155 "no such host: {name} (run `git-ark host list` to see registered hosts)"
1156 )
1157 })
1158 })
1159 .collect()
1160}
1161
1162fn upgrade_one(host: &Host, bytes: &[u8]) -> Result<()> {
1167 let (user, hostname) = parse_target(&host.ssh_target)?;
1168 let spec = SshSpec {
1169 user,
1170 host: hostname,
1171 port: Some(host.port),
1172 identity: host.identity.clone(),
1173 };
1174 ssh_pipe_file(
1175 &spec,
1176 &format!("{}/bin/git-ark", host.install_dir),
1177 bytes,
1178 0o755,
1179 )?;
1180 let out = ssh_run(&spec, &selfcheck_command(&host.install_dir))?;
1181 let kv = parse_kv(&out);
1182 let version = kv.get("git_ark_version").cloned().unwrap_or_default();
1183 println!("✓ {} upgraded → {version}", host.name);
1184 Ok(())
1185}
1186
1187pub fn upgrade(names: &[String], all: bool, binary: Option<&Path>) -> Result<()> {
1195 let registry = Registry::load(®istry_path()?)?;
1196 let targets = resolve_upgrade_targets(®istry, names, all)?;
1197
1198 let explicit = match binary {
1202 Some(path) => Some(
1203 std::fs::read(path).with_context(|| format!("reading --binary {}", path.display()))?,
1204 ),
1205 None => None,
1206 };
1207 let mut fetched: HashMap<String, Vec<u8>> = HashMap::new();
1208
1209 let mut any_failed = false;
1210 for host in &targets {
1211 let bytes: &[u8] = if let Some(explicit) = &explicit {
1212 if let Some(path) = binary {
1213 if !path.to_string_lossy().contains(&host.triple) {
1214 eprintln!(
1215 "⚠ --binary {} doesn't look like it's built for {}'s triple ({}) — continuing anyway",
1216 path.display(),
1217 host.name,
1218 host.triple
1219 );
1220 }
1221 }
1222 explicit
1223 } else {
1224 if !fetched.contains_key(&host.triple) {
1225 println!(
1226 "• fetching git-ark v{} for {} ({})…",
1227 release::version(),
1228 host.name,
1229 host.triple
1230 );
1231 match release::fetch_host_binary(&host.triple) {
1232 Ok(b) => {
1233 fetched.insert(host.triple.clone(), b);
1234 }
1235 Err(e) => {
1236 println!("✗ {}: {e}", host.name);
1237 any_failed = true;
1238 continue;
1239 }
1240 }
1241 }
1242 &fetched[&host.triple]
1243 };
1244
1245 if let Err(e) = upgrade_one(host, bytes) {
1246 println!("✗ {}: {e}", host.name);
1247 any_failed = true;
1248 }
1249 }
1250
1251 if any_failed {
1252 bail!("one or more hosts failed to upgrade — see above");
1253 }
1254 Ok(())
1255}
1256
1257fn mirror_transition(registry: &Registry, target: &str) -> (Option<String>, String) {
1267 let demote = registry
1268 .mirror_host()
1269 .filter(|h| h.name != target)
1270 .map(|h| h.name.clone());
1271 (demote, target.to_string())
1272}
1273
1274fn apply_host_config(host: &Host, mirror: bool, github_token: Option<&str>) -> Result<()> {
1283 let key_id = std::env::var("GIT_ARK_HOST_S3_KEY_ID")
1284 .ok()
1285 .filter(|s| !s.is_empty());
1286 let secret = std::env::var("GIT_ARK_HOST_S3_SECRET")
1287 .ok()
1288 .filter(|s| !s.is_empty());
1289 let (key_id, secret) = match (key_id, secret) {
1290 (Some(k), Some(s)) => (k, s),
1291 _ => bail!(
1292 "GIT_ARK_HOST_S3_KEY_ID and GIT_ARK_HOST_S3_SECRET must both be set to re-apply \
1293 {}'s config (changing the mirror re-renders secrets.toml, which needs the host's \
1294 write-only S3 credential, not just the GitHub token)",
1295 host.name
1296 ),
1297 };
1298
1299 let (user, hostname) = parse_target(&host.ssh_target)?;
1300 let spec = SshSpec {
1301 user,
1302 host: hostname,
1303 port: Some(host.port),
1304 identity: host.identity.clone(),
1305 };
1306
1307 let s3 = S3Config {
1308 bucket: host.bucket.clone(),
1309 region: host.region.clone(),
1310 prefix: per_host_prefix(&host.prefix, &host.name),
1311 endpoint: host.endpoint.clone(),
1312 };
1313 let config_text = render_config(&host.install_dir, &host.recipient, &s3, mirror);
1314 ssh_pipe_file(
1315 &spec,
1316 &format!("{}/config.toml", host.install_dir),
1317 config_text.as_bytes(),
1318 0o644,
1319 )?;
1320 let secrets_text = render_secrets(&key_id, &secret, github_token);
1321 ssh_pipe_file(
1322 &spec,
1323 &format!("{}/secrets.toml", host.install_dir),
1324 secrets_text.as_bytes(),
1325 0o600,
1326 )?;
1327 Ok(())
1328}
1329
1330fn designate_mirror(registry: &mut Registry, name: &str, github_token: &str) -> Result<()> {
1337 if !registry.list().iter().any(|h| h.name == name) {
1338 bail!("no such host: {name} (run `git-ark host list` to see registered hosts)");
1339 }
1340 let (demote, promote) = mirror_transition(registry, name);
1341
1342 if let Some(old_name) = &demote {
1343 let old = registry
1344 .list()
1345 .iter()
1346 .find(|h| &h.name == old_name)
1347 .expect("mirror_transition only names a host present in the registry")
1348 .clone();
1349 apply_host_config(&old, false, None)?;
1350 registry
1351 .hosts
1352 .iter_mut()
1353 .find(|h| h.name == old.name)
1354 .expect("just found above")
1355 .mirror = false;
1356 registry.save(®istry_path()?)?;
1360 }
1361
1362 let target = registry
1363 .list()
1364 .iter()
1365 .find(|h| h.name == promote)
1366 .expect("checked at the top of this fn")
1367 .clone();
1368 apply_host_config(&target, true, Some(github_token)).with_context(|| {
1369 format!(
1370 "demoted the previous mirror but could not promote {promote} — the fleet has no \
1371 mirror right now; re-run `git-ark mirror set {promote}` once it is reachable"
1372 )
1373 })?;
1374 registry
1375 .hosts
1376 .iter_mut()
1377 .find(|h| h.name == promote)
1378 .expect("just found above")
1379 .mirror = true;
1380
1381 registry.save(®istry_path()?)?;
1382 Ok(())
1383}
1384
1385pub fn mirror_set(name: &str) -> Result<()> {
1389 let mut registry = Registry::load(®istry_path()?)?;
1390 if !registry.list().iter().any(|h| h.name == name) {
1391 bail!("no such host: {name} (run `git-ark host list` to see registered hosts)");
1392 }
1393 let token = match std::env::var("GIT_ARK_GITHUB_TOKEN")
1394 .ok()
1395 .filter(|s| !s.is_empty())
1396 {
1397 Some(t) => t,
1398 None => bail!("GIT_ARK_GITHUB_TOKEN must be set to designate a GitHub mirror"),
1399 };
1400 designate_mirror(&mut registry, name, &token)?;
1401 println!("✓ {name} is now the GitHub mirror");
1402 Ok(())
1403}
1404
1405pub fn mirror_show() -> Result<()> {
1407 let registry = Registry::load(®istry_path()?)?;
1408 match registry.mirror_host() {
1409 Some(h) => println!("{}", h.name),
1410 None => println!("none"),
1411 }
1412 Ok(())
1413}
1414
1415fn push_urls(registry: &Registry, host_names: &[String], repo: &str) -> Vec<String> {
1425 host_names
1426 .iter()
1427 .map(|name| {
1428 let alias = registry
1429 .list()
1430 .iter()
1431 .find(|h| &h.name == name)
1432 .map(|h| h.push_alias())
1433 .unwrap_or_else(|| format!("git-ark-{name}"));
1434 format!("{alias}:{repo}")
1435 })
1436 .collect()
1437}
1438
1439fn git_remote(repo_path: &Path, args: &[&str]) -> Result<()> {
1442 let out = Command::new("git")
1443 .arg("-C")
1444 .arg(repo_path)
1445 .arg("remote")
1446 .args(args)
1447 .output()
1448 .with_context(|| format!("spawning git remote {}", args.join(" ")))?;
1449 if !out.status.success() {
1450 bail!(
1451 "git remote {} failed: {}",
1452 args.join(" "),
1453 String::from_utf8_lossy(&out.stderr).trim()
1454 );
1455 }
1456 Ok(())
1457}
1458
1459pub fn route(repo_path: &Path, host_names: &[String]) -> Result<()> {
1466 if host_names.is_empty() {
1467 bail!("--to <names> is required (comma-separated host names)");
1468 }
1469
1470 let registry = Registry::load(®istry_path()?)?;
1471 let unknown: Vec<&String> = host_names
1472 .iter()
1473 .filter(|n| !registry.list().iter().any(|h| &h.name == *n))
1474 .collect();
1475 if !unknown.is_empty() {
1476 let names: Vec<&str> = unknown.iter().map(|s| s.as_str()).collect();
1477 bail!(
1478 "unknown host(s): {} (run `git-ark host list` to see registered hosts)",
1479 names.join(", ")
1480 );
1481 }
1482
1483 let abs = repo_path
1486 .canonicalize()
1487 .with_context(|| format!("resolving {}", repo_path.display()))?;
1488 let repo = abs
1489 .file_name()
1490 .and_then(|n| n.to_str())
1491 .ok_or_else(|| anyhow::anyhow!("{} has no usable directory name", repo_path.display()))?
1492 .to_string();
1493 let urls = push_urls(®istry, host_names, &repo);
1494
1495 let _ = Command::new("git")
1499 .arg("-C")
1500 .arg(repo_path)
1501 .args(["remote", "remove", "git-ark"])
1502 .output();
1503
1504 git_remote(repo_path, &["add", "git-ark", &urls[0]])?;
1505 for url in &urls {
1506 git_remote(repo_path, &["set-url", "--add", "--push", "git-ark", url])?;
1507 }
1508
1509 println!(
1510 "✓ {repo} → git push git-ark fans to: {}",
1511 host_names.join(",")
1512 );
1513 Ok(())
1514}
1515
1516fn needs_workflow(repo_path: &Path) -> bool {
1524 std::fs::read_dir(repo_path.join(".github").join("workflows"))
1525 .map(|mut entries| entries.next().is_some())
1526 .unwrap_or(false)
1527}
1528
1529fn format_check(c: &github::TokenCheck, owner: &str, repo: &str) -> String {
1532 let mut lines = Vec::new();
1533
1534 lines.push(if c.valid {
1535 format!(
1536 "✓ authenticates (login: {})",
1537 c.login.as_deref().unwrap_or("?")
1538 )
1539 } else {
1540 "✗ token does not authenticate".to_string()
1541 });
1542
1543 lines.push(match &c.repo {
1544 github::RepoAccess::Exists { admin: true, .. } => {
1545 format!("✓ can administer {owner}/{repo}")
1546 }
1547 github::RepoAccess::Exists {
1548 push: true,
1549 admin: false,
1550 } => format!("✓ can push {owner}/{repo} (no admin — ok if it already exists)"),
1551 github::RepoAccess::Exists { push: false, .. } => {
1552 format!("✗ no push access to {owner}/{repo}")
1553 }
1554 github::RepoAccess::Absent => format!(
1555 "⚠ {owner}/{repo}: 404 — absent (created on first mirror) OR the token can't see it \
1556 (missing org access / unauthorized SSO PAT). GitHub returns 404 either way — verify \
1557 before relying on it"
1558 ),
1559 github::RepoAccess::Error(e) => format!("✗ {owner}/{repo}: {e}"),
1560 });
1561
1562 if c.needs_workflow {
1563 lines.push(match c.has_workflow_scope() {
1564 Some(true) => "✓ workflow scope".to_string(),
1565 Some(false) => {
1566 "✗ missing 'workflow' scope (needed to push .github/workflows/)".to_string()
1567 }
1568 None => "⚠ repo ships workflows; a fine-grained PAT needs Workflows:write (can't verify here)".to_string(),
1569 });
1570 }
1571
1572 lines.join("\n")
1573}
1574
1575pub fn mirror_check(repo_path: &Path) -> Result<()> {
1580 let policy = read_repo_policy(repo_path)?;
1581 let g = policy
1582 .as_ref()
1583 .and_then(|p| p.github.as_ref())
1584 .filter(|g| g.enabled)
1585 .ok_or_else(|| {
1586 anyhow::anyhow!(
1587 "{} has no enabled `github:` block in .git-ark.yml — nothing to check",
1588 repo_path.display()
1589 )
1590 })?;
1591 let repo_name = g.repo.clone().unwrap_or_else(|| {
1592 repo_path
1593 .canonicalize()
1594 .ok()
1595 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
1596 .unwrap_or_default()
1597 });
1598
1599 let token = std::env::var("GIT_ARK_GITHUB_TOKEN")
1600 .ok()
1601 .filter(|s| !s.is_empty())
1602 .ok_or_else(|| anyhow::anyhow!("GIT_ARK_GITHUB_TOKEN must be set to run `mirror check`"))?;
1603
1604 let workflow = needs_workflow(repo_path);
1605 let check = github::check_token(&token, &g.owner, &repo_name, workflow);
1606 let report = format_check(&check, &g.owner, &repo_name);
1607 println!("{report}");
1608
1609 if report.lines().any(|l| l.starts_with('✗')) {
1610 bail!("mirror check found hard failures — see above");
1611 }
1612 Ok(())
1613}
1614
1615#[derive(Debug, Clone, PartialEq)]
1624pub enum KeyPlan {
1625 Reuse(PathBuf),
1626 Generate(PathBuf),
1627}
1628
1629const KEY_CANDIDATES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"];
1632
1633pub fn pick_or_plan_keygen(ssh_dir: &Path, existing: &[String]) -> KeyPlan {
1639 for candidate in KEY_CANDIDATES {
1640 if existing.iter().any(|e| e == candidate) {
1641 return KeyPlan::Reuse(ssh_dir.join(candidate));
1642 }
1643 }
1644 KeyPlan::Generate(ssh_dir.join("id_ed25519"))
1645}
1646
1647fn print_manual_fallback(target: &str, pub_path: &Path) -> Result<()> {
1651 let pubkey = std::fs::read_to_string(pub_path)
1652 .with_context(|| format!("reading {}", pub_path.display()))?;
1653 println!("{}", pubkey.trim());
1654 println!(
1655 "add this line to {target}:~/.ssh/authorized_keys (e.g. via console access), \
1656 then re-run git-ark host add"
1657 );
1658 Ok(())
1659}
1660
1661pub fn host_setup_key(target: &str, port: Option<u16>) -> Result<()> {
1672 let ssh_dir = home_dir()?.join(".ssh");
1673 std::fs::create_dir_all(&ssh_dir).with_context(|| format!("creating {}", ssh_dir.display()))?;
1678
1679 let existing: Vec<String> = KEY_CANDIDATES
1680 .iter()
1681 .filter(|name| ssh_dir.join(name).exists())
1682 .map(|s| s.to_string())
1683 .collect();
1684
1685 let priv_path = match pick_or_plan_keygen(&ssh_dir, &existing) {
1686 KeyPlan::Reuse(p) => {
1687 println!("using existing key {}", p.display());
1688 p
1689 }
1690 KeyPlan::Generate(p) => {
1691 println!(
1692 "no SSH key found in {} — generating {}",
1693 ssh_dir.display(),
1694 p.display()
1695 );
1696 let status = Command::new("ssh-keygen")
1697 .args(["-t", "ed25519", "-f"])
1698 .arg(&p)
1699 .args(["-N", ""])
1700 .status()
1701 .context("spawning ssh-keygen")?;
1702 if !status.success() {
1703 bail!("ssh-keygen failed generating {}", p.display());
1704 }
1705 p
1706 }
1707 };
1708 let pub_path = PathBuf::from(format!("{}.pub", priv_path.display()));
1709
1710 let mut copy_args: Vec<String> = vec!["-i".to_string(), pub_path.display().to_string()];
1711 if let Some(port) = port {
1712 copy_args.push("-p".to_string());
1713 copy_args.push(port.to_string());
1714 }
1715 copy_args.push(target.to_string());
1716 let copy_ok = Command::new("ssh-copy-id")
1717 .args(©_args)
1718 .status()
1719 .map(|s| s.success())
1720 .unwrap_or(false);
1721 if !copy_ok {
1722 print_manual_fallback(target, &pub_path)?;
1723 bail!("ssh-copy-id to {target} did not succeed");
1724 }
1725
1726 let mut verify_args: Vec<String> = vec![
1729 "-o".to_string(),
1730 "BatchMode=yes".to_string(),
1731 "-o".to_string(),
1732 "ConnectTimeout=5".to_string(),
1733 ];
1734 if let Some(port) = port {
1735 verify_args.push("-p".to_string());
1736 verify_args.push(port.to_string());
1737 }
1738 verify_args.push("-i".to_string());
1739 verify_args.push(priv_path.display().to_string());
1740 verify_args.push(target.to_string());
1741 verify_args.push("true".to_string());
1742 let verified = Command::new("ssh")
1743 .args(&verify_args)
1744 .status()
1745 .map(|s| s.success())
1746 .unwrap_or(false);
1747
1748 if !verified {
1749 print_manual_fallback(target, &pub_path)?;
1750 bail!("key auth to {target} still doesn't work after setup-key");
1751 }
1752
1753 match port {
1754 Some(p) => println!(
1755 "✓ key auth to {target} works — now run: git-ark host add <name> {target} \
1756 --identity {} --port {p} --bucket … --recipient … --binary …",
1757 priv_path.display()
1758 ),
1759 None => println!(
1760 "✓ key auth to {target} works — now run: git-ark host add <name> {target} \
1761 --identity {} --bucket … --recipient … --binary …",
1762 priv_path.display()
1763 ),
1764 }
1765 Ok(())
1766}
1767
1768#[cfg(test)]
1769mod tests {
1770 use super::*;
1771
1772 #[test]
1773 fn parse_target_splits_user_and_host() {
1774 let (user, host) = parse_target("ark@example.com").unwrap();
1775 assert_eq!(user, "ark");
1776 assert_eq!(host, "example.com");
1777 }
1778
1779 #[test]
1780 fn parse_target_rejects_bare_host() {
1781 assert!(parse_target("example.com").is_err());
1782 }
1783
1784 #[test]
1785 fn parse_target_rejects_empty_user_or_host() {
1786 assert!(parse_target("@example.com").is_err());
1787 assert!(parse_target("ark@").is_err());
1788 }
1789
1790 #[test]
1791 fn find_alias_discovers_the_nas_style_manual_block() {
1792 let cfg = "Host git-ark\n HostName store.lan\n User pfugate\n \
1794 IdentityFile ~/.ssh/git-ark\n IdentitiesOnly yes\n";
1795 assert_eq!(
1796 find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
1797 Some("git-ark".to_string())
1798 );
1799 }
1800
1801 #[test]
1802 fn find_alias_prefers_exact_name_then_git_ark_then_first() {
1803 let cfg = "Host git-ark\n HostName store.lan\n User pfugate\n\n\
1805 Host git-ark-nas\n HostName store.lan\n User pfugate\n";
1806 assert_eq!(
1807 find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
1808 Some("git-ark-nas".to_string())
1809 );
1810 let cfg2 = "Host box\n HostName store.lan\n User pfugate\n\n\
1812 Host git-ark\n HostName store.lan\n User pfugate\n";
1813 assert_eq!(
1814 find_alias_for_target(cfg2, "store.lan", Some("pfugate"), "nas"),
1815 Some("git-ark".to_string())
1816 );
1817 }
1818
1819 #[test]
1820 fn find_alias_filters_by_user_but_tolerates_a_userless_stanza() {
1821 let cfg = "Host wrong\n HostName store.lan\n User someoneelse\n\n\
1822 Host right\n HostName store.lan\n";
1823 assert_eq!(
1825 find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
1826 Some("right".to_string())
1827 );
1828 }
1829
1830 #[test]
1831 fn find_alias_none_when_no_stanza_reaches_the_target() {
1832 let cfg = "Host other\n HostName elsewhere.example\n User pfugate\n";
1833 assert_eq!(
1834 find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
1835 None
1836 );
1837 }
1838
1839 #[test]
1840 fn find_alias_handles_multiple_aliases_on_one_host_line() {
1841 let cfg = "Host shorty git-ark-nas\n HostName store.lan\n User pfugate\n";
1843 assert_eq!(
1844 find_alias_for_target(cfg, "store.lan", Some("pfugate"), "nas"),
1845 Some("git-ark-nas".to_string())
1846 );
1847 }
1848
1849 #[test]
1850 fn adopt_parses_config_path_from_forced_command() {
1851 let ak = "ssh-rsa AAAA someone@key\n\
1852 command=\"/home/op/git-ark/bin/git-ark shell --config /home/op/git-ark/config.toml\",no-pty,no-port-forwarding ssh-ed25519 AAAA git-ark\n";
1853 assert_eq!(
1854 parse_forced_command_config(ak).as_deref(),
1855 Some("/home/op/git-ark/config.toml")
1856 );
1857 }
1858
1859 #[test]
1860 fn adopt_config_path_none_without_a_git_ark_line() {
1861 assert_eq!(
1862 parse_forced_command_config("ssh-ed25519 AAAA me@host\n"),
1863 None
1864 );
1865 }
1866
1867 #[test]
1868 fn adopt_maps_uname_to_triple() {
1869 assert_eq!(
1870 triple_from_uname("Linux\nx86_64").unwrap(),
1871 "x86_64-unknown-linux-musl"
1872 );
1873 assert_eq!(
1874 triple_from_uname("Linux\naarch64").unwrap(),
1875 "aarch64-unknown-linux-musl"
1876 );
1877 assert!(triple_from_uname("Darwin\narm64").is_err());
1878 }
1879
1880 #[test]
1881 fn ssh_args_includes_port_and_identity_when_set() {
1882 let spec = SshSpec {
1883 user: "ark".to_string(),
1884 host: "example.com".to_string(),
1885 port: Some(2222),
1886 identity: Some(PathBuf::from("/home/op/.ssh/id_ed25519")),
1887 };
1888 let args = ssh_args(&spec);
1889 assert_eq!(
1890 args,
1891 vec![
1892 "-p".to_string(),
1893 "2222".to_string(),
1894 "-i".to_string(),
1895 "/home/op/.ssh/id_ed25519".to_string(),
1896 "-o".to_string(),
1897 "StrictHostKeyChecking=accept-new".to_string(),
1898 "ark@example.com".to_string(),
1899 ]
1900 );
1901 }
1902
1903 #[test]
1904 fn ssh_args_omits_port_and_identity_when_unset() {
1905 let spec = SshSpec {
1906 user: "ark".to_string(),
1907 host: "example.com".to_string(),
1908 port: None,
1909 identity: None,
1910 };
1911 let args = ssh_args(&spec);
1912 assert_eq!(
1913 args,
1914 vec![
1915 "-o".to_string(),
1916 "StrictHostKeyChecking=accept-new".to_string(),
1917 "ark@example.com".to_string(),
1918 ]
1919 );
1920 }
1921
1922 #[test]
1923 fn pipe_file_command_stages_then_atomically_moves() {
1924 let cmd = pipe_file_command("/home/ark/git-ark/bin/git-ark", 0o755);
1925 assert!(cmd.starts_with("umask 077; cat > "));
1926 assert!(cmd.contains("chmod 755"));
1927 assert!(cmd.contains("mv "));
1928 assert!(cmd.contains("/home/ark/git-ark/bin/git-ark.uploading"));
1929 assert!(cmd.ends_with("/home/ark/git-ark/bin/git-ark"));
1930 }
1931
1932 #[test]
1933 fn append_unique_script_filters_marker_before_appending() {
1934 let script = append_unique_script("~/.ssh/authorized_keys", "/home/ark/git-ark");
1935 assert!(script.starts_with("f=~/.ssh/authorized_keys;"));
1936 assert!(script.contains("grep -vF"));
1940 assert!(!script.contains("grep -qF"));
1941 assert!(script.contains("/home/ark/git-ark"));
1942 assert!(script.contains(".bak"));
1943 }
1944
1945 #[test]
1951 #[cfg(unix)]
1952 fn append_unique_script_replaces_stale_line_on_key_rotation() {
1953 let dir = tempfile::tempdir().unwrap();
1954 let f = dir.path().join("authorized_keys");
1955 std::fs::write(&f, "ssh-rsa UNRELATED someone-else@laptop\n").unwrap();
1956
1957 let run = |line: &str| {
1958 let script = append_unique_script(&f.display().to_string(), "/home/ark/git-ark");
1959 let mut child = Command::new("sh")
1960 .arg("-c")
1961 .arg(&script)
1962 .stdin(Stdio::piped())
1963 .spawn()
1964 .unwrap();
1965 child
1966 .stdin
1967 .take()
1968 .unwrap()
1969 .write_all(line.as_bytes())
1970 .unwrap();
1971 assert!(child.wait().unwrap().success());
1972 };
1973
1974 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";
1975 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";
1976
1977 run(old_line);
1978 let after_first = std::fs::read_to_string(&f).unwrap();
1979 assert!(after_first.contains("OLDKEY"));
1980 assert!(after_first.contains("UNRELATED"));
1981
1982 run(new_line);
1984 let after_rotation = std::fs::read_to_string(&f).unwrap();
1985 assert!(
1986 after_rotation.contains("NEWKEY"),
1987 "rotated key must be authorized"
1988 );
1989 assert!(
1990 !after_rotation.contains("OLDKEY"),
1991 "stale key must be evicted, not left authorized"
1992 );
1993 assert!(
1994 after_rotation.contains("UNRELATED"),
1995 "unrelated authorized_keys lines must be preserved"
1996 );
1997 assert_eq!(
1998 after_rotation.matches("git-ark-testbox").count(),
1999 1,
2000 "must not duplicate the forced-command line"
2001 );
2002 }
2003
2004 #[test]
2005 fn selfcheck_command_quotes_install_dir() {
2006 let cmd = selfcheck_command("/home/ark/git ark");
2007 assert!(cmd.contains("'/home/ark/git ark/bin/git-ark'"));
2008 assert!(cmd.contains("--config"));
2009 assert!(cmd.contains("'/home/ark/git ark/config.toml'"));
2010 }
2011
2012 #[test]
2013 fn selfcheck_command_has_expected_shape_without_special_chars() {
2014 let cmd = selfcheck_command("/home/ark/git-ark");
2015 assert_eq!(
2016 cmd,
2017 "/home/ark/git-ark/bin/git-ark selfcheck --config /home/ark/git-ark/config.toml"
2018 );
2019 }
2020
2021 #[test]
2022 fn parse_kv_reads_selfcheck_shaped_output() {
2023 let out = "git_ark_version=0.1.0\nrepos_root=/home/ark/git-ark/repos\nconfig_valid=true\n";
2024 let kv = parse_kv(out);
2025 assert_eq!(kv.get("git_ark_version").map(String::as_str), Some("0.1.0"));
2026 assert_eq!(kv.get("config_valid").map(String::as_str), Some("true"));
2027 }
2028
2029 #[test]
2030 fn client_dir_is_under_config() {
2031 assert_eq!(
2032 client_dir(Path::new("/home/op")),
2033 PathBuf::from("/home/op/.config/git-ark")
2034 );
2035 }
2036
2037 #[test]
2038 fn registry_path_honors_git_ark_hosts_override() {
2039 std::env::set_var("GIT_ARK_HOSTS", "/tmp/git-ark-test-hosts.toml");
2040 let p = registry_path().unwrap();
2041 std::env::remove_var("GIT_ARK_HOSTS");
2042 assert_eq!(p, PathBuf::from("/tmp/git-ark-test-hosts.toml"));
2043 }
2044
2045 #[test]
2046 fn upsert_ssh_config_block_appends_when_absent() {
2047 let existing = "Host other\n HostName elsewhere.example.com\n";
2048 let block = "Host git-ark-testbox\n HostName example.com\n Port 2222\n User ark\n IdentityFile /k\n IdentitiesOnly yes\n";
2049 let out = upsert_ssh_config_block(existing, "testbox", block);
2050 assert!(out.contains("Host other"));
2051 assert!(out.contains("Host git-ark-testbox"));
2052 assert!(out.contains("HostName example.com"));
2053 }
2054
2055 #[test]
2056 fn upsert_ssh_config_block_replaces_in_place_and_preserves_siblings() {
2057 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";
2058 let block = "Host git-ark-testbox\n HostName new.example.com\n Port 2222\n User ark\n IdentityFile /k\n IdentitiesOnly yes\n";
2059 let out = upsert_ssh_config_block(existing, "testbox", block);
2060
2061 assert!(out.contains("Host before"));
2062 assert!(out.contains("Host after"));
2063 assert!(out.contains("HostName new.example.com"));
2064 assert!(!out.contains("old.example.com"));
2065 assert_eq!(out.matches("Host git-ark-testbox").count(), 1);
2067 }
2068
2069 #[test]
2070 fn upsert_ssh_config_block_is_idempotent() {
2071 let block = "Host git-ark-testbox\n HostName example.com\n Port 2222\n User ark\n IdentityFile /k\n IdentitiesOnly yes\n";
2072 let once = upsert_ssh_config_block("", "testbox", block);
2073 let twice = upsert_ssh_config_block(&once, "testbox", block);
2074 assert_eq!(once, twice);
2075 assert_eq!(once.matches("Host git-ark-testbox").count(), 1);
2076 }
2077
2078 #[test]
2079 fn remove_ssh_config_block_drops_only_the_named_block() {
2080 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";
2081 let out = remove_ssh_config_block(existing, "testbox");
2082 assert!(out.contains("Host before"));
2083 assert!(out.contains("Host after"));
2084 assert!(!out.contains("git-ark-testbox"));
2085 assert!(!out.contains("old.example.com"));
2086 }
2087
2088 #[test]
2089 fn remove_ssh_config_block_absent_is_a_no_op() {
2090 let existing = "Host other\n HostName elsewhere.example.com\n";
2091 let out = remove_ssh_config_block(existing, "testbox");
2092 assert_eq!(out, "Host other\n HostName elsewhere.example.com\n");
2093 }
2094
2095 #[test]
2096 fn remove_ssh_config_block_of_only_block_leaves_empty_file() {
2097 let existing = "Host git-ark-testbox\n HostName example.com\n Port 2222\n";
2098 let out = remove_ssh_config_block(existing, "testbox");
2099 assert_eq!(out, "");
2100 }
2101
2102 #[test]
2106 fn remove_ssh_config_block_preserves_trailing_match_block() {
2107 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";
2108 let out = remove_ssh_config_block(existing, "box");
2109 assert!(!out.contains("git-ark-box"));
2110 assert!(out.contains("Match host *.corp"));
2111 assert!(out.contains("User alice"));
2112 }
2113
2114 #[test]
2115 fn validate_host_name_accepts_ordinary_names() {
2116 assert!(validate_host_name("testbox").is_ok());
2117 assert!(validate_host_name("box-1.prod_east").is_ok());
2118 }
2119
2120 #[test]
2121 fn validate_host_name_rejects_path_traversal() {
2122 assert!(validate_host_name("../../etc/passwd").is_err());
2123 assert!(validate_host_name("..").is_err());
2124 assert!(validate_host_name(".").is_err());
2125 }
2126
2127 #[test]
2128 fn validate_host_name_rejects_empty_and_unsafe_chars() {
2129 assert!(validate_host_name("").is_err());
2130 assert!(validate_host_name("box/name").is_err());
2131 assert!(validate_host_name("box name").is_err());
2132 assert!(validate_host_name("box$(rm)").is_err());
2133 }
2134
2135 #[test]
2136 fn home_dir_falls_back_to_userprofile_when_home_unset() {
2137 let saved_home = std::env::var("HOME").ok();
2138 std::env::remove_var("HOME");
2139 std::env::set_var("USERPROFILE", "/Users/win");
2140
2141 let result = home_dir();
2142
2143 std::env::remove_var("USERPROFILE");
2144 if let Some(home) = saved_home {
2145 std::env::set_var("HOME", home);
2146 }
2147
2148 assert_eq!(result.unwrap(), PathBuf::from("/Users/win"));
2149 }
2150
2151 fn test_host(name: &str, mirror: bool) -> Host {
2152 Host {
2153 name: name.to_string(),
2154 ssh_target: format!("ark@{name}.example.com"),
2155 port: 22,
2156 identity: None,
2157 triple: "x86_64-unknown-linux-musl".to_string(),
2158 install_dir: "/home/ark/git-ark".to_string(),
2159 recipient: "age1abc".to_string(),
2160 bucket: "b".to_string(),
2161 region: "us-east-1".to_string(),
2162 prefix: "git-ark".to_string(),
2163 endpoint: None,
2164 mirror,
2165 push_alias: None,
2166 }
2167 }
2168
2169 #[test]
2170 fn mirror_transition_promotes_target_when_no_current_mirror() {
2171 let mut registry = Registry::default();
2172 registry.upsert(test_host("nas", false));
2173 registry.upsert(test_host("ec2", false));
2174
2175 let (demote, promote) = mirror_transition(®istry, "ec2");
2176 assert_eq!(demote, None);
2177 assert_eq!(promote, "ec2");
2178 }
2179
2180 #[test]
2181 fn mirror_transition_demotes_a_different_current_mirror() {
2182 let mut registry = Registry::default();
2183 registry.upsert(test_host("nas", true));
2184 registry.upsert(test_host("ec2", false));
2185
2186 let (demote, promote) = mirror_transition(®istry, "ec2");
2187 assert_eq!(demote, Some("nas".to_string()));
2188 assert_eq!(promote, "ec2");
2189 }
2190
2191 #[test]
2192 fn mirror_transition_is_a_no_op_demote_when_target_already_mirror() {
2193 let mut registry = Registry::default();
2194 registry.upsert(test_host("nas", false));
2195 registry.upsert(test_host("ec2", true));
2196
2197 let (demote, promote) = mirror_transition(®istry, "ec2");
2198 assert_eq!(demote, None);
2199 assert_eq!(promote, "ec2");
2200 }
2201
2202 #[test]
2207 fn format_status_row_reachable_healthy() {
2208 let s = HostStatus::Reachable {
2209 version: "0.1.0".to_string(),
2210 disk_free: 60_000_000_000_000,
2211 disk_low: false,
2212 config_valid: true,
2213 };
2214 let row = format_status_row("nas", false, &s);
2215 assert!(row.contains("nas"));
2216 assert!(row.contains('✓'));
2217 assert!(row.contains("0.1.0"));
2218 assert!(
2219 row.contains("54.6 TB"),
2220 "expected a human free size, got: {row}"
2221 );
2222 assert!(!row.contains('★'));
2223 }
2224
2225 #[test]
2226 fn format_status_row_reachable_marks_mirror() {
2227 let s = HostStatus::Reachable {
2228 version: "0.1.0".to_string(),
2229 disk_free: 60_000_000_000_000,
2230 disk_low: false,
2231 config_valid: true,
2232 };
2233 let row = format_status_row("nas", true, &s);
2234 assert!(row.contains('★'));
2235 assert!(row.to_lowercase().contains("mirror"));
2236 }
2237
2238 #[test]
2239 fn format_status_row_reachable_disk_low_warns() {
2240 let s = HostStatus::Reachable {
2241 version: "0.1.0".to_string(),
2242 disk_free: 1_000_000_000,
2243 disk_low: true,
2244 config_valid: true,
2245 };
2246 let row = format_status_row("nas", false, &s);
2247 assert!(row.contains('⚠'));
2248 }
2249
2250 #[test]
2251 fn format_status_row_broken_shows_reason() {
2252 let s = HostStatus::Broken("selfcheck: config invalid".to_string());
2253 let row = format_status_row("nas", false, &s);
2254 assert!(row.starts_with('✗'));
2255 assert!(row.to_lowercase().contains("broken"));
2256 assert!(row.contains("selfcheck: config invalid"));
2257 }
2258
2259 #[test]
2260 fn format_status_row_unreachable() {
2261 let s = HostStatus::Unreachable;
2262 let row = format_status_row("nas", false, &s);
2263 assert!(row.starts_with('✗'));
2264 assert!(row.to_lowercase().contains("unreachable"));
2265 }
2266
2267 #[test]
2268 fn push_urls_uses_the_registrys_recorded_alias() {
2269 let mut registry = Registry::default();
2270 registry.upsert(test_host("ec2", false));
2272 let mut nas = test_host("nas", false);
2274 nas.push_alias = Some("git-ark".to_string());
2275 registry.upsert(nas);
2276
2277 let names = vec!["nas".to_string(), "ec2".to_string(), "ghost".to_string()];
2279 assert_eq!(
2280 push_urls(®istry, &names, "myrepo"),
2281 vec![
2282 "git-ark:myrepo".to_string(), "git-ark-ec2:myrepo".to_string(), "git-ark-ghost:myrepo".to_string(), ]
2286 );
2287 }
2288
2289 #[test]
2290 fn host_rows_resolve_alias_and_sort_by_name() {
2291 let mut registry = Registry::default();
2292 registry.upsert(test_host("zeta", false));
2293 let mut nas = test_host("nas", true);
2294 nas.push_alias = Some("git-ark".to_string());
2295 registry.upsert(nas);
2296
2297 let rows = host_rows(®istry);
2298 assert_eq!(rows[0].name, "nas"); assert_eq!(rows[0].alias, "git-ark"); assert!(rows[0].mirror);
2301 assert_eq!(rows[1].name, "zeta");
2302 assert_eq!(rows[1].alias, "git-ark-zeta"); let json = serde_json::to_string(&rows).unwrap();
2306 assert!(json.contains("\"alias\":\"git-ark\""));
2307 assert!(json.contains("\"name\":\"nas\""));
2308 }
2309
2310 #[test]
2315 fn needs_workflow_true_when_workflows_dir_has_entries() {
2316 let dir = tempfile::tempdir().unwrap();
2317 let workflows = dir.path().join(".github").join("workflows");
2318 std::fs::create_dir_all(&workflows).unwrap();
2319 std::fs::write(workflows.join("ci.yml"), "name: ci\n").unwrap();
2320 assert!(needs_workflow(dir.path()));
2321 }
2322
2323 #[test]
2324 fn needs_workflow_false_when_workflows_dir_empty() {
2325 let dir = tempfile::tempdir().unwrap();
2326 std::fs::create_dir_all(dir.path().join(".github").join("workflows")).unwrap();
2327 assert!(!needs_workflow(dir.path()));
2328 }
2329
2330 #[test]
2331 fn needs_workflow_false_when_no_github_dir() {
2332 let dir = tempfile::tempdir().unwrap();
2333 assert!(!needs_workflow(dir.path()));
2334 }
2335
2336 fn token_check(
2337 valid: bool,
2338 repo: github::RepoAccess,
2339 scopes: Option<Vec<&str>>,
2340 needs_workflow: bool,
2341 ) -> github::TokenCheck {
2342 github::TokenCheck {
2343 valid,
2344 login: if valid { Some("me".to_string()) } else { None },
2345 scopes: scopes.map(|s| s.into_iter().map(str::to_string).collect()),
2346 repo,
2347 needs_workflow,
2348 }
2349 }
2350
2351 #[test]
2352 fn format_check_reports_failed_auth() {
2353 let c = token_check(false, github::RepoAccess::Absent, None, false);
2354 let out = format_check(&c, "acme", "app");
2355 assert!(out.contains("✗ token does not authenticate"));
2356 }
2357
2358 #[test]
2359 fn format_check_reports_successful_auth_with_login() {
2360 let c = token_check(
2361 true,
2362 github::RepoAccess::Exists {
2363 push: true,
2364 admin: true,
2365 },
2366 None,
2367 false,
2368 );
2369 let out = format_check(&c, "acme", "app");
2370 assert!(out.contains("✓ authenticates (login: me)"));
2371 }
2372
2373 #[test]
2374 fn format_check_repo_admin() {
2375 let c = token_check(
2376 true,
2377 github::RepoAccess::Exists {
2378 push: true,
2379 admin: true,
2380 },
2381 None,
2382 false,
2383 );
2384 let out = format_check(&c, "acme", "app");
2385 assert!(out.contains("✓ can administer acme/app"));
2386 }
2387
2388 #[test]
2389 fn format_check_repo_push_without_admin() {
2390 let c = token_check(
2391 true,
2392 github::RepoAccess::Exists {
2393 push: true,
2394 admin: false,
2395 },
2396 None,
2397 false,
2398 );
2399 let out = format_check(&c, "acme", "app");
2400 assert!(out.contains("✓ can push acme/app"));
2401 assert!(out.contains("no admin"));
2402 }
2403
2404 #[test]
2405 fn format_check_repo_no_push() {
2406 let c = token_check(
2407 true,
2408 github::RepoAccess::Exists {
2409 push: false,
2410 admin: false,
2411 },
2412 None,
2413 false,
2414 );
2415 let out = format_check(&c, "acme", "app");
2416 assert!(out.contains("✗ no push access to acme/app"));
2417 }
2418
2419 #[test]
2420 fn format_check_repo_absent() {
2421 let c = token_check(true, github::RepoAccess::Absent, None, false);
2422 let out = format_check(&c, "acme", "app");
2423 assert!(out.contains("⚠ acme/app"));
2426 assert!(out.contains("404") && out.contains("org access"));
2427 }
2428
2429 #[test]
2430 fn format_check_repo_error() {
2431 let c = token_check(
2432 true,
2433 github::RepoAccess::Error("HTTP 403".to_string()),
2434 None,
2435 false,
2436 );
2437 let out = format_check(&c, "acme", "app");
2438 assert!(out.contains("✗ acme/app: HTTP 403"));
2439 }
2440
2441 #[test]
2442 fn format_check_omits_workflow_line_when_not_needed() {
2443 let c = token_check(
2444 true,
2445 github::RepoAccess::Exists {
2446 push: true,
2447 admin: true,
2448 },
2449 Some(vec!["repo"]),
2450 false,
2451 );
2452 let out = format_check(&c, "acme", "app");
2453 assert!(!out.contains("workflow"));
2454 }
2455
2456 #[test]
2457 fn format_check_workflow_scope_present() {
2458 let c = token_check(
2459 true,
2460 github::RepoAccess::Exists {
2461 push: true,
2462 admin: true,
2463 },
2464 Some(vec!["repo", "workflow"]),
2465 true,
2466 );
2467 let out = format_check(&c, "acme", "app");
2468 assert!(out.contains("✓ workflow scope"));
2469 }
2470
2471 #[test]
2472 fn format_check_workflow_scope_missing() {
2473 let c = token_check(
2474 true,
2475 github::RepoAccess::Exists {
2476 push: true,
2477 admin: true,
2478 },
2479 Some(vec!["repo"]),
2480 true,
2481 );
2482 let out = format_check(&c, "acme", "app");
2483 assert!(out.contains("✗ missing 'workflow' scope"));
2484 }
2485
2486 #[test]
2487 fn format_check_workflow_scope_uninspectable() {
2488 let c = token_check(
2489 true,
2490 github::RepoAccess::Exists {
2491 push: true,
2492 admin: true,
2493 },
2494 None,
2495 true,
2496 );
2497 let out = format_check(&c, "acme", "app");
2498 assert!(out.contains("⚠ repo ships workflows"));
2499 }
2500
2501 #[test]
2506 fn resolve_upgrade_targets_all_returns_every_host() {
2507 let mut registry = Registry::default();
2508 registry.upsert(test_host("nas", false));
2509 registry.upsert(test_host("ec2", true));
2510
2511 let targets = resolve_upgrade_targets(®istry, &[], true).unwrap();
2512 let mut names: Vec<&str> = targets.iter().map(|h| h.name.as_str()).collect();
2513 names.sort();
2514 assert_eq!(names, vec!["ec2", "nas"]);
2515 }
2516
2517 #[test]
2518 fn resolve_upgrade_targets_all_bails_on_empty_registry() {
2519 let registry = Registry::default();
2520 assert!(resolve_upgrade_targets(®istry, &[], true).is_err());
2521 }
2522
2523 #[test]
2524 fn resolve_upgrade_targets_named_returns_those_hosts() {
2525 let mut registry = Registry::default();
2526 registry.upsert(test_host("nas", false));
2527 registry.upsert(test_host("ec2", true));
2528
2529 let names = vec!["ec2".to_string()];
2530 let targets = resolve_upgrade_targets(®istry, &names, false).unwrap();
2531 assert_eq!(targets.len(), 1);
2532 assert_eq!(targets[0].name, "ec2");
2533 }
2534
2535 #[test]
2536 fn resolve_upgrade_targets_bails_on_unknown_name() {
2537 let mut registry = Registry::default();
2538 registry.upsert(test_host("nas", false));
2539
2540 let names = vec!["nope".to_string()];
2541 let err = resolve_upgrade_targets(®istry, &names, false).unwrap_err();
2542 assert!(err.to_string().contains("nope"));
2543 }
2544
2545 #[test]
2546 fn resolve_upgrade_targets_bails_when_neither_all_nor_names() {
2547 let mut registry = Registry::default();
2548 registry.upsert(test_host("nas", false));
2549
2550 let err = resolve_upgrade_targets(®istry, &[], false).unwrap_err();
2551 assert!(err.to_string().contains("--all"));
2552 }
2553
2554 #[test]
2555 fn resolve_upgrade_targets_all_wins_over_names() {
2556 let mut registry = Registry::default();
2557 registry.upsert(test_host("nas", false));
2558 registry.upsert(test_host("ec2", true));
2559
2560 let names = vec!["nope".to_string()];
2563 let targets = resolve_upgrade_targets(®istry, &names, true).unwrap();
2564 assert_eq!(targets.len(), 2);
2565 }
2566
2567 #[test]
2572 fn pick_or_plan_keygen_reuses_existing_ed25519() {
2573 let dir = PathBuf::from("/home/op/.ssh");
2574 let existing = vec!["id_ed25519".to_string()];
2575 assert_eq!(
2576 pick_or_plan_keygen(&dir, &existing),
2577 KeyPlan::Reuse(dir.join("id_ed25519"))
2578 );
2579 }
2580
2581 #[test]
2582 fn pick_or_plan_keygen_reuses_rsa_when_thats_all_there_is() {
2583 let dir = PathBuf::from("/home/op/.ssh");
2584 let existing = vec!["id_rsa".to_string()];
2585 assert_eq!(
2586 pick_or_plan_keygen(&dir, &existing),
2587 KeyPlan::Reuse(dir.join("id_rsa"))
2588 );
2589 }
2590
2591 #[test]
2592 fn pick_or_plan_keygen_prefers_ecdsa_over_rsa() {
2593 let dir = PathBuf::from("/home/op/.ssh");
2594 let existing = vec!["id_rsa".to_string(), "id_ecdsa".to_string()];
2595 assert_eq!(
2596 pick_or_plan_keygen(&dir, &existing),
2597 KeyPlan::Reuse(dir.join("id_ecdsa"))
2598 );
2599 }
2600
2601 #[test]
2602 fn pick_or_plan_keygen_generates_ed25519_when_none_exist() {
2603 let dir = PathBuf::from("/home/op/.ssh");
2604 assert_eq!(
2605 pick_or_plan_keygen(&dir, &[]),
2606 KeyPlan::Generate(dir.join("id_ed25519"))
2607 );
2608 }
2609}