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, validate_upstream_source};
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 validate_upstream_source(source)?;
77 if path.exists() {
78 return Err(Error::msg("repository already exists"));
79 }
80 let parent = path
81 .parent()
82 .ok_or_else(|| Error::msg("repository path has no parent"))?;
83 fs::create_dir_all(parent)?;
84
85 let name = path
86 .file_name()
87 .and_then(|name| name.to_str())
88 .unwrap_or("repository");
89 let suffix = SystemTime::now()
90 .duration_since(UNIX_EPOCH)
91 .unwrap_or_default()
92 .as_nanos();
93 let temp = parent.join(format!(".{name}.clone-{suffix}"));
94
95 let result = (|| {
96 fs::create_dir_all(&temp)?;
97 run(Command::new("git").arg("init").arg("--bare").arg(&temp))?;
98 run(Command::new("git")
99 .arg("--git-dir")
100 .arg(&temp)
101 .arg("fetch")
102 .arg("--")
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("--")
117 .arg("repository.rngit.upstream.source")
118 .arg(source))?;
119 if matches!(repository_type, "fork" | "mirror") {
120 run(Command::new("git")
121 .arg("--git-dir")
122 .arg(&temp)
123 .arg("config")
124 .arg("repository.rngit.upstream.sync")
125 .arg(unix_timestamp().to_string()))?;
126 }
127 fs::rename(&temp, path)?;
128 Ok(())
129 })();
130
131 if result.is_err() {
132 let _ = fs::remove_dir_all(&temp);
133 }
134 result
135}
136
137pub fn update_head_to_source_default(repo_path: &Path, source: &str) -> Result<()> {
138 require_repository(repo_path)?;
139 let mut target_branch = source_default_head(source)?;
140 if let Some(branch) = target_branch.as_deref() {
141 let check = Command::new("git")
142 .arg("--git-dir")
143 .arg(repo_path)
144 .arg("show-ref")
145 .arg("--verify")
146 .arg("--quiet")
147 .arg("--")
148 .arg(branch)
149 .status()?;
150 if !check.success() {
151 target_branch = None;
152 }
153 }
154 let target_branch = match target_branch {
155 Some(branch) => branch,
156 None => first_local_branch(repo_path)?
157 .ok_or_else(|| Error::msg("repository has no local branch for HEAD"))?,
158 };
159 run(Command::new("git")
160 .arg("--git-dir")
161 .arg(repo_path)
162 .arg("symbolic-ref")
163 .arg("--")
164 .arg("HEAD")
165 .arg(target_branch))?;
166 Ok(())
167}
168
169fn source_default_head(source: &str) -> Result<Option<String>> {
170 validate_upstream_source(source)?;
172 let output = Command::new("git")
173 .arg("ls-remote")
174 .arg("--symref")
175 .arg("--")
176 .arg(source)
177 .arg("HEAD")
178 .output()?;
179 if !output.status.success() {
180 return Ok(None);
181 }
182 let stdout = String::from_utf8_lossy(&output.stdout);
183 for line in stdout.lines() {
184 let Some(rest) = line.strip_prefix("ref: ") else {
185 continue;
186 };
187 let Some((target, name)) = rest.split_once('\t') else {
188 continue;
189 };
190 if name == "HEAD" && target.starts_with("refs/heads/") {
191 return Ok(Some(target.to_string()));
192 }
193 }
194 Ok(None)
195}
196
197fn first_local_branch(repo_path: &Path) -> Result<Option<String>> {
198 let output = run(Command::new("git")
199 .arg("--git-dir")
200 .arg(repo_path)
201 .arg("for-each-ref")
202 .arg("--format=%(refname)")
203 .arg("--sort=refname")
204 .arg("refs/heads"))?;
205 Ok(output
206 .lines()
207 .find(|line| line.starts_with("refs/heads/"))
208 .map(ToOwned::to_owned))
209}
210
211pub fn repository_config(path: &Path, key: &str) -> Result<Option<String>> {
212 require_repository(path)?;
213 let output = Command::new("git")
214 .arg("--git-dir")
215 .arg(path)
216 .arg("config")
217 .arg("--get")
218 .arg(key)
219 .output()?;
220 if !output.status.success() {
221 return Ok(None);
222 }
223 Ok(Some(
224 String::from_utf8_lossy(&output.stdout).trim().to_string(),
225 ))
226}
227
228pub fn sync_upstream(path: &Path) -> Result<String> {
229 require_repository(path)?;
230 let repository_type = repository_config(path, "repository.rngit.type")?
231 .ok_or_else(|| Error::msg("repository is neither fork nor mirror"))?;
232 if !matches!(repository_type.as_str(), "fork" | "mirror") {
233 return Err(Error::msg("repository is neither fork nor mirror"));
234 }
235 let source = repository_config(path, "repository.rngit.upstream.source")?
236 .filter(|source| !source.is_empty())
237 .ok_or_else(|| Error::msg("missing upstream source"))?;
238 validate_upstream_source(&source)?;
240 run(Command::new("git")
241 .arg("--git-dir")
242 .arg(path)
243 .arg("fetch")
244 .arg("--")
245 .arg(&source)
246 .arg("+refs/*:refs/*"))?;
247 if repository_type == "mirror" {
248 update_head_to_source_default(path, &source)?;
249 }
250 if matches!(repository_type.as_str(), "fork" | "mirror") {
251 run(Command::new("git")
252 .arg("--git-dir")
253 .arg(path)
254 .arg("config")
255 .arg("repository.rngit.upstream.sync")
256 .arg(unix_timestamp().to_string()))?;
257 }
258 Ok(repository_type)
259}
260
261pub fn is_bare_repository(path: &Path) -> bool {
262 path.join("HEAD").exists() && path.join("objects").is_dir()
263}
264
265pub fn list_refs(path: &Path) -> Result<Vec<(String, String)>> {
266 require_repository(path)?;
267 let output = run(Command::new("git")
268 .arg("--git-dir")
269 .arg(path)
270 .arg("for-each-ref")
271 .arg("--format=%(objectname) %(refname)"))?;
272 Ok(output
273 .lines()
274 .filter_map(|line| {
275 let (sha, name) = line.split_once(' ')?;
276 Some((sha.to_string(), name.to_string()))
277 })
278 .collect())
279}
280
281pub fn list_refs_text(path: &Path) -> Result<Vec<u8>> {
282 let mut out = Vec::new();
283 for (sha, name) in list_refs(path)? {
284 out.extend_from_slice(sha.as_bytes());
285 out.push(b' ');
286 out.extend_from_slice(name.as_bytes());
287 out.push(b'\n');
288 }
289 Ok(out)
290}
291
292pub fn create_bundle(path: &Path, have: &[String]) -> Result<Vec<u8>> {
293 create_bundle_for_refs(path, have, &[])
294}
295
296pub fn create_bundle_for_refs(path: &Path, have: &[String], refs: &[String]) -> Result<Vec<u8>> {
297 require_repository(path)?;
298 validate_shas(have)?;
299 for refname in refs {
300 validate_namespaced_ref(refname)?;
301 }
302 if list_refs(path)?.is_empty() {
303 return Ok(Vec::new());
304 }
305 let bundle_path = temp_path("rngit-fetch", "bundle");
306 let exclusions = valid_repo_objects(path, have);
307 let mut cmd = Command::new("git");
308 cmd.arg("--git-dir")
309 .arg(path)
310 .arg("bundle")
311 .arg("create")
312 .arg(&bundle_path);
313 if refs.is_empty() {
314 cmd.arg("--all");
315 } else {
316 cmd.args(refs);
317 }
318 add_exclusions(&mut cmd, &exclusions);
319 let result = run(&mut cmd);
320 let bytes = match result {
321 Ok(_) => fs::read(&bundle_path)?,
322 Err(err) if is_empty_bundle_error(&err) => Vec::new(),
323 Err(err) => {
324 let _ = fs::remove_file(&bundle_path);
325 return Err(err);
326 }
327 };
328 let _ = fs::remove_file(&bundle_path);
329 Ok(bytes)
330}
331
332pub fn apply_push(path: &Path, bundle: &[u8], updates: &[RefUpdate]) -> Result<()> {
333 validate_ref_updates(updates)?;
334 ensure_bare_repository(path)?;
335 if !bundle.is_empty() {
336 let bundle_path = temp_path("rngit-push", "bundle");
337 fs::write(&bundle_path, bundle)?;
338 let result = run(Command::new("git")
339 .arg("--git-dir")
340 .arg(path)
341 .arg("fetch")
342 .arg("--")
343 .arg(&bundle_path)
344 .arg("+refs/heads/*:refs/heads/*")
345 .arg("+refs/tags/*:refs/tags/*"));
346 let _ = fs::remove_file(&bundle_path);
347 result?;
348 }
349
350 for update in updates {
351 if let Some(new) = update.new.as_deref() {
352 update_ref(
353 path,
354 &update.refname,
355 new,
356 update.old.as_deref(),
357 update.force,
358 )?;
359 } else {
360 delete_ref(path, &update.refname, update.old.as_deref(), update.force)?;
361 }
362 }
363 Ok(())
364}
365
366pub fn local_ref_sha(refname: &str) -> Result<Option<String>> {
367 if refname.is_empty() || refname.starts_with('-') {
369 return Ok(None);
370 }
371 let output = Command::new("git")
372 .arg("rev-parse")
373 .arg("--verify")
374 .arg(refname)
375 .output()?;
376 if !output.status.success() {
377 return Ok(None);
378 }
379 Ok(Some(
380 String::from_utf8_lossy(&output.stdout).trim().to_string(),
381 ))
382}
383
384pub fn create_local_bundle(refs: &[String], exclusions: &[String]) -> Result<Vec<u8>> {
385 create_local_bundle_in(Path::new("."), refs, exclusions)
386}
387
388fn create_local_bundle_in(repo: &Path, refs: &[String], exclusions: &[String]) -> Result<Vec<u8>> {
389 if refs.is_empty() {
390 return Ok(Vec::new());
391 }
392 for refname in refs {
393 validate_refname(refname)?;
394 }
395 validate_shas(exclusions)?;
396 let bundle_path = temp_path("rngit-local-push", "bundle");
397 let valid_exclusions = valid_local_objects_in(repo, exclusions);
398 let mut cmd = Command::new("git");
399 cmd.arg("-C")
400 .arg(repo)
401 .arg("bundle")
402 .arg("create")
403 .arg(&bundle_path)
404 .args(refs);
405 add_exclusions(&mut cmd, &valid_exclusions);
406 let result = run(&mut cmd);
407 let bytes = match result {
408 Ok(_) => fs::read(&bundle_path)?,
409 Err(err) if is_empty_bundle_error(&err) => Vec::new(),
410 Err(err) => {
411 let _ = fs::remove_file(&bundle_path);
412 return Err(err);
413 }
414 };
415 let _ = fs::remove_file(&bundle_path);
416 Ok(bytes)
417}
418
419pub fn object_exists_local(sha: &str) -> bool {
420 object_exists_local_in(Path::new("."), sha)
421}
422
423fn object_exists_local_in(repo: &Path, sha: &str) -> bool {
424 if !is_valid_sha(sha) {
425 return false;
426 }
427 Command::new("git")
428 .arg("-C")
429 .arg(repo)
430 .arg("cat-file")
431 .arg("-e")
432 .arg(format!("{sha}^{{object}}"))
433 .output()
434 .map(|output| output.status.success())
435 .unwrap_or(false)
436}
437
438pub fn fetch_bundle_into_local(bundle: &[u8], wanted: &[String]) -> Result<()> {
439 if bundle.is_empty() {
440 return Ok(());
441 }
442 for refname in wanted {
443 validate_refname(refname)?;
444 }
445 let bundle_path = temp_path("rngit-local-fetch", "bundle");
446 fs::write(&bundle_path, bundle)?;
447 let mut cmd = Command::new("git");
448 cmd.arg("fetch").arg("--").arg(&bundle_path);
449 if wanted.is_empty() {
450 cmd.arg("refs/*:refs/*");
451 } else {
452 cmd.args(wanted);
453 }
454 let result = run(&mut cmd);
455 let _ = fs::remove_file(&bundle_path);
456 result.map(|_| ())
457}
458
459fn update_ref(path: &Path, refname: &str, new: &str, old: Option<&str>, force: bool) -> Result<()> {
460 let mut cmd = Command::new("git");
461 cmd.arg("--git-dir")
462 .arg(path)
463 .arg("update-ref")
464 .arg("--")
465 .arg(refname)
466 .arg(new);
467 if !force {
468 if let Some(old) = old {
469 cmd.arg(old);
470 }
471 }
472 run(&mut cmd).map(|_| ())
473}
474
475fn delete_ref(path: &Path, refname: &str, old: Option<&str>, force: bool) -> Result<()> {
476 let mut cmd = Command::new("git");
477 cmd.arg("--git-dir")
478 .arg(path)
479 .arg("update-ref")
480 .arg("-d")
481 .arg("--")
482 .arg(refname);
483 if !force {
484 if let Some(old) = old {
485 cmd.arg(old);
486 }
487 }
488 run(&mut cmd).map(|_| ())
489}
490
491fn require_repository(path: &Path) -> Result<()> {
492 if is_bare_repository(path) {
493 Ok(())
494 } else {
495 Err(Error::msg("repository not found"))
496 }
497}
498
499fn valid_repo_objects(path: &Path, shas: &[String]) -> Vec<String> {
500 shas.iter()
501 .filter(|sha| object_exists_in_repo(path, sha))
502 .cloned()
503 .collect()
504}
505
506fn valid_local_objects_in(repo: &Path, shas: &[String]) -> Vec<String> {
507 shas.iter()
508 .filter(|sha| object_exists_local_in(repo, sha))
509 .cloned()
510 .collect()
511}
512
513fn object_exists_in_repo(path: &Path, sha: &str) -> bool {
514 if !is_valid_sha(sha) {
515 return false;
516 }
517 Command::new("git")
518 .arg("--git-dir")
519 .arg(path)
520 .arg("cat-file")
521 .arg("-e")
522 .arg(format!("{sha}^{{object}}"))
523 .output()
524 .map(|output| output.status.success())
525 .unwrap_or(false)
526}
527
528fn is_valid_refname(refname: &str) -> bool {
529 if refname.is_empty()
530 || refname.starts_with('-')
531 || refname.starts_with('/')
532 || refname.ends_with('/')
533 || refname.ends_with('.')
534 || !refname.contains('/')
535 || refname.contains(' ')
536 || refname.contains("..")
537 || refname.contains("/.")
538 || refname.contains("//")
539 || refname.contains('\\')
540 || refname.contains('~')
541 || refname.contains('^')
542 || refname.contains(':')
543 || refname.contains('?')
544 || refname.contains('*')
545 || refname.contains('[')
546 || refname.contains("@{")
547 || refname == "@"
548 {
549 return false;
550 }
551
552 if refname
553 .split('/')
554 .any(|component| component.is_empty() || component.ends_with(".lock"))
555 {
556 return false;
557 }
558
559 refname.chars().all(|c| (c as u32) >= 40 && c != '\u{7f}')
560}
561
562fn is_valid_sha(sha: &str) -> bool {
563 sha.len() >= 40 && sha.len().is_multiple_of(2) && sha.bytes().all(|b| b.is_ascii_hexdigit())
564}
565
566fn add_exclusions(cmd: &mut Command, exclusions: &[String]) {
567 for sha in exclusions {
568 cmd.arg(format!("^{sha}"));
569 }
570}
571
572fn is_empty_bundle_error(err: &Error) -> bool {
573 let message = err.to_string();
574 message.contains("Refusing to create empty bundle")
575 || message.contains("does not have any commits")
576 || message.contains("Refusing to create empty")
577}
578
579fn temp_path(prefix: &str, extension: &str) -> PathBuf {
580 let now = SystemTime::now()
581 .duration_since(UNIX_EPOCH)
582 .unwrap_or_default()
583 .as_nanos();
584 std::env::temp_dir().join(format!("{prefix}-{}-{now}.{extension}", std::process::id()))
585}
586
587fn unix_timestamp() -> u64 {
588 SystemTime::now()
589 .duration_since(UNIX_EPOCH)
590 .unwrap_or_default()
591 .as_secs()
592}
593
594fn run(cmd: &mut Command) -> Result<String> {
595 let output = cmd.output()?;
596 if output.status.success() {
597 return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
598 }
599 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
600 Err(Error::msg(if stderr.is_empty() {
601 "git command failed".to_string()
602 } else {
603 stderr
604 }))
605}
606
607#[cfg(test)]
608pub(crate) mod test_support {
609 use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
610 use std::path::Path;
611 use std::process::{Child, Command, Stdio};
612 use std::time::{Duration, Instant};
613
614 pub(crate) struct GitDaemon {
617 child: Child,
618 port: u16,
619 }
620
621 impl GitDaemon {
622 pub(crate) fn serve(base: &Path) -> Self {
623 let mut last_error = String::new();
624 for _ in 0..5 {
625 let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
626 .and_then(|listener| listener.local_addr())
627 .map(|addr| addr.port())
628 .expect("reserve local port");
629 let mut child = Command::new("git")
630 .arg("daemon")
631 .arg("--reuseaddr")
632 .arg("--export-all")
633 .arg("--listen=127.0.0.1")
634 .arg(format!("--port={port}"))
635 .arg(format!("--base-path={}", base.display()))
636 .arg(base)
637 .stdin(Stdio::null())
638 .stdout(Stdio::null())
639 .stderr(Stdio::piped())
640 .spawn()
641 .expect("start git daemon");
642 let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
643 let deadline = Instant::now() + Duration::from_secs(10);
644 while Instant::now() < deadline {
645 if let Ok(Some(status)) = child.try_wait() {
646 last_error = format!("git daemon exited with {status}");
647 break;
648 }
649 if TcpStream::connect_timeout(&addr, Duration::from_millis(100)).is_ok() {
650 return Self { child, port };
651 }
652 std::thread::sleep(Duration::from_millis(20));
653 }
654 let _ = child.kill();
655 let _ = child.wait();
656 }
657 panic!("git daemon did not start: {last_error}");
658 }
659
660 pub(crate) fn url(&self, name: &str) -> String {
661 format!("git://127.0.0.1:{}/{name}", self.port)
662 }
663 }
664
665 impl Drop for GitDaemon {
666 fn drop(&mut self) {
667 let _ = self.child.kill();
668 let _ = self.child.wait();
669 }
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676
677 #[test]
678 fn bare_repo_can_list_refs() {
679 let tmp = tempfile::tempdir().unwrap();
680 let repo = tmp.path().join("repo.git");
681 ensure_bare_repository(&repo).unwrap();
682 assert!(list_refs(&repo).unwrap().is_empty());
683 }
684
685 #[test]
686 fn repository_paths_reject_traversal() {
687 assert!(repository_path(Path::new("/tmp/repos"), "../x").is_err());
688 assert!(repository_path(Path::new("/tmp/repos"), "group/repo").is_ok());
689 }
690
691 #[test]
692 fn ref_validation_rejects_noncanonical_names() {
693 assert!(validate_refname("refs/heads/main").is_ok());
694 assert!(validate_refname("refs/tags/v1").is_ok());
695
696 for name in [
697 "-refs/heads/main",
698 "/refs/heads/main",
699 "refs/heads/main/",
700 "refs/heads/main.",
701 "refs/heads/feature lock",
702 "main",
703 "refs/heads/../main",
704 "refs/heads/.hidden",
705 "refs/heads//main",
706 "refs\\heads\\main",
707 "refs/heads/main.lock",
708 "refs/heads/main~",
709 "refs/heads/main^",
710 "refs/heads/main:evil",
711 "refs/heads/main?",
712 "refs/heads/main*",
713 "refs/heads/main[",
714 "refs/heads/@{upstream}",
715 "@",
716 "refs/heads/\nmain",
717 ] {
718 assert!(
719 validate_refname(name).is_err(),
720 "{name:?} should be rejected"
721 );
722 }
723 }
724
725 #[test]
726 fn sha_validation_rejects_non_hex_or_short_values() {
727 assert!(validate_sha("0123456789abcdef0123456789abcdef01234567").is_ok());
728 assert!(
729 validate_sha("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
730 .is_ok()
731 );
732
733 for sha in [
734 "",
735 "0123456789abcdef0123456789abcdef0123456",
736 "0123456789abcdef0123456789abcdef0123456g",
737 "--upload-pack=/tmp/x",
738 "0123456789abcdef0123456789abcdef012345678",
739 ] {
740 assert!(validate_sha(sha).is_err(), "{sha:?} should be rejected");
741 }
742 }
743
744 #[test]
745 fn bundle_push_roundtrip_updates_bare_repository() {
746 let tmp = tempfile::tempdir().unwrap();
747 let work = tmp.path().join("work");
748 let target = tmp.path().join("target.git");
749 fs::create_dir_all(&work).unwrap();
750
751 test_git(Command::new("git").arg("init").arg(&work));
752 fs::write(work.join("README.md"), "hello\n").unwrap();
753 test_git(
754 Command::new("git")
755 .arg("-C")
756 .arg(&work)
757 .arg("add")
758 .arg("README.md"),
759 );
760 test_git(
761 Command::new("git")
762 .arg("-C")
763 .arg(&work)
764 .arg("-c")
765 .arg("user.name=RNS Test")
766 .arg("-c")
767 .arg("user.email=rns@example.invalid")
768 .arg("commit")
769 .arg("-m")
770 .arg("init"),
771 );
772 test_git(
773 Command::new("git")
774 .arg("-C")
775 .arg(&work)
776 .arg("branch")
777 .arg("-M")
778 .arg("main"),
779 );
780 let sha = test_git(
781 Command::new("git")
782 .arg("-C")
783 .arg(&work)
784 .arg("rev-parse")
785 .arg("refs/heads/main"),
786 );
787 let sha = sha.trim().to_string();
788 let bundle_path = tmp.path().join("push.bundle");
789 test_git(
790 Command::new("git")
791 .arg("-C")
792 .arg(&work)
793 .arg("bundle")
794 .arg("create")
795 .arg(&bundle_path)
796 .arg("refs/heads/main"),
797 );
798
799 apply_push(
800 &target,
801 &fs::read(&bundle_path).unwrap(),
802 &[RefUpdate {
803 refname: "refs/heads/main".into(),
804 old: None,
805 new: Some(sha.clone()),
806 force: true,
807 }],
808 )
809 .unwrap();
810
811 assert_eq!(
812 list_refs(&target).unwrap(),
813 vec![(sha.clone(), "refs/heads/main".into())]
814 );
815 assert!(!create_bundle(&target, &[]).unwrap().is_empty());
816 assert!(create_bundle(&target, &[sha]).unwrap().is_empty());
817 }
818
819 #[test]
820 fn fetch_bundle_can_scope_to_requested_refs() {
821 let tmp = tempfile::tempdir().unwrap();
822 let work = tmp.path().join("work");
823 let target = tmp.path().join("target.git");
824 fs::create_dir_all(&work).unwrap();
825
826 test_git(Command::new("git").arg("init").arg(&work));
827 fs::write(work.join("README.md"), "main\n").unwrap();
828 test_git(
829 Command::new("git")
830 .arg("-C")
831 .arg(&work)
832 .arg("add")
833 .arg("README.md"),
834 );
835 test_git(
836 Command::new("git")
837 .arg("-C")
838 .arg(&work)
839 .arg("-c")
840 .arg("user.name=RNS Test")
841 .arg("-c")
842 .arg("user.email=rns@example.invalid")
843 .arg("commit")
844 .arg("-m")
845 .arg("main"),
846 );
847 test_git(
848 Command::new("git")
849 .arg("-C")
850 .arg(&work)
851 .arg("branch")
852 .arg("-M")
853 .arg("main"),
854 );
855 test_git(
856 Command::new("git")
857 .arg("-C")
858 .arg(&work)
859 .arg("checkout")
860 .arg("-b")
861 .arg("side"),
862 );
863 fs::write(work.join("SIDE.md"), "side\n").unwrap();
864 test_git(
865 Command::new("git")
866 .arg("-C")
867 .arg(&work)
868 .arg("add")
869 .arg("SIDE.md"),
870 );
871 test_git(
872 Command::new("git")
873 .arg("-C")
874 .arg(&work)
875 .arg("-c")
876 .arg("user.name=RNS Test")
877 .arg("-c")
878 .arg("user.email=rns@example.invalid")
879 .arg("commit")
880 .arg("-m")
881 .arg("side"),
882 );
883 ensure_bare_repository(&target).unwrap();
884 test_git(
885 Command::new("git")
886 .arg("--git-dir")
887 .arg(&target)
888 .arg("fetch")
889 .arg(&work)
890 .arg("+refs/heads/*:refs/heads/*"),
891 );
892
893 let bundle = create_bundle_for_refs(&target, &[], &["refs/heads/main".into()]).unwrap();
894 let bundle_path = tmp.path().join("fetch.bundle");
895 fs::write(&bundle_path, bundle).unwrap();
896 let heads = test_git(
897 Command::new("git")
898 .arg("bundle")
899 .arg("list-heads")
900 .arg(&bundle_path),
901 );
902
903 assert!(heads.contains("refs/heads/main"));
904 assert!(!heads.contains("refs/heads/side"));
905 }
906
907 #[test]
908 fn local_push_bundle_excludes_known_remote_objects() {
909 let tmp = tempfile::tempdir().unwrap();
910 let work = tmp.path().join("work");
911 fs::create_dir_all(&work).unwrap();
912
913 test_git(Command::new("git").arg("init").arg(&work));
914 fs::write(work.join("README.md"), "hello\n").unwrap();
915 test_git(
916 Command::new("git")
917 .arg("-C")
918 .arg(&work)
919 .arg("add")
920 .arg("README.md"),
921 );
922 test_git(
923 Command::new("git")
924 .arg("-C")
925 .arg(&work)
926 .arg("-c")
927 .arg("user.name=RNS Test")
928 .arg("-c")
929 .arg("user.email=rns@example.invalid")
930 .arg("commit")
931 .arg("-m")
932 .arg("init"),
933 );
934 test_git(
935 Command::new("git")
936 .arg("-C")
937 .arg(&work)
938 .arg("branch")
939 .arg("-M")
940 .arg("main"),
941 );
942 let sha = test_git(
943 Command::new("git")
944 .arg("-C")
945 .arg(&work)
946 .arg("rev-parse")
947 .arg("refs/heads/main"),
948 )
949 .trim()
950 .to_string();
951 let full = create_local_bundle_in(&work, &["refs/heads/main".into()], &[]).unwrap();
952 let excluded = create_local_bundle_in(&work, &["refs/heads/main".into()], &[sha]).unwrap();
953 assert!(!full.is_empty());
954 assert!(excluded.is_empty());
955 }
956
957 fn commit_in_new_work_tree(work: &Path, branch: &str) -> String {
958 fs::create_dir_all(work).unwrap();
959 test_git(
960 Command::new("git")
961 .arg("init")
962 .arg("-b")
963 .arg(branch)
964 .arg(work),
965 );
966 fs::write(work.join("README.md"), "hello\n").unwrap();
967 test_git(
968 Command::new("git")
969 .arg("-C")
970 .arg(work)
971 .args(["add", "README.md"]),
972 );
973 test_git(Command::new("git").arg("-C").arg(work).args([
974 "-c",
975 "user.name=RNS Test",
976 "-c",
977 "user.email=rns@example.invalid",
978 "commit",
979 "-m",
980 "init",
981 ]));
982 test_git(
983 Command::new("git")
984 .arg("-C")
985 .arg(work)
986 .args(["rev-parse", "HEAD"]),
987 )
988 .trim()
989 .to_string()
990 }
991
992 #[test]
993 fn clone_remote_bare_fetches_from_git_url_and_follows_source_head() {
994 let tmp = tempfile::tempdir().unwrap();
995 let served = tmp.path().join("served");
996 let sha = commit_in_new_work_tree(&served.join("project"), "trunk");
997 let daemon = test_support::GitDaemon::serve(&served);
998 let source = daemon.url("project");
999 let target = tmp.path().join("repos/group/mirror");
1000
1001 clone_remote_bare(&source, &target, "mirror").unwrap();
1002
1003 assert!(is_bare_repository(&target));
1004 assert_eq!(
1005 list_refs(&target).unwrap(),
1006 vec![(sha, "refs/heads/trunk".to_string())]
1007 );
1008 assert_eq!(
1009 test_git(
1010 Command::new("git")
1011 .arg("--git-dir")
1012 .arg(&target)
1013 .args(["symbolic-ref", "HEAD"])
1014 )
1015 .trim(),
1016 "refs/heads/trunk"
1017 );
1018 assert_eq!(
1019 repository_config(&target, "repository.rngit.upstream.source")
1020 .unwrap()
1021 .as_deref(),
1022 Some(source.as_str())
1023 );
1024 assert_eq!(sync_upstream(&target).unwrap(), "mirror");
1025 }
1026
1027 #[test]
1028 fn clone_remote_bare_rejects_sources_outside_the_allow_list() {
1029 let tmp = tempfile::tempdir().unwrap();
1030 let source_dir = tmp.path().join("source");
1031 commit_in_new_work_tree(&source_dir, "main");
1032 let local_path = source_dir.to_string_lossy().into_owned();
1033 let file_url = format!("file://{local_path}");
1034 for source in [
1035 local_path.as_str(),
1036 file_url.as_str(),
1037 "--option=value",
1038 "-c",
1039 "",
1040 ] {
1041 let target = tmp.path().join("repos/group/rejected");
1042 assert!(
1043 clone_remote_bare(source, &target, "fork").is_err(),
1044 "{source:?} should be rejected"
1045 );
1046 assert!(!target.exists());
1047 }
1048 }
1049
1050 #[test]
1051 fn sync_upstream_rejects_stored_source_outside_the_allow_list() {
1052 let tmp = tempfile::tempdir().unwrap();
1053 let repo = tmp.path().join("repo.git");
1054 ensure_bare_repository(&repo).unwrap();
1055 for (key, value) in [
1056 ("repository.rngit.type", "fork"),
1057 ("repository.rngit.upstream.source", "--option=value"),
1058 ] {
1059 test_git(
1060 Command::new("git")
1061 .arg("--git-dir")
1062 .arg(&repo)
1063 .args(["config", "--", key, value]),
1064 );
1065 }
1066
1067 assert!(sync_upstream(&repo).is_err());
1068 }
1069
1070 #[test]
1071 fn local_ref_lookup_ignores_option_like_names() {
1072 assert_eq!(local_ref_sha("--all").unwrap(), None);
1073 assert_eq!(local_ref_sha("").unwrap(), None);
1074 assert!(!object_exists_local("--all"));
1075 assert!(!object_exists_in_repo(Path::new("."), "--all"));
1076 }
1077
1078 fn test_git(cmd: &mut Command) -> String {
1079 let output = cmd.output().unwrap();
1080 assert!(
1081 output.status.success(),
1082 "git command failed: {}",
1083 String::from_utf8_lossy(&output.stderr)
1084 );
1085 String::from_utf8_lossy(&output.stdout).into_owned()
1086 }
1087}