Skip to main content

rns_git/
git.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::protocol::RefUpdate;
7use crate::util::validate_repo_name;
8use crate::{Error, Result};
9
10pub fn check_git_available() -> Result<()> {
11    run(Command::new("git").arg("--version")).map(|_| ())
12}
13
14pub fn repository_path(root: &Path, repository: &str) -> Result<PathBuf> {
15    validate_repo_name(repository)?;
16    Ok(root.join(repository))
17}
18
19pub fn validate_refname(refname: &str) -> Result<()> {
20    if is_valid_refname(refname) {
21        Ok(())
22    } else {
23        Err(Error::msg("invalid ref"))
24    }
25}
26
27pub fn validate_namespaced_ref(refname: &str) -> Result<()> {
28    validate_refname(refname)?;
29    if refname.starts_with("refs/") {
30        Ok(())
31    } else {
32        Err(Error::msg("invalid ref"))
33    }
34}
35
36pub fn validate_sha(sha: &str) -> Result<()> {
37    if is_valid_sha(sha) {
38        Ok(())
39    } else {
40        Err(Error::msg("invalid SHA"))
41    }
42}
43
44pub fn validate_shas<'a>(shas: impl IntoIterator<Item = &'a String>) -> Result<()> {
45    for sha in shas {
46        validate_sha(sha)?;
47    }
48    Ok(())
49}
50
51pub fn validate_ref_updates(updates: &[RefUpdate]) -> Result<()> {
52    for update in updates {
53        validate_namespaced_ref(&update.refname)?;
54        if let Some(old) = update.old.as_deref() {
55            validate_sha(old)?;
56        }
57        if let Some(new) = update.new.as_deref() {
58            validate_sha(new)?;
59        }
60    }
61    Ok(())
62}
63
64pub fn ensure_bare_repository(path: &Path) -> Result<()> {
65    if is_bare_repository(path) {
66        return Ok(());
67    }
68    if let Some(parent) = path.parent() {
69        fs::create_dir_all(parent)?;
70    }
71    run(Command::new("git").arg("init").arg("--bare").arg(path)).map(|_| ())
72}
73
74pub fn is_bare_repository(path: &Path) -> bool {
75    path.join("HEAD").exists() && path.join("objects").is_dir()
76}
77
78pub fn list_refs(path: &Path) -> Result<Vec<(String, String)>> {
79    require_repository(path)?;
80    let output = run(Command::new("git")
81        .arg("--git-dir")
82        .arg(path)
83        .arg("for-each-ref")
84        .arg("--format=%(objectname) %(refname)"))?;
85    Ok(output
86        .lines()
87        .filter_map(|line| {
88            let (sha, name) = line.split_once(' ')?;
89            Some((sha.to_string(), name.to_string()))
90        })
91        .collect())
92}
93
94pub fn list_refs_text(path: &Path) -> Result<Vec<u8>> {
95    let mut out = Vec::new();
96    for (sha, name) in list_refs(path)? {
97        out.extend_from_slice(sha.as_bytes());
98        out.push(b' ');
99        out.extend_from_slice(name.as_bytes());
100        out.push(b'\n');
101    }
102    Ok(out)
103}
104
105pub fn create_bundle(path: &Path, have: &[String]) -> Result<Vec<u8>> {
106    require_repository(path)?;
107    validate_shas(have)?;
108    if list_refs(path)?.is_empty() {
109        return Ok(Vec::new());
110    }
111    let bundle_path = temp_path("rngit-fetch", "bundle");
112    let exclusions = valid_repo_objects(path, have);
113    let mut cmd = Command::new("git");
114    cmd.arg("--git-dir")
115        .arg(path)
116        .arg("bundle")
117        .arg("create")
118        .arg(&bundle_path)
119        .arg("--all");
120    add_exclusions(&mut cmd, &exclusions);
121    let result = run(&mut cmd);
122    let bytes = match result {
123        Ok(_) => fs::read(&bundle_path)?,
124        Err(err) if is_empty_bundle_error(&err) => Vec::new(),
125        Err(err) => {
126            let _ = fs::remove_file(&bundle_path);
127            return Err(err);
128        }
129    };
130    let _ = fs::remove_file(&bundle_path);
131    Ok(bytes)
132}
133
134pub fn apply_push(path: &Path, bundle: &[u8], updates: &[RefUpdate]) -> Result<()> {
135    validate_ref_updates(updates)?;
136    ensure_bare_repository(path)?;
137    if !bundle.is_empty() {
138        let bundle_path = temp_path("rngit-push", "bundle");
139        fs::write(&bundle_path, bundle)?;
140        let result = run(Command::new("git")
141            .arg("--git-dir")
142            .arg(path)
143            .arg("fetch")
144            .arg(&bundle_path)
145            .arg("+refs/heads/*:refs/heads/*")
146            .arg("+refs/tags/*:refs/tags/*"));
147        let _ = fs::remove_file(&bundle_path);
148        result?;
149    }
150
151    for update in updates {
152        if let Some(new) = update.new.as_deref() {
153            update_ref(
154                path,
155                &update.refname,
156                new,
157                update.old.as_deref(),
158                update.force,
159            )?;
160        } else {
161            delete_ref(path, &update.refname, update.old.as_deref(), update.force)?;
162        }
163    }
164    Ok(())
165}
166
167pub fn local_ref_sha(refname: &str) -> Result<Option<String>> {
168    let output = Command::new("git")
169        .arg("rev-parse")
170        .arg("--verify")
171        .arg(refname)
172        .output()?;
173    if !output.status.success() {
174        return Ok(None);
175    }
176    Ok(Some(
177        String::from_utf8_lossy(&output.stdout).trim().to_string(),
178    ))
179}
180
181pub fn create_local_bundle(refs: &[String], exclusions: &[String]) -> Result<Vec<u8>> {
182    create_local_bundle_in(Path::new("."), refs, exclusions)
183}
184
185fn create_local_bundle_in(repo: &Path, refs: &[String], exclusions: &[String]) -> Result<Vec<u8>> {
186    if refs.is_empty() {
187        return Ok(Vec::new());
188    }
189    for refname in refs {
190        validate_refname(refname)?;
191    }
192    validate_shas(exclusions)?;
193    let bundle_path = temp_path("rngit-local-push", "bundle");
194    let valid_exclusions = valid_local_objects_in(repo, exclusions);
195    let mut cmd = Command::new("git");
196    cmd.arg("-C")
197        .arg(repo)
198        .arg("bundle")
199        .arg("create")
200        .arg(&bundle_path)
201        .args(refs);
202    add_exclusions(&mut cmd, &valid_exclusions);
203    let result = run(&mut cmd);
204    let bytes = match result {
205        Ok(_) => fs::read(&bundle_path)?,
206        Err(err) if is_empty_bundle_error(&err) => Vec::new(),
207        Err(err) => {
208            let _ = fs::remove_file(&bundle_path);
209            return Err(err);
210        }
211    };
212    let _ = fs::remove_file(&bundle_path);
213    Ok(bytes)
214}
215
216pub fn object_exists_local(sha: &str) -> bool {
217    object_exists_local_in(Path::new("."), sha)
218}
219
220fn object_exists_local_in(repo: &Path, sha: &str) -> bool {
221    Command::new("git")
222        .arg("-C")
223        .arg(repo)
224        .arg("cat-file")
225        .arg("-e")
226        .arg(format!("{sha}^{{object}}"))
227        .output()
228        .map(|output| output.status.success())
229        .unwrap_or(false)
230}
231
232pub fn fetch_bundle_into_local(bundle: &[u8], wanted: &[String]) -> Result<()> {
233    if bundle.is_empty() {
234        return Ok(());
235    }
236    for refname in wanted {
237        validate_refname(refname)?;
238    }
239    let bundle_path = temp_path("rngit-local-fetch", "bundle");
240    fs::write(&bundle_path, bundle)?;
241    let mut cmd = Command::new("git");
242    cmd.arg("fetch").arg(&bundle_path);
243    if wanted.is_empty() {
244        cmd.arg("refs/*:refs/*");
245    } else {
246        cmd.args(wanted);
247    }
248    let result = run(&mut cmd);
249    let _ = fs::remove_file(&bundle_path);
250    result.map(|_| ())
251}
252
253fn update_ref(path: &Path, refname: &str, new: &str, old: Option<&str>, force: bool) -> Result<()> {
254    let mut cmd = Command::new("git");
255    cmd.arg("--git-dir")
256        .arg(path)
257        .arg("update-ref")
258        .arg(refname)
259        .arg(new);
260    if !force {
261        if let Some(old) = old {
262            cmd.arg(old);
263        }
264    }
265    run(&mut cmd).map(|_| ())
266}
267
268fn delete_ref(path: &Path, refname: &str, old: Option<&str>, force: bool) -> Result<()> {
269    let mut cmd = Command::new("git");
270    cmd.arg("--git-dir")
271        .arg(path)
272        .arg("update-ref")
273        .arg("-d")
274        .arg(refname);
275    if !force {
276        if let Some(old) = old {
277            cmd.arg(old);
278        }
279    }
280    run(&mut cmd).map(|_| ())
281}
282
283fn require_repository(path: &Path) -> Result<()> {
284    if is_bare_repository(path) {
285        Ok(())
286    } else {
287        Err(Error::msg("repository not found"))
288    }
289}
290
291fn valid_repo_objects(path: &Path, shas: &[String]) -> Vec<String> {
292    shas.iter()
293        .filter(|sha| object_exists_in_repo(path, sha))
294        .cloned()
295        .collect()
296}
297
298fn valid_local_objects_in(repo: &Path, shas: &[String]) -> Vec<String> {
299    shas.iter()
300        .filter(|sha| object_exists_local_in(repo, sha))
301        .cloned()
302        .collect()
303}
304
305fn object_exists_in_repo(path: &Path, sha: &str) -> bool {
306    Command::new("git")
307        .arg("--git-dir")
308        .arg(path)
309        .arg("cat-file")
310        .arg("-e")
311        .arg(format!("{sha}^{{object}}"))
312        .output()
313        .map(|output| output.status.success())
314        .unwrap_or(false)
315}
316
317fn is_valid_refname(refname: &str) -> bool {
318    if refname.is_empty()
319        || refname.starts_with('-')
320        || refname.starts_with('/')
321        || refname.ends_with('/')
322        || refname.ends_with('.')
323        || !refname.contains('/')
324        || refname.contains(' ')
325        || refname.contains("..")
326        || refname.contains("/.")
327        || refname.contains("//")
328        || refname.contains('\\')
329        || refname.contains('~')
330        || refname.contains('^')
331        || refname.contains(':')
332        || refname.contains('?')
333        || refname.contains('*')
334        || refname.contains('[')
335        || refname.contains("@{")
336        || refname == "@"
337    {
338        return false;
339    }
340
341    if refname
342        .split('/')
343        .any(|component| component.is_empty() || component.ends_with(".lock"))
344    {
345        return false;
346    }
347
348    refname.chars().all(|c| (c as u32) >= 40 && c != '\u{7f}')
349}
350
351fn is_valid_sha(sha: &str) -> bool {
352    sha.len() >= 40 && sha.len() % 2 == 0 && sha.bytes().all(|b| b.is_ascii_hexdigit())
353}
354
355fn add_exclusions(cmd: &mut Command, exclusions: &[String]) {
356    for sha in exclusions {
357        cmd.arg(format!("^{sha}"));
358    }
359}
360
361fn is_empty_bundle_error(err: &Error) -> bool {
362    let message = err.to_string();
363    message.contains("Refusing to create empty bundle")
364        || message.contains("does not have any commits")
365        || message.contains("Refusing to create empty")
366}
367
368fn temp_path(prefix: &str, extension: &str) -> PathBuf {
369    let now = SystemTime::now()
370        .duration_since(UNIX_EPOCH)
371        .unwrap_or_default()
372        .as_nanos();
373    std::env::temp_dir().join(format!("{prefix}-{}-{now}.{extension}", std::process::id()))
374}
375
376fn run(cmd: &mut Command) -> Result<String> {
377    let output = cmd.output()?;
378    if output.status.success() {
379        return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
380    }
381    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
382    Err(Error::msg(if stderr.is_empty() {
383        "git command failed".to_string()
384    } else {
385        stderr
386    }))
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn bare_repo_can_list_refs() {
395        let tmp = tempfile::tempdir().unwrap();
396        let repo = tmp.path().join("repo.git");
397        ensure_bare_repository(&repo).unwrap();
398        assert!(list_refs(&repo).unwrap().is_empty());
399    }
400
401    #[test]
402    fn repository_paths_reject_traversal() {
403        assert!(repository_path(Path::new("/tmp/repos"), "../x").is_err());
404        assert!(repository_path(Path::new("/tmp/repos"), "group/repo").is_ok());
405    }
406
407    #[test]
408    fn ref_validation_rejects_noncanonical_names() {
409        assert!(validate_refname("refs/heads/main").is_ok());
410        assert!(validate_refname("refs/tags/v1").is_ok());
411
412        for name in [
413            "-refs/heads/main",
414            "/refs/heads/main",
415            "refs/heads/main/",
416            "refs/heads/main.",
417            "refs/heads/feature lock",
418            "main",
419            "refs/heads/../main",
420            "refs/heads/.hidden",
421            "refs/heads//main",
422            "refs\\heads\\main",
423            "refs/heads/main.lock",
424            "refs/heads/main~",
425            "refs/heads/main^",
426            "refs/heads/main:evil",
427            "refs/heads/main?",
428            "refs/heads/main*",
429            "refs/heads/main[",
430            "refs/heads/@{upstream}",
431            "@",
432            "refs/heads/\nmain",
433        ] {
434            assert!(
435                validate_refname(name).is_err(),
436                "{name:?} should be rejected"
437            );
438        }
439    }
440
441    #[test]
442    fn sha_validation_rejects_non_hex_or_short_values() {
443        assert!(validate_sha("0123456789abcdef0123456789abcdef01234567").is_ok());
444        assert!(
445            validate_sha("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
446                .is_ok()
447        );
448
449        for sha in [
450            "",
451            "0123456789abcdef0123456789abcdef0123456",
452            "0123456789abcdef0123456789abcdef0123456g",
453            "--upload-pack=/tmp/x",
454            "0123456789abcdef0123456789abcdef012345678",
455        ] {
456            assert!(validate_sha(sha).is_err(), "{sha:?} should be rejected");
457        }
458    }
459
460    #[test]
461    fn bundle_push_roundtrip_updates_bare_repository() {
462        let tmp = tempfile::tempdir().unwrap();
463        let work = tmp.path().join("work");
464        let target = tmp.path().join("target.git");
465        fs::create_dir_all(&work).unwrap();
466
467        test_git(Command::new("git").arg("init").arg(&work));
468        fs::write(work.join("README.md"), "hello\n").unwrap();
469        test_git(
470            Command::new("git")
471                .arg("-C")
472                .arg(&work)
473                .arg("add")
474                .arg("README.md"),
475        );
476        test_git(
477            Command::new("git")
478                .arg("-C")
479                .arg(&work)
480                .arg("-c")
481                .arg("user.name=RNS Test")
482                .arg("-c")
483                .arg("user.email=rns@example.invalid")
484                .arg("commit")
485                .arg("-m")
486                .arg("init"),
487        );
488        test_git(
489            Command::new("git")
490                .arg("-C")
491                .arg(&work)
492                .arg("branch")
493                .arg("-M")
494                .arg("main"),
495        );
496        let sha = test_git(
497            Command::new("git")
498                .arg("-C")
499                .arg(&work)
500                .arg("rev-parse")
501                .arg("refs/heads/main"),
502        );
503        let sha = sha.trim().to_string();
504        let bundle_path = tmp.path().join("push.bundle");
505        test_git(
506            Command::new("git")
507                .arg("-C")
508                .arg(&work)
509                .arg("bundle")
510                .arg("create")
511                .arg(&bundle_path)
512                .arg("refs/heads/main"),
513        );
514
515        apply_push(
516            &target,
517            &fs::read(&bundle_path).unwrap(),
518            &[RefUpdate {
519                refname: "refs/heads/main".into(),
520                old: None,
521                new: Some(sha.clone()),
522                force: true,
523            }],
524        )
525        .unwrap();
526
527        assert_eq!(
528            list_refs(&target).unwrap(),
529            vec![(sha.clone(), "refs/heads/main".into())]
530        );
531        assert!(!create_bundle(&target, &[]).unwrap().is_empty());
532        assert!(create_bundle(&target, &[sha]).unwrap().is_empty());
533    }
534
535    #[test]
536    fn local_push_bundle_excludes_known_remote_objects() {
537        let tmp = tempfile::tempdir().unwrap();
538        let work = tmp.path().join("work");
539        fs::create_dir_all(&work).unwrap();
540
541        test_git(Command::new("git").arg("init").arg(&work));
542        fs::write(work.join("README.md"), "hello\n").unwrap();
543        test_git(
544            Command::new("git")
545                .arg("-C")
546                .arg(&work)
547                .arg("add")
548                .arg("README.md"),
549        );
550        test_git(
551            Command::new("git")
552                .arg("-C")
553                .arg(&work)
554                .arg("-c")
555                .arg("user.name=RNS Test")
556                .arg("-c")
557                .arg("user.email=rns@example.invalid")
558                .arg("commit")
559                .arg("-m")
560                .arg("init"),
561        );
562        test_git(
563            Command::new("git")
564                .arg("-C")
565                .arg(&work)
566                .arg("branch")
567                .arg("-M")
568                .arg("main"),
569        );
570        let sha = test_git(
571            Command::new("git")
572                .arg("-C")
573                .arg(&work)
574                .arg("rev-parse")
575                .arg("refs/heads/main"),
576        )
577        .trim()
578        .to_string();
579        let full = create_local_bundle_in(&work, &["refs/heads/main".into()], &[]).unwrap();
580        let excluded = create_local_bundle_in(&work, &["refs/heads/main".into()], &[sha]).unwrap();
581        assert!(!full.is_empty());
582        assert!(excluded.is_empty());
583    }
584
585    fn test_git(cmd: &mut Command) -> String {
586        let output = cmd.output().unwrap();
587        assert!(
588            output.status.success(),
589            "git command failed: {}",
590            String::from_utf8_lossy(&output.stderr)
591        );
592        String::from_utf8_lossy(&output.stdout).into_owned()
593    }
594}