Skip to main content

git_ark/
shell.rs

1use crate::config::Config;
2use crate::{git, hooks};
3use anyhow::{bail, Context, Result};
4use std::os::unix::process::CommandExt;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum GitVerb {
10    ReceivePack,
11    UploadPack,
12    UploadArchive,
13}
14
15impl GitVerb {
16    pub fn binary(&self) -> &'static str {
17        match self {
18            GitVerb::ReceivePack => "git-receive-pack",
19            GitVerb::UploadPack => "git-upload-pack",
20            GitVerb::UploadArchive => "git-upload-archive",
21        }
22    }
23
24    pub fn is_write(&self) -> bool {
25        matches!(self, GitVerb::ReceivePack)
26    }
27}
28
29#[derive(Debug, Clone)]
30pub struct GitCommand {
31    pub verb: GitVerb,
32    pub repo_arg: String,
33}
34
35pub fn parse_ssh_command(cmd: &str) -> Result<GitCommand> {
36    let tokens = shell_words::split(cmd).context("splitting SSH_ORIGINAL_COMMAND")?;
37    if tokens.len() != 2 {
38        bail!(
39            "expected `<git-verb> <repo>`, got {} token(s): {cmd:?}",
40            tokens.len()
41        );
42    }
43    let verb = match tokens[0].as_str() {
44        "git-receive-pack" => GitVerb::ReceivePack,
45        "git-upload-pack" => GitVerb::UploadPack,
46        "git-upload-archive" => GitVerb::UploadArchive,
47        other => bail!("disallowed command: {other:?}"),
48    };
49    Ok(GitCommand {
50        verb,
51        repo_arg: tokens[1].clone(),
52    })
53}
54
55/// Return the first `dir.join(name)` in `dirs` that is a regular file.
56fn find_in_dirs(name: &str, dirs: &[PathBuf]) -> Option<PathBuf> {
57    dirs.iter().map(|d| d.join(name)).find(|p| p.is_file())
58}
59
60/// Resolve a git plumbing binary (e.g. `git-receive-pack`) to an absolute path.
61///
62/// Under an sshd forced command `$PATH` is often minimal or empty, so a bare
63/// `Command::new("git-receive-pack")` can fail to find the binary even though
64/// git is installed. Search `$PATH` first, then a fixed set of standard
65/// locations, and hand `exec` an absolute path.
66fn resolve_git_binary(name: &str) -> Result<PathBuf> {
67    let mut dirs: Vec<PathBuf> = std::env::var_os("PATH")
68        .map(|p| std::env::split_paths(&p).collect())
69        .unwrap_or_default();
70    for fallback in ["/usr/bin", "/bin", "/usr/local/bin", "/opt/homebrew/bin"] {
71        dirs.push(PathBuf::from(fallback));
72    }
73    find_in_dirs(name, &dirs).ok_or_else(|| {
74        anyhow::anyhow!(
75            "could not locate git plumbing binary {name:?} in $PATH or standard \
76             directories (/usr/bin, /bin, /usr/local/bin, /opt/homebrew/bin); \
77             ensure git is installed on the host"
78        )
79    })
80}
81
82pub fn resolve_repo_path(repos_root: &Path, raw: &str) -> Result<PathBuf> {
83    let mut s = raw.trim();
84    s = s.trim_start_matches('/');
85    if let Some(rest) = s.strip_prefix("~/") {
86        s = rest;
87    }
88    s = s.trim_start_matches('/');
89    if s.is_empty() {
90        bail!("empty repo path");
91    }
92
93    let mut rel = PathBuf::new();
94    let mut last = String::new();
95    for comp in s.split('/') {
96        if comp.is_empty() || comp == "." {
97            continue;
98        }
99        if comp == ".." {
100            bail!("path traversal is not allowed: {raw:?}");
101        }
102        rel.push(comp);
103        last = comp.to_string();
104    }
105    if last.is_empty() {
106        bail!("empty repo path after normalization: {raw:?}");
107    }
108    if !last.ends_with(".git") {
109        rel.set_file_name(format!("{last}.git"));
110    }
111    Ok(repos_root.join(rel))
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn parses_receive_pack() {
120        let c = parse_ssh_command("git-receive-pack 'myproject.git'").unwrap();
121        assert!(matches!(c.verb, GitVerb::ReceivePack));
122        assert_eq!(c.repo_arg, "myproject.git");
123        assert!(c.verb.is_write());
124    }
125
126    #[test]
127    fn parses_upload_pack() {
128        let c = parse_ssh_command("git-upload-pack 'a/b.git'").unwrap();
129        assert!(matches!(c.verb, GitVerb::UploadPack));
130        assert!(!c.verb.is_write());
131    }
132
133    #[test]
134    fn rejects_non_git_command() {
135        assert!(parse_ssh_command("rm -rf /").is_err());
136        assert!(parse_ssh_command("scp x y").is_err());
137    }
138
139    #[test]
140    fn rejects_wrong_arity() {
141        assert!(parse_ssh_command("git-receive-pack").is_err());
142        assert!(parse_ssh_command("git-receive-pack a b").is_err());
143    }
144
145    #[test]
146    fn rejects_disallowed_verb_with_correct_arity() {
147        // 2 tokens (correct arity) but the verb is not whitelisted → must be rejected by the whitelist, not the arity check.
148        assert!(parse_ssh_command("git-fake 'repo.git'").is_err());
149        assert!(parse_ssh_command("bash 'repo.git'").is_err());
150    }
151
152    #[test]
153    fn find_in_dirs_returns_absolute_hit_and_none_when_absent() {
154        let d = tempfile::tempdir().unwrap();
155        let bin = d.path().join("git-receive-pack");
156        std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
157        let dirs = vec![
158            PathBuf::from("/nonexistent-dir-xyz"),
159            d.path().to_path_buf(),
160        ];
161
162        let found = find_in_dirs("git-receive-pack", &dirs).unwrap();
163        assert_eq!(found, bin);
164        assert!(
165            found.is_absolute(),
166            "resolved path must be absolute: {found:?}"
167        );
168
169        assert!(
170            find_in_dirs("git-upload-pack", &dirs).is_none(),
171            "absent binary must resolve to None"
172        );
173    }
174}
175
176#[derive(Debug)]
177pub enum Action {
178    InitThenServe { repo: PathBuf, verb: GitVerb },
179    Serve { repo: PathBuf, verb: GitVerb },
180    Reject(String),
181}
182
183pub fn plan_action(cfg: &Config, cmd: &GitCommand) -> Action {
184    let repo = match resolve_repo_path(&cfg.repos_root, &cmd.repo_arg) {
185        Ok(r) => r,
186        Err(e) => return Action::Reject(e.to_string()),
187    };
188    let exists = repo.exists();
189    match (cmd.verb.is_write(), exists) {
190        (_, true) => Action::Serve {
191            repo,
192            verb: cmd.verb,
193        },
194        (true, false) => Action::InitThenServe {
195            repo,
196            verb: cmd.verb,
197        },
198        (false, false) => Action::Reject(format!("repository does not exist: {}", cmd.repo_arg)),
199    }
200}
201
202/// Entry point for the SSH forced command. Reads `SSH_ORIGINAL_COMMAND`, plans the
203/// action, performs any side effects (bare-repo init, hook install), then execs the
204/// real git plumbing. Only returns if the exec itself fails.
205pub fn run_shell(cfg: &Config, config_path: &Path, binary: &Path) -> Result<()> {
206    let raw = std::env::var("SSH_ORIGINAL_COMMAND")
207        .context("SSH_ORIGINAL_COMMAND is not set (this runs as an SSH forced command)")?;
208    let cmd = parse_ssh_command(&raw)?;
209    let (repo, verb) = match plan_action(cfg, &cmd) {
210        Action::Reject(why) => bail!("{why}"),
211        Action::Serve { repo, verb } => (repo, verb),
212        Action::InitThenServe { repo, verb } => {
213            git::init_bare(&repo)?;
214            (repo, verb)
215        }
216    };
217    if verb.is_write() {
218        hooks::install_post_receive(&repo, binary, config_path)?;
219    }
220    // Resolve to an absolute path first — a forced command's $PATH may be empty.
221    let abs = resolve_git_binary(verb.binary())?;
222    // exec replaces this process; only returns on failure.
223    let err = Command::new(&abs).arg(&repo).exec();
224    bail!("failed to exec {}: {err}", abs.display());
225}
226
227#[cfg(test)]
228mod plan_tests {
229    use super::*;
230    use crate::config::{Config, GithubConfig, S3Config};
231
232    fn cfg(root: &std::path::Path) -> Config {
233        Config {
234            repos_root: root.to_path_buf(),
235            age_recipient: "age1x".into(),
236            s3: S3Config {
237                bucket: "b".into(),
238                region: "r".into(),
239                prefix: "git-ark".into(),
240                endpoint: None,
241            },
242            github: GithubConfig::default(),
243            mirror: false,
244            backup_refs: Vec::new(),
245            disk_warn_percent: 15,
246            disk_warn_min_free_bytes: 10 * 1024 * 1024 * 1024,
247        }
248    }
249
250    #[test]
251    fn push_to_missing_repo_inits() {
252        let d = tempfile::tempdir().unwrap();
253        let c = cfg(d.path());
254        let cmd = GitCommand {
255            verb: GitVerb::ReceivePack,
256            repo_arg: "new".into(),
257        };
258        match plan_action(&c, &cmd) {
259            Action::InitThenServe { repo, verb } => {
260                assert_eq!(verb, GitVerb::ReceivePack);
261                assert_eq!(repo, d.path().join("new.git"));
262            }
263            other => panic!("expected InitThenServe, got {other:?}"),
264        }
265    }
266
267    #[test]
268    fn fetch_from_missing_repo_rejects() {
269        let d = tempfile::tempdir().unwrap();
270        let c = cfg(d.path());
271        let cmd = GitCommand {
272            verb: GitVerb::UploadPack,
273            repo_arg: "ghost".into(),
274        };
275        assert!(matches!(plan_action(&c, &cmd), Action::Reject(_)));
276    }
277
278    #[test]
279    fn serve_existing_repo() {
280        let d = tempfile::tempdir().unwrap();
281        let repo = d.path().join("here.git");
282        std::fs::create_dir_all(&repo).unwrap();
283        let c = cfg(d.path());
284        let cmd = GitCommand {
285            verb: GitVerb::UploadPack,
286            repo_arg: "here".into(),
287        };
288        assert!(matches!(plan_action(&c, &cmd), Action::Serve { .. }));
289    }
290}
291
292#[cfg(test)]
293mod resolve_tests {
294    use super::*;
295    use std::path::Path;
296
297    #[test]
298    fn strips_leading_slash_and_tilde() {
299        let root = Path::new("/srv/repos");
300        assert_eq!(
301            resolve_repo_path(root, "/myproject.git").unwrap(),
302            root.join("myproject.git")
303        );
304        assert_eq!(
305            resolve_repo_path(root, "~/myproject.git").unwrap(),
306            root.join("myproject.git")
307        );
308        assert_eq!(
309            resolve_repo_path(root, "myproject.git").unwrap(),
310            root.join("myproject.git")
311        );
312    }
313
314    #[test]
315    fn appends_git_suffix() {
316        let root = Path::new("/srv/repos");
317        assert_eq!(
318            resolve_repo_path(root, "myproject").unwrap(),
319            root.join("myproject.git")
320        );
321    }
322
323    #[test]
324    fn allows_subdirs() {
325        let root = Path::new("/srv/repos");
326        assert_eq!(
327            resolve_repo_path(root, "team/app").unwrap(),
328            root.join("team/app.git")
329        );
330    }
331
332    #[test]
333    fn rejects_traversal() {
334        let root = Path::new("/srv/repos");
335        assert!(resolve_repo_path(root, "../../etc/passwd").is_err());
336        assert!(resolve_repo_path(root, "a/../../b").is_err());
337        assert!(resolve_repo_path(root, "").is_err());
338    }
339}