1use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6pub fn validate_github_name(repo: &str) -> anyhow::Result<(&str, &str)> {
8 let (owner, name) = repo.split_once('/').unwrap_or(("", ""));
9 anyhow::ensure!(
10 !owner.is_empty()
11 && owner.len() <= 39
12 && owner
13 .bytes()
14 .all(|c| c.is_ascii_alphanumeric() || c == b'-')
15 && !owner.starts_with('-')
16 && !owner.ends_with('-')
17 && !owner.contains("--")
18 && !name.is_empty()
19 && name.len() <= 100
20 && name
21 .bytes()
22 .all(|c| c.is_ascii_alphanumeric() || b"-_.".contains(&c))
23 && name != "."
24 && name != "..",
25 "invalid GitHub owner/repo name: {repo:?}"
26 );
27 Ok((owner, name))
28}
29
30pub struct CloneLock(std::fs::File);
32
33impl Drop for CloneLock {
34 fn drop(&mut self) {
35 #[cfg(unix)]
36 {
37 use std::os::fd::AsRawFd;
38 unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) };
40 }
41 let _ = &self.0;
42 }
43}
44
45pub async fn clone_lock(cfg: &crate::config::Repos) -> anyhow::Result<CloneLock> {
47 use anyhow::Context;
48 let root = cfg
49 .roots
50 .first()
51 .context("[repos] auto_clone requires a nonempty roots array")?;
52 std::fs::create_dir_all(root)?;
53 let file = std::fs::OpenOptions::new()
54 .create(true)
55 .truncate(false)
56 .read(true)
57 .write(true)
58 .open(root.join(".magi-clone.lock"))?;
59 loop {
60 match crate::pr_head::try_lock_file(&file) {
61 Ok(()) => return Ok(CloneLock(file)),
62 Err(e)
63 if e.kind() == std::io::ErrorKind::WouldBlock
64 || cfg!(windows) && e.raw_os_error() == Some(33) =>
65 {
66 tokio::time::sleep(Duration::from_millis(50)).await;
67 }
68 Err(e) => return Err(e.into()),
69 }
70 }
71}
72
73fn incomplete_marker(checkout: &Path) -> PathBuf {
76 let mut name = std::ffi::OsString::from(".magi-clone@");
77 name.push(checkout.file_name().unwrap_or_default());
78 checkout.with_file_name(name)
79}
80
81pub fn acquisition_incomplete(checkout: &Path) -> bool {
84 !matches!(std::fs::symlink_metadata(incomplete_marker(checkout)),
85 Err(e) if e.kind() == std::io::ErrorKind::NotFound)
86}
87
88pub async fn clone_github_with_gh(
92 repo: &str,
93 cfg: &crate::config::Repos,
94 gh: &str,
95) -> anyhow::Result<PathBuf> {
96 use anyhow::{Context, ensure};
97 let (owner, name) = validate_github_name(repo)?;
98 let root = cfg
99 .roots
100 .first()
101 .context("[repos] auto_clone requires a nonempty roots array")?;
102 let dest = root.join("github.com").join(owner).join(name);
103 match std::fs::symlink_metadata(&dest) {
104 Ok(_) => anyhow::bail!("clone destination already exists: {}", dest.display()),
105 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
106 Err(e) => {
107 return Err(e).with_context(|| format!("inspect clone destination {}", dest.display()));
108 }
109 }
110 let view = vec![
111 gh.to_owned(),
112 "repo".into(),
113 "view".into(),
114 format!("https://github.com/{repo}"),
115 "--json".into(),
116 "nameWithOwner".into(),
117 ];
118 let output = acquisition_command(&view).await?;
119 let value: serde_json::Value = serde_json::from_slice(&output).with_context(|| {
120 format!(
121 "command {view:?} returned invalid JSON: {}",
122 String::from_utf8_lossy(&output)
123 )
124 })?;
125 let confirmed = value["nameWithOwner"].as_str().with_context(|| {
126 format!(
127 "command {view:?} omitted nameWithOwner: {}",
128 String::from_utf8_lossy(&output)
129 )
130 })?;
131 validate_github_name(confirmed).with_context(|| {
132 format!(
133 "command {view:?} returned invalid name: {}",
134 String::from_utf8_lossy(&output)
135 )
136 })?;
137 ensure!(
138 confirmed.eq_ignore_ascii_case(repo),
139 "command {view:?} resolved to {confirmed:?}, refusing a different repository"
140 );
141 let default = vec![
142 gh.to_owned(),
143 "repo".into(),
144 "clone".into(),
145 "https://github.com/{repo}.git".into(),
146 "{dest}".into(),
147 ];
148 let template = cfg.clone_command.as_ref().unwrap_or(&default);
149 ensure!(
150 !template.is_empty() && !template[0].is_empty(),
151 "[repos] clone_command must contain an executable"
152 );
153 let argv: Vec<String> = template
156 .iter()
157 .map(|arg| {
158 arg.replace("{repo}", repo)
159 .replace("{owner}", owner)
160 .replace("{name}", name)
161 .replace("{dest}", &dest.to_string_lossy())
162 })
163 .collect();
164 std::fs::create_dir_all(dest.parent().context("clone destination has no parent")?)?;
165 let marker = incomplete_marker(&dest);
166 std::fs::OpenOptions::new().write(true).create_new(true).open(&marker)
167 .with_context(|| format!("cannot mark acquisition at {}; inspect any previous failed acquisition and its marker {}", dest.display(), marker.display()))?;
168 let acquired: anyhow::Result<PathBuf> = async {
171 acquisition_command(&argv).await?;
172 let canonical = dest
173 .canonicalize()
174 .with_context(|| format!("command {argv:?} did not create {}", dest.display()))?;
175 let top = crate::git::toplevel(&canonical).await.with_context(|| {
176 format!(
177 "command {argv:?} did not create a checkout at {}",
178 dest.display()
179 )
180 })?;
181 ensure!(
182 top.canonicalize()? == canonical && canonical.join(".git").exists(),
183 "command {argv:?} did not create a checkout at {}",
184 dest.display()
185 );
186 Ok(canonical)
187 }
188 .await;
189 let canonical = acquired.map_err(|error| {
190 if matches!(std::fs::symlink_metadata(&dest),
191 Err(e) if e.kind() == std::io::ErrorKind::NotFound)
192 {
193 if let Err(cleanup) = std::fs::remove_file(&marker) {
194 return error.context(format!(
195 "remove acquisition marker {} after failure: {cleanup}",
196 marker.display()
197 ));
198 }
199 }
200 error
201 })?;
202 std::fs::remove_file(&marker).with_context(|| {
203 format!(
204 "publish completed acquisition by removing {}",
205 marker.display()
206 )
207 })?;
208 Ok(canonical)
209}
210
211async fn acquisition_command(argv: &[String]) -> anyhow::Result<Vec<u8>> {
212 use anyhow::{Context, ensure};
213 let output = tokio::process::Command::new(&argv[0])
214 .args(&argv[1..])
215 .output()
216 .await
217 .with_context(|| format!("could not execute command {argv:?}"))?;
218 ensure!(
219 output.status.success(),
220 "command {argv:?} failed ({}):\nstdout: {}\nstderr: {}",
221 output.status,
222 String::from_utf8_lossy(&output.stdout),
223 String::from_utf8_lossy(&output.stderr)
224 );
225 Ok(output.stdout)
226}
227
228#[cfg(all(test, unix))]
229mod acquisition_tests {
230 use super::*;
231 use std::os::unix::fs::PermissionsExt;
232 use std::path::Path;
233
234 fn script(dir: &Path, name: &str, body: &str) -> String {
235 let path = dir.join(name);
236 let status = std::process::Command::new("sh")
239 .args([
240 "-c",
241 "printf '%s\\n' '#!/bin/sh' \"$2\" > \"$1\"",
242 "fixture",
243 ])
244 .arg(&path)
245 .arg(body)
246 .status()
247 .unwrap();
248 assert!(status.success());
249 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
250 path.to_string_lossy().into_owned()
251 }
252
253 #[test]
254 fn github_names_refuse_traversal_options_and_invalid_characters() {
255 for name in [
256 "bare",
257 "a/b/c",
258 "a/..",
259 "a/.",
260 "a/",
261 "/b",
262 "a--b/c",
263 "a-/b",
264 "-a/b",
265 "a/b;touch",
266 "a/b\\c",
267 "a/é",
268 ] {
269 assert!(validate_github_name(name).is_err(), "{name}");
270 }
271 assert!(validate_github_name(&format!("{}/b", "a".repeat(40))).is_err());
272 assert!(validate_github_name(&format!("a/{}", "b".repeat(101))).is_err());
273 for name in ["owner/repo", "a-b/.repo_name-1", "Owner/Repo.git"] {
274 validate_github_name(name).unwrap();
275 }
276 }
277
278 #[tokio::test]
279 async fn acquisition_failures_are_explicit_and_preserve_existing_paths() {
280 let tmp = tempfile::tempdir().unwrap();
281 let mut cfg = crate::config::Repos {
282 roots: vec![tmp.path().join("root")],
283 ..Default::default()
284 };
285 let gh = script(tmp.path(), "gh", "echo denied; echo forbidden >&2; exit 7");
286 let error = clone_github_with_gh("sample/project", &cfg, &gh)
287 .await
288 .unwrap_err()
289 .to_string();
290 assert!(
291 error.contains(&gh)
292 && error.contains("denied")
293 && error.contains("forbidden")
294 && error.contains('7'),
295 "{error}"
296 );
297 let gh = script(
298 tmp.path(),
299 "gh",
300 "echo '{\"nameWithOwner\":\"other/project\"}'",
301 );
302 assert!(
303 clone_github_with_gh("sample/project", &cfg, &gh)
304 .await
305 .unwrap_err()
306 .to_string()
307 .contains("different repository")
308 );
309 let gh = script(
310 tmp.path(),
311 "gh",
312 "echo '{\"nameWithOwner\":\"sample/project\"}'",
313 );
314 let clone = script(tmp.path(), "clone", "echo clone-failed >&2; exit 9");
315 cfg.clone_command = Some(vec![clone.clone()]);
316 let error = clone_github_with_gh("sample/project", &cfg, &gh)
317 .await
318 .unwrap_err()
319 .to_string();
320 assert!(error.contains(&clone) && error.contains("clone-failed"));
321 assert!(!acquisition_incomplete(
322 &cfg.roots[0].join("github.com/sample/project")
323 ));
324 let clone = script(tmp.path(), "clone", "exit 0");
325 cfg.clone_command = Some(vec![clone]);
326 assert!(
327 clone_github_with_gh("sample/project", &cfg, &gh)
328 .await
329 .unwrap_err()
330 .to_string()
331 .contains("did not create")
332 );
333 let dest = cfg.roots[0].join("github.com/sample/project");
334 std::fs::create_dir_all(&dest).unwrap();
335 std::fs::write(dest.join("keep"), "untouched").unwrap();
336 assert!(
337 clone_github_with_gh("sample/project", &cfg, &gh)
338 .await
339 .unwrap_err()
340 .to_string()
341 .contains("already exists")
342 );
343 assert_eq!(
344 std::fs::read_to_string(dest.join("keep")).unwrap(),
345 "untouched"
346 );
347 cfg.roots.clear();
348 assert!(
349 clone_github_with_gh("sample/project", &cfg, &gh)
350 .await
351 .unwrap_err()
352 .to_string()
353 .contains("nonempty roots")
354 );
355 }
356
357 #[tokio::test]
358 async fn acquisition_retries_after_failure_without_a_destination() {
359 let tmp = tempfile::tempdir().unwrap();
360 let gh = script(
361 tmp.path(),
362 "gh",
363 "echo '{\"nameWithOwner\":\"sample/project\"}'",
364 );
365 let mut cfg = crate::config::Repos {
366 roots: vec![tmp.path().join("root")],
367 clone_command: Some(vec![
368 tmp.path().join("missing-command").display().to_string(),
369 ]),
370 ..Default::default()
371 };
372 let dest = cfg.roots[0].join("github.com/sample/project");
373 assert!(
374 clone_github_with_gh("sample/project", &cfg, &gh)
375 .await
376 .is_err()
377 );
378 assert!(!dest.exists());
379 assert!(!acquisition_incomplete(&dest));
380 let clone = script(
381 tmp.path(),
382 "clone",
383 r#"if [ ! -f "$0.attempted" ]; then
384 touch "$0.attempted"
385 echo transient-failure >&2
386 exit 9
387fi
388git init -q "$1""#,
389 );
390 cfg.clone_command = Some(vec![clone, "{dest}".into()]);
391 let error = clone_github_with_gh("sample/project", &cfg, &gh)
392 .await
393 .unwrap_err();
394 assert!(error.to_string().contains("transient-failure"));
395 assert!(!dest.exists());
396 assert!(!acquisition_incomplete(&dest));
397 let acquired = clone_github_with_gh("sample/project", &cfg, &gh)
398 .await
399 .unwrap();
400 assert_eq!(acquired, dest.canonicalize().unwrap());
401 assert!(!acquisition_incomplete(&dest));
402
403 cfg.roots = vec![tmp.path().join("partial")];
405 let partial = cfg.roots[0].join("github.com/sample/project");
406 let clone = script(tmp.path(), "partial-clone", "git init -q \"$1\"; exit 9");
407 cfg.clone_command = Some(vec![clone, "{dest}".into()]);
408 assert!(
409 clone_github_with_gh("sample/project", &cfg, &gh)
410 .await
411 .is_err()
412 );
413 assert!(partial.join(".git").exists());
414 assert!(acquisition_incomplete(&partial));
415 assert!(crate::repos::scan(&cfg.roots).is_empty());
416 assert!(
417 clone_github_with_gh("sample/project", &cfg, &gh)
418 .await
419 .unwrap_err()
420 .to_string()
421 .contains("already exists")
422 );
423 }
424
425 #[tokio::test]
426 async fn acquisition_default_command_uses_the_exact_destination() {
427 let tmp = tempfile::tempdir().unwrap();
428 let gh = script(
429 tmp.path(),
430 "gh",
431 r#"export GH_HOST=enterprise.invalid
432if [ "$2" = view ]; then
433 [ "$3" = https://github.com/sample/project ] || exit 8
434 echo '{"nameWithOwner":"Sample/Project"}'
435else
436 [ "$1" = repo ] && [ "$2" = clone ] && [ "$3" = https://github.com/sample/project.git ] || exit 8
437 git init -q "$4"
438fi"#,
439 );
440 let cfg = crate::config::Repos {
441 roots: vec![tmp.path().join("root with spaces")],
442 ..Default::default()
443 };
444 let path = clone_github_with_gh("sample/project", &cfg, &gh)
445 .await
446 .unwrap();
447 assert_eq!(
448 path,
449 cfg.roots[0]
450 .join("github.com/sample/project")
451 .canonicalize()
452 .unwrap()
453 );
454 }
455
456 #[tokio::test]
457 async fn configured_clone_preserves_spaces_in_substituted_arguments() {
458 let tmp = tempfile::tempdir().unwrap();
459 let gh = script(
460 tmp.path(),
461 "gh",
462 "echo '{\"nameWithOwner\":\"sample/project\"}'",
463 );
464 let clone = script(
465 tmp.path(),
466 "clone with spaces",
467 r#"[ "$#" = 4 ] && [ "$1" = 'sample x' ] && [ "$2" = project ] && [ "$3" = sample/project ] || exit 8
468git init -q "$4""#,
469 );
470 let cfg = crate::config::Repos {
471 roots: vec![tmp.path().join("root with spaces")],
472 clone_command: Some(vec![
473 clone,
474 "{owner} x".into(),
475 "{name}".into(),
476 "{repo}".into(),
477 "{dest}".into(),
478 ]),
479 ..Default::default()
480 };
481 let path = clone_github_with_gh("sample/project", &cfg, &gh)
482 .await
483 .unwrap();
484 assert_eq!(
485 path,
486 cfg.roots[0]
487 .join("github.com/sample/project")
488 .canonicalize()
489 .unwrap()
490 );
491 }
492
493 #[tokio::test]
494 async fn clone_lock_is_exclusive_and_releases_on_drop() {
495 let tmp = tempfile::tempdir().unwrap();
496 let cfg = crate::config::Repos {
497 roots: vec![tmp.path().to_owned()],
498 ..Default::default()
499 };
500 let first = clone_lock(&cfg).await.unwrap();
501 assert!(
502 tokio::time::timeout(Duration::from_millis(100), clone_lock(&cfg))
503 .await
504 .is_err()
505 );
506 drop(first);
507 tokio::time::timeout(Duration::from_secs(1), clone_lock(&cfg))
508 .await
509 .unwrap()
510 .unwrap();
511 }
512}