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
55fn find_in_dirs(name: &str, dirs: &[PathBuf]) -> Option<PathBuf> {
57 dirs.iter().map(|d| d.join(name)).find(|p| p.is_file())
58}
59
60fn 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 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
202pub 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 let abs = resolve_git_binary(verb.binary())?;
222 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}