1pub const PROBE_SCRIPT: &str = r#"
10echo "os=$(uname -s)"
11echo "arch=$(uname -m)"
12echo "git=$(git --version 2>/dev/null || echo MISSING)"
13echo "home=$HOME"
14echo "home_writable=$([ -w "$HOME" ] && echo yes || echo no)"
15echo "ssh_appendable=$( { mkdir -p "$HOME/.ssh" && [ -w "$HOME/.ssh" ]; } >/dev/null 2>&1 && echo yes || echo no)"
16echo "existing_ark=$( [ -x "$HOME/git-ark/bin/git-ark" ] && "$HOME/git-ark/bin/git-ark" --version 2>/dev/null | awk '{print $2}' || echo none)"
17"#;
18
19#[derive(Debug, Clone, PartialEq)]
21pub struct ProbeFacts {
22 pub os: String,
23 pub arch: String,
24 pub git_version: Option<(u32, u32)>,
27 pub home: String,
28 pub home_writable: bool,
29 pub ssh_appendable: bool,
30 pub existing_version: Option<String>,
32}
33
34pub fn parse_probe(output: &str) -> ProbeFacts {
36 let mut os = String::new();
37 let mut arch = String::new();
38 let mut git_version = None;
39 let mut home = String::new();
40 let mut home_writable = false;
41 let mut ssh_appendable = false;
42 let mut existing_version = None;
43
44 for line in output.lines() {
45 let Some((key, value)) = line.split_once('=') else {
46 continue;
47 };
48 match key {
49 "os" => os = value.to_string(),
50 "arch" => arch = value.to_string(),
51 "git" => git_version = parse_git_version(value),
52 "home" => home = value.to_string(),
53 "home_writable" => home_writable = value == "yes",
54 "ssh_appendable" => ssh_appendable = value == "yes",
55 "existing_ark" => {
56 existing_version = if value == "none" {
57 None
58 } else {
59 Some(value.to_string())
60 }
61 }
62 _ => {}
63 }
64 }
65
66 ProbeFacts {
67 os,
68 arch,
69 git_version,
70 home,
71 home_writable,
72 ssh_appendable,
73 existing_version,
74 }
75}
76
77fn parse_git_version(text: &str) -> Option<(u32, u32)> {
80 let rest = text.strip_prefix("git version ")?;
81 let mut parts = rest.split('.');
82 let major: u32 = parts.next()?.parse().ok()?;
83 let minor: u32 = parts.next()?.parse().ok()?;
84 Some((major, minor))
85}
86
87const MIN_GIT_VERSION: (u32, u32) = (2, 28);
89
90#[derive(Debug, Clone, PartialEq)]
92pub struct HostPlan {
93 pub triple: String,
96 pub install_dir: String,
98 pub existing_version: Option<String>,
100}
101
102pub fn assess(facts: &ProbeFacts) -> Result<HostPlan, Vec<String>> {
106 let mut reasons = Vec::new();
107
108 let triple = match facts.os.as_str() {
109 "Linux" => match facts.arch.as_str() {
110 "x86_64" => Some("x86_64-unknown-linux-musl"),
111 "aarch64" | "arm64" => Some("aarch64-unknown-linux-musl"),
112 other => {
113 reasons.push(format!(
114 "unsupported architecture: {other} (git-ark hosts are x86_64 or aarch64)"
115 ));
116 None
117 }
118 },
119 other => {
120 reasons.push(format!(
121 "unsupported host OS: {other} (git-ark hosts are Linux)"
122 ));
123 None
124 }
125 };
126
127 match facts.git_version {
128 None => reasons.push("git not found (install git >= 2.28)".to_string()),
129 Some(v) if v < MIN_GIT_VERSION => {
130 reasons.push(format!("git >= 2.28 required, found {}.{}", v.0, v.1))
131 }
132 Some(_) => {}
133 }
134
135 if !facts.home_writable {
136 reasons.push("home directory not writable".to_string());
137 }
138
139 if !facts.ssh_appendable {
140 reasons.push("cannot write ~/.ssh (authorized_keys not installable)".to_string());
141 }
142
143 if let Some(triple) = triple {
144 if reasons.is_empty() {
145 return Ok(HostPlan {
146 triple: triple.to_string(),
147 install_dir: format!("{}/git-ark", facts.home),
148 existing_version: facts.existing_version.clone(),
149 });
150 }
151 }
152
153 Err(reasons)
154}
155
156pub fn render_config(
164 install_dir: &str,
165 recipient: &str,
166 s3: &crate::config::S3Config,
167 mirror: bool,
168) -> String {
169 let mut out = format!(
170 "repos_root = \"{install_dir}/repos\"\nage_recipient = \"{recipient}\"\n\n[s3]\nbucket = \"{}\"\nregion = \"{}\"\nprefix = \"{}\"\n",
171 s3.bucket, s3.region, s3.prefix
172 );
173 if let Some(endpoint) = &s3.endpoint {
174 out.push_str(&format!("endpoint = \"{endpoint}\"\n"));
175 }
176 if mirror {
177 out.push_str("mirror = true\n");
178 }
179 out
180}
181
182pub fn render_secrets(key_id: &str, secret: &str, github_token: Option<&str>) -> String {
187 let mut out =
188 format!("[aws]\naccess_key_id = \"{key_id}\"\nsecret_access_key = \"{secret}\"\n");
189 if let Some(token) = github_token {
190 out.push_str(&format!("\n[github]\ntoken = \"{token}\"\n"));
191 }
192 out
193}
194
195pub fn forced_command_line(install_dir: &str, pubkey: &str) -> String {
200 format!(
201 "command=\"{install_dir}/bin/git-ark shell --config {install_dir}/config.toml\",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding {pubkey}"
202 )
203}
204
205pub fn per_host_prefix(base: &str, name: &str) -> String {
208 format!("{base}/{name}")
209}
210
211pub fn ssh_config_block(name: &str, host: &str, port: u16, user: &str, identity: &str) -> String {
214 format!(
215 "Host git-ark-{name}\n HostName {host}\n Port {port}\n User {user}\n IdentityFile {identity}\n IdentitiesOnly yes\n"
216 )
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 const SAMPLE: &str = "os=Linux\narch=aarch64\ngit=git version 2.39.5\nhome=/home/ark\nhome_writable=yes\nssh_appendable=yes\nexisting_ark=none\n";
226
227 #[test]
228 fn parses_sample_probe_output() {
229 let facts = parse_probe(SAMPLE);
230 assert_eq!(facts.os, "Linux");
231 assert_eq!(facts.arch, "aarch64");
232 assert_eq!(facts.git_version, Some((2, 39)));
233 assert_eq!(facts.home, "/home/ark");
234 assert!(facts.home_writable);
235 assert!(facts.ssh_appendable);
236 assert_eq!(facts.existing_version, None);
237 }
238
239 #[test]
240 fn parses_older_git_version() {
241 let out = "os=Linux\narch=x86_64\ngit=git version 2.28.0\nhome=/home/ark\nhome_writable=yes\nssh_appendable=yes\nexisting_ark=none\n";
242 let facts = parse_probe(out);
243 assert_eq!(facts.git_version, Some((2, 28)));
244 }
245
246 #[test]
247 fn missing_git_parses_to_none() {
248 let out = "os=Linux\narch=x86_64\ngit=MISSING\nhome=/home/ark\nhome_writable=yes\nssh_appendable=yes\nexisting_ark=none\n";
249 let facts = parse_probe(out);
250 assert_eq!(facts.git_version, None);
251 }
252
253 #[test]
254 fn existing_ark_version_is_parsed_when_present() {
255 let out = "os=Linux\narch=x86_64\ngit=git version 2.39.5\nhome=/home/ark\nhome_writable=yes\nssh_appendable=yes\nexisting_ark=0.1.0\n";
256 let facts = parse_probe(out);
257 assert_eq!(facts.existing_version, Some("0.1.0".to_string()));
258 }
259
260 #[test]
261 fn unwritable_home_and_unappendable_ssh_parse_to_false() {
262 let out = "os=Linux\narch=x86_64\ngit=git version 2.39.5\nhome=/home/ark\nhome_writable=no\nssh_appendable=no\nexisting_ark=none\n";
263 let facts = parse_probe(out);
264 assert!(!facts.home_writable);
265 assert!(!facts.ssh_appendable);
266 }
267
268 fn capable_facts() -> ProbeFacts {
270 ProbeFacts {
271 os: "Linux".to_string(),
272 arch: "x86_64".to_string(),
273 git_version: Some((2, 39)),
274 home: "/home/ark".to_string(),
275 home_writable: true,
276 ssh_appendable: true,
277 existing_version: None,
278 }
279 }
280
281 #[test]
282 fn linux_x86_64_maps_to_musl_triple() {
283 let plan = assess(&capable_facts()).unwrap();
284 assert_eq!(plan.triple, "x86_64-unknown-linux-musl");
285 assert_eq!(plan.install_dir, "/home/ark/git-ark");
286 assert_eq!(plan.existing_version, None);
287 }
288
289 #[test]
290 fn linux_aarch64_maps_to_aarch64_musl_triple() {
291 let facts = ProbeFacts {
292 arch: "aarch64".to_string(),
293 ..capable_facts()
294 };
295 let plan = assess(&facts).unwrap();
296 assert_eq!(plan.triple, "aarch64-unknown-linux-musl");
297 }
298
299 #[test]
300 fn linux_arm64_maps_to_aarch64_musl_triple() {
301 let facts = ProbeFacts {
302 arch: "arm64".to_string(),
303 ..capable_facts()
304 };
305 let plan = assess(&facts).unwrap();
306 assert_eq!(plan.triple, "aarch64-unknown-linux-musl");
307 }
308
309 #[test]
310 fn unsupported_arch_on_linux_is_a_reason() {
311 let facts = ProbeFacts {
312 arch: "riscv64".to_string(),
313 ..capable_facts()
314 };
315 let reasons = assess(&facts).unwrap_err();
316 assert!(reasons
317 .iter()
318 .any(|r| r.contains("unsupported architecture")));
319 }
320
321 #[test]
322 fn darwin_is_unsupported_os() {
323 let facts = ProbeFacts {
324 os: "Darwin".to_string(),
325 ..capable_facts()
326 };
327 let reasons = assess(&facts).unwrap_err();
328 assert!(reasons.iter().any(|r| r.contains("unsupported host OS")));
329 }
330
331 #[test]
332 fn missing_git_is_a_reason() {
333 let facts = ProbeFacts {
334 git_version: None,
335 ..capable_facts()
336 };
337 let reasons = assess(&facts).unwrap_err();
338 assert!(reasons.iter().any(|r| r.contains("git not found")));
339 }
340
341 #[test]
342 fn git_below_minimum_is_a_reason() {
343 let facts = ProbeFacts {
344 git_version: Some((2, 27)),
345 ..capable_facts()
346 };
347 let reasons = assess(&facts).unwrap_err();
348 assert!(reasons
349 .iter()
350 .any(|r| r.contains("git >= 2.28 required, found 2.27")));
351 }
352
353 #[test]
354 fn unwritable_home_is_a_reason() {
355 let facts = ProbeFacts {
356 home_writable: false,
357 ..capable_facts()
358 };
359 let reasons = assess(&facts).unwrap_err();
360 assert!(reasons
361 .iter()
362 .any(|r| r.contains("home directory not writable")));
363 }
364
365 #[test]
366 fn unappendable_ssh_is_a_reason() {
367 let facts = ProbeFacts {
368 ssh_appendable: false,
369 ..capable_facts()
370 };
371 let reasons = assess(&facts).unwrap_err();
372 assert!(reasons.iter().any(|r| r.contains("cannot write ~/.ssh")));
373 }
374
375 #[test]
376 fn multiple_failures_are_all_collected() {
377 let facts = ProbeFacts {
378 os: "Darwin".to_string(),
379 git_version: None,
380 home_writable: false,
381 ssh_appendable: false,
382 ..capable_facts()
383 };
384 let reasons = assess(&facts).unwrap_err();
385 assert!(reasons.iter().any(|r| r.contains("unsupported host OS")));
386 assert!(reasons.iter().any(|r| r.contains("git not found")));
387 assert!(reasons
388 .iter()
389 .any(|r| r.contains("home directory not writable")));
390 assert!(reasons.iter().any(|r| r.contains("cannot write ~/.ssh")));
391 assert_eq!(reasons.len(), 4);
392 }
393
394 #[test]
395 fn existing_version_carries_through_to_plan() {
396 let facts = ProbeFacts {
397 existing_version: Some("0.1.0".to_string()),
398 ..capable_facts()
399 };
400 let plan = assess(&facts).unwrap();
401 assert_eq!(plan.existing_version, Some("0.1.0".to_string()));
402 }
403
404 fn s3_with_endpoint() -> crate::config::S3Config {
405 crate::config::S3Config {
406 bucket: "b".to_string(),
407 region: "us-east-1".to_string(),
408 prefix: "git-ark".to_string(),
409 endpoint: Some("http://minio:9000".to_string()),
410 }
411 }
412
413 #[test]
414 fn render_config_includes_repos_root_and_recipient() {
415 let cfg = render_config("/home/ark/git-ark", "age1abc", &s3_with_endpoint(), false);
416 assert!(cfg.contains("repos_root = \"/home/ark/git-ark/repos\""));
417 assert!(cfg.contains("age_recipient = \"age1abc\""));
418 }
419
420 #[test]
421 fn render_config_includes_endpoint_when_some() {
422 let cfg = render_config("/home/ark/git-ark", "age1abc", &s3_with_endpoint(), false);
423 assert!(cfg.contains("[s3]"));
424 assert!(cfg.contains("bucket = \"b\""));
425 assert!(cfg.contains("region = \"us-east-1\""));
426 assert!(cfg.contains("prefix = \"git-ark\""));
427 assert!(cfg.contains("endpoint = \"http://minio:9000\""));
428 }
429
430 #[test]
431 fn render_config_omits_endpoint_when_none() {
432 let s3 = crate::config::S3Config {
433 bucket: "b".to_string(),
434 region: "us-east-1".to_string(),
435 prefix: "git-ark".to_string(),
436 endpoint: None,
437 };
438 let cfg = render_config("/home/ark/git-ark", "age1abc", &s3, false);
439 assert!(!cfg.contains("endpoint"));
440 }
441
442 #[test]
443 fn render_config_includes_mirror_line_when_true() {
444 let cfg = render_config("/home/ark/git-ark", "age1abc", &s3_with_endpoint(), true);
445 assert!(cfg.contains("mirror = true"));
446 }
447
448 #[test]
449 fn render_config_omits_mirror_line_when_false() {
450 let cfg = render_config("/home/ark/git-ark", "age1abc", &s3_with_endpoint(), false);
451 assert!(!cfg.contains("mirror ="));
452 }
453
454 #[test]
455 fn per_host_prefix_namespaces_base_by_host_name() {
456 assert_eq!(per_host_prefix("git-ark", "nas"), "git-ark/nas");
457 }
458
459 #[test]
460 fn render_secrets_has_aws_section_only() {
461 let s = render_secrets("AKIAEXAMPLE", "sekrit", None);
462 assert!(s.contains("[aws]"));
463 assert!(s.contains("access_key_id = \"AKIAEXAMPLE\""));
464 assert!(s.contains("secret_access_key = \"sekrit\""));
465 assert!(!s.contains("[github]"));
466 }
467
468 #[test]
469 fn render_secrets_includes_github_token_when_some() {
470 let s = render_secrets("AKIAEXAMPLE", "sekrit", Some("ghp_x"));
471 assert!(s.contains("[github]"));
472 assert!(s.contains("token = \"ghp_x\""));
473 }
474
475 #[test]
476 fn render_secrets_omits_github_section_when_none() {
477 let s = render_secrets("AKIAEXAMPLE", "sekrit", None);
478 assert!(!s.contains("[github]"));
479 assert!(!s.contains("token ="));
480 }
481
482 #[test]
483 fn forced_command_line_has_expected_shape() {
484 let line = forced_command_line("/home/ark/git-ark", "ssh-ed25519 AAAAC3... git-ark");
485 assert!(line.starts_with(
486 "command=\"/home/ark/git-ark/bin/git-ark shell --config /home/ark/git-ark/config.toml\","
487 ));
488 assert!(line.contains(",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding "));
489 assert!(line.ends_with(" ssh-ed25519 AAAAC3... git-ark"));
490 }
491
492 #[test]
493 fn ssh_config_block_has_host_alias_and_identities_only() {
494 let block = ssh_config_block(
495 "testbox",
496 "example.com",
497 2222,
498 "ark",
499 "/home/phil/.ssh/git-ark/testbox",
500 );
501 assert!(block.contains("Host git-ark-testbox"));
502 assert!(block.contains("HostName example.com"));
503 assert!(block.contains("Port 2222"));
504 assert!(block.contains("User ark"));
505 assert!(block.contains("IdentityFile /home/phil/.ssh/git-ark/testbox"));
506 assert!(block.contains("IdentitiesOnly yes"));
507 }
508}