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 clone_remote_bare(source: &str, path: &Path, repository_type: &str) -> Result<()> {
75    if source.trim().is_empty() {
76        return Err(Error::msg("missing source"));
77    }
78    if path.exists() {
79        return Err(Error::msg("repository already exists"));
80    }
81    let parent = path
82        .parent()
83        .ok_or_else(|| Error::msg("repository path has no parent"))?;
84    fs::create_dir_all(parent)?;
85
86    let name = path
87        .file_name()
88        .and_then(|name| name.to_str())
89        .unwrap_or("repository");
90    let suffix = SystemTime::now()
91        .duration_since(UNIX_EPOCH)
92        .unwrap_or_default()
93        .as_nanos();
94    let temp = parent.join(format!(".{name}.clone-{suffix}"));
95
96    let result = (|| {
97        fs::create_dir_all(&temp)?;
98        run(Command::new("git").arg("init").arg("--bare").arg(&temp))?;
99        run(Command::new("git")
100            .arg("--git-dir")
101            .arg(&temp)
102            .arg("fetch")
103            .arg(source)
104            .arg("+refs/*:refs/*"))?;
105        update_head_to_source_default(&temp, source)?;
106        run(Command::new("git")
107            .arg("--git-dir")
108            .arg(&temp)
109            .arg("config")
110            .arg("repository.rngit.type")
111            .arg(repository_type))?;
112        run(Command::new("git")
113            .arg("--git-dir")
114            .arg(&temp)
115            .arg("config")
116            .arg("repository.rngit.upstream.source")
117            .arg(source))?;
118        if matches!(repository_type, "fork" | "mirror") {
119            run(Command::new("git")
120                .arg("--git-dir")
121                .arg(&temp)
122                .arg("config")
123                .arg("repository.rngit.upstream.sync")
124                .arg(unix_timestamp().to_string()))?;
125        }
126        fs::rename(&temp, path)?;
127        Ok(())
128    })();
129
130    if result.is_err() {
131        let _ = fs::remove_dir_all(&temp);
132    }
133    result
134}
135
136pub fn update_head_to_source_default(repo_path: &Path, source: &str) -> Result<()> {
137    require_repository(repo_path)?;
138    let mut target_branch = source_default_head(source)?;
139    if let Some(branch) = target_branch.as_deref() {
140        let check = Command::new("git")
141            .arg("--git-dir")
142            .arg(repo_path)
143            .arg("show-ref")
144            .arg("--verify")
145            .arg("--quiet")
146            .arg(branch)
147            .status()?;
148        if !check.success() {
149            target_branch = None;
150        }
151    }
152    let target_branch = match target_branch {
153        Some(branch) => branch,
154        None => first_local_branch(repo_path)?
155            .ok_or_else(|| Error::msg("repository has no local branch for HEAD"))?,
156    };
157    run(Command::new("git")
158        .arg("--git-dir")
159        .arg(repo_path)
160        .arg("symbolic-ref")
161        .arg("HEAD")
162        .arg(target_branch))?;
163    Ok(())
164}
165
166fn source_default_head(source: &str) -> Result<Option<String>> {
167    let output = Command::new("git")
168        .arg("ls-remote")
169        .arg("--symref")
170        .arg(source)
171        .arg("HEAD")
172        .output()?;
173    if !output.status.success() {
174        return Ok(None);
175    }
176    let stdout = String::from_utf8_lossy(&output.stdout);
177    for line in stdout.lines() {
178        let Some(rest) = line.strip_prefix("ref: ") else {
179            continue;
180        };
181        let Some((target, name)) = rest.split_once('\t') else {
182            continue;
183        };
184        if name == "HEAD" && target.starts_with("refs/heads/") {
185            return Ok(Some(target.to_string()));
186        }
187    }
188    Ok(None)
189}
190
191fn first_local_branch(repo_path: &Path) -> Result<Option<String>> {
192    let output = run(Command::new("git")
193        .arg("--git-dir")
194        .arg(repo_path)
195        .arg("for-each-ref")
196        .arg("--format=%(refname)")
197        .arg("--sort=refname")
198        .arg("refs/heads"))?;
199    Ok(output
200        .lines()
201        .find(|line| line.starts_with("refs/heads/"))
202        .map(ToOwned::to_owned))
203}
204
205pub fn repository_config(path: &Path, key: &str) -> Result<Option<String>> {
206    require_repository(path)?;
207    let output = Command::new("git")
208        .arg("--git-dir")
209        .arg(path)
210        .arg("config")
211        .arg("--get")
212        .arg(key)
213        .output()?;
214    if !output.status.success() {
215        return Ok(None);
216    }
217    Ok(Some(
218        String::from_utf8_lossy(&output.stdout).trim().to_string(),
219    ))
220}
221
222pub fn sync_upstream(path: &Path) -> Result<String> {
223    require_repository(path)?;
224    let repository_type = repository_config(path, "repository.rngit.type")?
225        .ok_or_else(|| Error::msg("repository is neither fork nor mirror"))?;
226    if !matches!(repository_type.as_str(), "fork" | "mirror") {
227        return Err(Error::msg("repository is neither fork nor mirror"));
228    }
229    let source = repository_config(path, "repository.rngit.upstream.source")?
230        .filter(|source| !source.is_empty())
231        .ok_or_else(|| Error::msg("missing upstream source"))?;
232    run(Command::new("git")
233        .arg("--git-dir")
234        .arg(path)
235        .arg("fetch")
236        .arg(&source)
237        .arg("+refs/*:refs/*"))?;
238    if repository_type == "mirror" {
239        update_head_to_source_default(path, &source)?;
240    }
241    if matches!(repository_type.as_str(), "fork" | "mirror") {
242        run(Command::new("git")
243            .arg("--git-dir")
244            .arg(path)
245            .arg("config")
246            .arg("repository.rngit.upstream.sync")
247            .arg(unix_timestamp().to_string()))?;
248    }
249    Ok(repository_type)
250}
251
252pub fn is_bare_repository(path: &Path) -> bool {
253    path.join("HEAD").exists() && path.join("objects").is_dir()
254}
255
256pub fn list_refs(path: &Path) -> Result<Vec<(String, String)>> {
257    require_repository(path)?;
258    let output = run(Command::new("git")
259        .arg("--git-dir")
260        .arg(path)
261        .arg("for-each-ref")
262        .arg("--format=%(objectname) %(refname)"))?;
263    Ok(output
264        .lines()
265        .filter_map(|line| {
266            let (sha, name) = line.split_once(' ')?;
267            Some((sha.to_string(), name.to_string()))
268        })
269        .collect())
270}
271
272pub fn list_refs_text(path: &Path) -> Result<Vec<u8>> {
273    let mut out = Vec::new();
274    for (sha, name) in list_refs(path)? {
275        out.extend_from_slice(sha.as_bytes());
276        out.push(b' ');
277        out.extend_from_slice(name.as_bytes());
278        out.push(b'\n');
279    }
280    Ok(out)
281}
282
283pub fn create_bundle(path: &Path, have: &[String]) -> Result<Vec<u8>> {
284    require_repository(path)?;
285    validate_shas(have)?;
286    if list_refs(path)?.is_empty() {
287        return Ok(Vec::new());
288    }
289    let bundle_path = temp_path("rngit-fetch", "bundle");
290    let exclusions = valid_repo_objects(path, have);
291    let mut cmd = Command::new("git");
292    cmd.arg("--git-dir")
293        .arg(path)
294        .arg("bundle")
295        .arg("create")
296        .arg(&bundle_path)
297        .arg("--all");
298    add_exclusions(&mut cmd, &exclusions);
299    let result = run(&mut cmd);
300    let bytes = match result {
301        Ok(_) => fs::read(&bundle_path)?,
302        Err(err) if is_empty_bundle_error(&err) => Vec::new(),
303        Err(err) => {
304            let _ = fs::remove_file(&bundle_path);
305            return Err(err);
306        }
307    };
308    let _ = fs::remove_file(&bundle_path);
309    Ok(bytes)
310}
311
312pub fn apply_push(path: &Path, bundle: &[u8], updates: &[RefUpdate]) -> Result<()> {
313    validate_ref_updates(updates)?;
314    ensure_bare_repository(path)?;
315    if !bundle.is_empty() {
316        let bundle_path = temp_path("rngit-push", "bundle");
317        fs::write(&bundle_path, bundle)?;
318        let result = run(Command::new("git")
319            .arg("--git-dir")
320            .arg(path)
321            .arg("fetch")
322            .arg(&bundle_path)
323            .arg("+refs/heads/*:refs/heads/*")
324            .arg("+refs/tags/*:refs/tags/*"));
325        let _ = fs::remove_file(&bundle_path);
326        result?;
327    }
328
329    for update in updates {
330        if let Some(new) = update.new.as_deref() {
331            update_ref(
332                path,
333                &update.refname,
334                new,
335                update.old.as_deref(),
336                update.force,
337            )?;
338        } else {
339            delete_ref(path, &update.refname, update.old.as_deref(), update.force)?;
340        }
341    }
342    Ok(())
343}
344
345pub fn local_ref_sha(refname: &str) -> Result<Option<String>> {
346    let output = Command::new("git")
347        .arg("rev-parse")
348        .arg("--verify")
349        .arg(refname)
350        .output()?;
351    if !output.status.success() {
352        return Ok(None);
353    }
354    Ok(Some(
355        String::from_utf8_lossy(&output.stdout).trim().to_string(),
356    ))
357}
358
359pub fn create_local_bundle(refs: &[String], exclusions: &[String]) -> Result<Vec<u8>> {
360    create_local_bundle_in(Path::new("."), refs, exclusions)
361}
362
363fn create_local_bundle_in(repo: &Path, refs: &[String], exclusions: &[String]) -> Result<Vec<u8>> {
364    if refs.is_empty() {
365        return Ok(Vec::new());
366    }
367    for refname in refs {
368        validate_refname(refname)?;
369    }
370    validate_shas(exclusions)?;
371    let bundle_path = temp_path("rngit-local-push", "bundle");
372    let valid_exclusions = valid_local_objects_in(repo, exclusions);
373    let mut cmd = Command::new("git");
374    cmd.arg("-C")
375        .arg(repo)
376        .arg("bundle")
377        .arg("create")
378        .arg(&bundle_path)
379        .args(refs);
380    add_exclusions(&mut cmd, &valid_exclusions);
381    let result = run(&mut cmd);
382    let bytes = match result {
383        Ok(_) => fs::read(&bundle_path)?,
384        Err(err) if is_empty_bundle_error(&err) => Vec::new(),
385        Err(err) => {
386            let _ = fs::remove_file(&bundle_path);
387            return Err(err);
388        }
389    };
390    let _ = fs::remove_file(&bundle_path);
391    Ok(bytes)
392}
393
394pub fn object_exists_local(sha: &str) -> bool {
395    object_exists_local_in(Path::new("."), sha)
396}
397
398fn object_exists_local_in(repo: &Path, sha: &str) -> bool {
399    Command::new("git")
400        .arg("-C")
401        .arg(repo)
402        .arg("cat-file")
403        .arg("-e")
404        .arg(format!("{sha}^{{object}}"))
405        .output()
406        .map(|output| output.status.success())
407        .unwrap_or(false)
408}
409
410pub fn fetch_bundle_into_local(bundle: &[u8], wanted: &[String]) -> Result<()> {
411    if bundle.is_empty() {
412        return Ok(());
413    }
414    for refname in wanted {
415        validate_refname(refname)?;
416    }
417    let bundle_path = temp_path("rngit-local-fetch", "bundle");
418    fs::write(&bundle_path, bundle)?;
419    let mut cmd = Command::new("git");
420    cmd.arg("fetch").arg(&bundle_path);
421    if wanted.is_empty() {
422        cmd.arg("refs/*:refs/*");
423    } else {
424        cmd.args(wanted);
425    }
426    let result = run(&mut cmd);
427    let _ = fs::remove_file(&bundle_path);
428    result.map(|_| ())
429}
430
431fn update_ref(path: &Path, refname: &str, new: &str, old: Option<&str>, force: bool) -> Result<()> {
432    let mut cmd = Command::new("git");
433    cmd.arg("--git-dir")
434        .arg(path)
435        .arg("update-ref")
436        .arg(refname)
437        .arg(new);
438    if !force {
439        if let Some(old) = old {
440            cmd.arg(old);
441        }
442    }
443    run(&mut cmd).map(|_| ())
444}
445
446fn delete_ref(path: &Path, refname: &str, old: Option<&str>, force: bool) -> Result<()> {
447    let mut cmd = Command::new("git");
448    cmd.arg("--git-dir")
449        .arg(path)
450        .arg("update-ref")
451        .arg("-d")
452        .arg(refname);
453    if !force {
454        if let Some(old) = old {
455            cmd.arg(old);
456        }
457    }
458    run(&mut cmd).map(|_| ())
459}
460
461fn require_repository(path: &Path) -> Result<()> {
462    if is_bare_repository(path) {
463        Ok(())
464    } else {
465        Err(Error::msg("repository not found"))
466    }
467}
468
469fn valid_repo_objects(path: &Path, shas: &[String]) -> Vec<String> {
470    shas.iter()
471        .filter(|sha| object_exists_in_repo(path, sha))
472        .cloned()
473        .collect()
474}
475
476fn valid_local_objects_in(repo: &Path, shas: &[String]) -> Vec<String> {
477    shas.iter()
478        .filter(|sha| object_exists_local_in(repo, sha))
479        .cloned()
480        .collect()
481}
482
483fn object_exists_in_repo(path: &Path, sha: &str) -> bool {
484    Command::new("git")
485        .arg("--git-dir")
486        .arg(path)
487        .arg("cat-file")
488        .arg("-e")
489        .arg(format!("{sha}^{{object}}"))
490        .output()
491        .map(|output| output.status.success())
492        .unwrap_or(false)
493}
494
495fn is_valid_refname(refname: &str) -> bool {
496    if refname.is_empty()
497        || refname.starts_with('-')
498        || refname.starts_with('/')
499        || refname.ends_with('/')
500        || refname.ends_with('.')
501        || !refname.contains('/')
502        || refname.contains(' ')
503        || refname.contains("..")
504        || refname.contains("/.")
505        || refname.contains("//")
506        || refname.contains('\\')
507        || refname.contains('~')
508        || refname.contains('^')
509        || refname.contains(':')
510        || refname.contains('?')
511        || refname.contains('*')
512        || refname.contains('[')
513        || refname.contains("@{")
514        || refname == "@"
515    {
516        return false;
517    }
518
519    if refname
520        .split('/')
521        .any(|component| component.is_empty() || component.ends_with(".lock"))
522    {
523        return false;
524    }
525
526    refname.chars().all(|c| (c as u32) >= 40 && c != '\u{7f}')
527}
528
529fn is_valid_sha(sha: &str) -> bool {
530    sha.len() >= 40 && sha.len() % 2 == 0 && sha.bytes().all(|b| b.is_ascii_hexdigit())
531}
532
533fn add_exclusions(cmd: &mut Command, exclusions: &[String]) {
534    for sha in exclusions {
535        cmd.arg(format!("^{sha}"));
536    }
537}
538
539fn is_empty_bundle_error(err: &Error) -> bool {
540    let message = err.to_string();
541    message.contains("Refusing to create empty bundle")
542        || message.contains("does not have any commits")
543        || message.contains("Refusing to create empty")
544}
545
546fn temp_path(prefix: &str, extension: &str) -> PathBuf {
547    let now = SystemTime::now()
548        .duration_since(UNIX_EPOCH)
549        .unwrap_or_default()
550        .as_nanos();
551    std::env::temp_dir().join(format!("{prefix}-{}-{now}.{extension}", std::process::id()))
552}
553
554fn unix_timestamp() -> u64 {
555    SystemTime::now()
556        .duration_since(UNIX_EPOCH)
557        .unwrap_or_default()
558        .as_secs()
559}
560
561fn run(cmd: &mut Command) -> Result<String> {
562    let output = cmd.output()?;
563    if output.status.success() {
564        return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
565    }
566    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
567    Err(Error::msg(if stderr.is_empty() {
568        "git command failed".to_string()
569    } else {
570        stderr
571    }))
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    #[test]
579    fn bare_repo_can_list_refs() {
580        let tmp = tempfile::tempdir().unwrap();
581        let repo = tmp.path().join("repo.git");
582        ensure_bare_repository(&repo).unwrap();
583        assert!(list_refs(&repo).unwrap().is_empty());
584    }
585
586    #[test]
587    fn repository_paths_reject_traversal() {
588        assert!(repository_path(Path::new("/tmp/repos"), "../x").is_err());
589        assert!(repository_path(Path::new("/tmp/repos"), "group/repo").is_ok());
590    }
591
592    #[test]
593    fn ref_validation_rejects_noncanonical_names() {
594        assert!(validate_refname("refs/heads/main").is_ok());
595        assert!(validate_refname("refs/tags/v1").is_ok());
596
597        for name in [
598            "-refs/heads/main",
599            "/refs/heads/main",
600            "refs/heads/main/",
601            "refs/heads/main.",
602            "refs/heads/feature lock",
603            "main",
604            "refs/heads/../main",
605            "refs/heads/.hidden",
606            "refs/heads//main",
607            "refs\\heads\\main",
608            "refs/heads/main.lock",
609            "refs/heads/main~",
610            "refs/heads/main^",
611            "refs/heads/main:evil",
612            "refs/heads/main?",
613            "refs/heads/main*",
614            "refs/heads/main[",
615            "refs/heads/@{upstream}",
616            "@",
617            "refs/heads/\nmain",
618        ] {
619            assert!(
620                validate_refname(name).is_err(),
621                "{name:?} should be rejected"
622            );
623        }
624    }
625
626    #[test]
627    fn sha_validation_rejects_non_hex_or_short_values() {
628        assert!(validate_sha("0123456789abcdef0123456789abcdef01234567").is_ok());
629        assert!(
630            validate_sha("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
631                .is_ok()
632        );
633
634        for sha in [
635            "",
636            "0123456789abcdef0123456789abcdef0123456",
637            "0123456789abcdef0123456789abcdef0123456g",
638            "--upload-pack=/tmp/x",
639            "0123456789abcdef0123456789abcdef012345678",
640        ] {
641            assert!(validate_sha(sha).is_err(), "{sha:?} should be rejected");
642        }
643    }
644
645    #[test]
646    fn bundle_push_roundtrip_updates_bare_repository() {
647        let tmp = tempfile::tempdir().unwrap();
648        let work = tmp.path().join("work");
649        let target = tmp.path().join("target.git");
650        fs::create_dir_all(&work).unwrap();
651
652        test_git(Command::new("git").arg("init").arg(&work));
653        fs::write(work.join("README.md"), "hello\n").unwrap();
654        test_git(
655            Command::new("git")
656                .arg("-C")
657                .arg(&work)
658                .arg("add")
659                .arg("README.md"),
660        );
661        test_git(
662            Command::new("git")
663                .arg("-C")
664                .arg(&work)
665                .arg("-c")
666                .arg("user.name=RNS Test")
667                .arg("-c")
668                .arg("user.email=rns@example.invalid")
669                .arg("commit")
670                .arg("-m")
671                .arg("init"),
672        );
673        test_git(
674            Command::new("git")
675                .arg("-C")
676                .arg(&work)
677                .arg("branch")
678                .arg("-M")
679                .arg("main"),
680        );
681        let sha = test_git(
682            Command::new("git")
683                .arg("-C")
684                .arg(&work)
685                .arg("rev-parse")
686                .arg("refs/heads/main"),
687        );
688        let sha = sha.trim().to_string();
689        let bundle_path = tmp.path().join("push.bundle");
690        test_git(
691            Command::new("git")
692                .arg("-C")
693                .arg(&work)
694                .arg("bundle")
695                .arg("create")
696                .arg(&bundle_path)
697                .arg("refs/heads/main"),
698        );
699
700        apply_push(
701            &target,
702            &fs::read(&bundle_path).unwrap(),
703            &[RefUpdate {
704                refname: "refs/heads/main".into(),
705                old: None,
706                new: Some(sha.clone()),
707                force: true,
708            }],
709        )
710        .unwrap();
711
712        assert_eq!(
713            list_refs(&target).unwrap(),
714            vec![(sha.clone(), "refs/heads/main".into())]
715        );
716        assert!(!create_bundle(&target, &[]).unwrap().is_empty());
717        assert!(create_bundle(&target, &[sha]).unwrap().is_empty());
718    }
719
720    #[test]
721    fn local_push_bundle_excludes_known_remote_objects() {
722        let tmp = tempfile::tempdir().unwrap();
723        let work = tmp.path().join("work");
724        fs::create_dir_all(&work).unwrap();
725
726        test_git(Command::new("git").arg("init").arg(&work));
727        fs::write(work.join("README.md"), "hello\n").unwrap();
728        test_git(
729            Command::new("git")
730                .arg("-C")
731                .arg(&work)
732                .arg("add")
733                .arg("README.md"),
734        );
735        test_git(
736            Command::new("git")
737                .arg("-C")
738                .arg(&work)
739                .arg("-c")
740                .arg("user.name=RNS Test")
741                .arg("-c")
742                .arg("user.email=rns@example.invalid")
743                .arg("commit")
744                .arg("-m")
745                .arg("init"),
746        );
747        test_git(
748            Command::new("git")
749                .arg("-C")
750                .arg(&work)
751                .arg("branch")
752                .arg("-M")
753                .arg("main"),
754        );
755        let sha = test_git(
756            Command::new("git")
757                .arg("-C")
758                .arg(&work)
759                .arg("rev-parse")
760                .arg("refs/heads/main"),
761        )
762        .trim()
763        .to_string();
764        let full = create_local_bundle_in(&work, &["refs/heads/main".into()], &[]).unwrap();
765        let excluded = create_local_bundle_in(&work, &["refs/heads/main".into()], &[sha]).unwrap();
766        assert!(!full.is_empty());
767        assert!(excluded.is_empty());
768    }
769
770    fn test_git(cmd: &mut Command) -> String {
771        let output = cmd.output().unwrap();
772        assert!(
773            output.status.success(),
774            "git command failed: {}",
775            String::from_utf8_lossy(&output.stderr)
776        );
777        String::from_utf8_lossy(&output.stdout).into_owned()
778    }
779}