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}