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}