Skip to main content

git_ark/
hostspec.rs

1//! Pure host-add logic: the POSIX probe script and its parser.
2//!
3//! `host add` runs `PROBE_SCRIPT` on the target over the operator's SSH
4//! before writing a single byte to it. `parse_probe` turns the captured
5//! `key=value` output into `ProbeFacts` for the capability check (next task).
6
7/// POSIX `sh` — no bashisms — run on the target host over the operator's SSH.
8/// Emits one `key=value` line per fact; order doesn't matter to the parser.
9pub 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/// Facts about a probed host, parsed from `PROBE_SCRIPT`'s output.
20#[derive(Debug, Clone, PartialEq)]
21pub struct ProbeFacts {
22    pub os: String,
23    pub arch: String,
24    /// `(major, minor)` parsed from `git --version`; `None` if git is
25    /// missing or its output didn't match the expected shape.
26    pub git_version: Option<(u32, u32)>,
27    pub home: String,
28    pub home_writable: bool,
29    pub ssh_appendable: bool,
30    /// Version of an already-installed git-ark at `$HOME/git-ark`, if any.
31    pub existing_version: Option<String>,
32}
33
34/// Parse `PROBE_SCRIPT`'s `key=value` output into `ProbeFacts`.
35pub 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
77/// Parse `git version X.Y…` into `(X, Y)`. Anything else — `MISSING`,
78/// unexpected output — is `None`.
79fn 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
87/// Minimum git version `host add` will install onto.
88const MIN_GIT_VERSION: (u32, u32) = (2, 28);
89
90/// What `host add` will do to a host that passed capability assessment.
91#[derive(Debug, Clone, PartialEq)]
92pub struct HostPlan {
93    /// Rust target triple for the binary to ship (e.g.
94    /// `x86_64-unknown-linux-musl`).
95    pub triple: String,
96    /// Absolute install directory on the host (`<home>/git-ark`).
97    pub install_dir: String,
98    /// Version of an already-installed git-ark at that path, if any.
99    pub existing_version: Option<String>,
100}
101
102/// Decide whether a probed host is capable of hosting git-ark, and if so,
103/// plan how. Collects every blocking reason rather than stopping at the
104/// first, so the operator sees the whole picture before fixing anything.
105pub 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
156/// Render the host's `config.toml`: `repos_root` under `install_dir`, the
157/// age recipient (public key only — the private identity never leaves the
158/// client), and an `[s3]` table. `endpoint` is included only when the S3
159/// config has one (real AWS S3 otherwise). `mirror = true` is appended only
160/// when `mirror` is set — the host's backup arm gates the GitHub mirror on
161/// this line, so a non-mirror host omits it entirely rather than writing
162/// `mirror = false`.
163pub 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
182/// Render the host's `secrets.toml`: the write-only S3 credential, `[aws]`,
183/// plus an optional `[github]` token. Written `chmod 600` by the caller;
184/// never printed. The token is the client-enforced singleton mirror's
185/// credential — present only on the one host currently designated `mirror`.
186pub 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
195/// The exact `authorized_keys` entry for the forced-command key: restricted
196/// to running `git-ark shell` against this host's own config, with no pty,
197/// port/agent/X11 forwarding. Uses the absolute `install_dir` — sshd does
198/// not expand `$HOME` in `command=`.
199pub 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
205/// A host's effective S3 prefix: the base prefix namespaced by host name, so
206/// each host writes to its own subtree (independent 1:1 copies).
207pub fn per_host_prefix(base: &str, name: &str) -> String {
208    format!("{base}/{name}")
209}
210
211/// A client `~/.ssh/config` block wiring `git push git-ark-<name>:<repo>` to
212/// the host over the forced-command key.
213pub 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    // Real container output, captured from the manual probe run against
224    // docker/test-host.
225    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    /// A capable Linux/x86_64 host, for tests that flip one fact at a time.
269    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}