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 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
237 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
238 path.to_string_lossy().into_owned()
239 }
240
241 #[test]
242 fn github_names_refuse_traversal_options_and_invalid_characters() {
243 for name in [
244 "bare",
245 "a/b/c",
246 "a/..",
247 "a/.",
248 "a/",
249 "/b",
250 "a--b/c",
251 "a-/b",
252 "-a/b",
253 "a/b;touch",
254 "a/b\\c",
255 "a/é",
256 ] {
257 assert!(validate_github_name(name).is_err(), "{name}");
258 }
259 assert!(validate_github_name(&format!("{}/b", "a".repeat(40))).is_err());
260 assert!(validate_github_name(&format!("a/{}", "b".repeat(101))).is_err());
261 for name in ["owner/repo", "a-b/.repo_name-1", "Owner/Repo.git"] {
262 validate_github_name(name).unwrap();
263 }
264 }
265
266 #[tokio::test]
267 async fn acquisition_failures_are_explicit_and_preserve_existing_paths() {
268 let tmp = tempfile::tempdir().unwrap();
269 let mut cfg = crate::config::Repos {
270 roots: vec![tmp.path().join("root")],
271 ..Default::default()
272 };
273 let gh = script(tmp.path(), "gh", "echo denied; echo forbidden >&2; exit 7");
274 let error = clone_github_with_gh("sample/project", &cfg, &gh)
275 .await
276 .unwrap_err()
277 .to_string();
278 assert!(
279 error.contains(&gh)
280 && error.contains("denied")
281 && error.contains("forbidden")
282 && error.contains('7')
283 );
284 let gh = script(
285 tmp.path(),
286 "gh",
287 "echo '{\"nameWithOwner\":\"other/project\"}'",
288 );
289 assert!(
290 clone_github_with_gh("sample/project", &cfg, &gh)
291 .await
292 .unwrap_err()
293 .to_string()
294 .contains("different repository")
295 );
296 let gh = script(
297 tmp.path(),
298 "gh",
299 "echo '{\"nameWithOwner\":\"sample/project\"}'",
300 );
301 let clone = script(tmp.path(), "clone", "echo clone-failed >&2; exit 9");
302 cfg.clone_command = Some(vec![clone.clone()]);
303 let error = clone_github_with_gh("sample/project", &cfg, &gh)
304 .await
305 .unwrap_err()
306 .to_string();
307 assert!(error.contains(&clone) && error.contains("clone-failed"));
308 assert!(!acquisition_incomplete(
309 &cfg.roots[0].join("github.com/sample/project")
310 ));
311 let clone = script(tmp.path(), "clone", "exit 0");
312 cfg.clone_command = Some(vec![clone]);
313 assert!(
314 clone_github_with_gh("sample/project", &cfg, &gh)
315 .await
316 .unwrap_err()
317 .to_string()
318 .contains("did not create")
319 );
320 let dest = cfg.roots[0].join("github.com/sample/project");
321 std::fs::create_dir_all(&dest).unwrap();
322 std::fs::write(dest.join("keep"), "untouched").unwrap();
323 assert!(
324 clone_github_with_gh("sample/project", &cfg, &gh)
325 .await
326 .unwrap_err()
327 .to_string()
328 .contains("already exists")
329 );
330 assert_eq!(
331 std::fs::read_to_string(dest.join("keep")).unwrap(),
332 "untouched"
333 );
334 cfg.roots.clear();
335 assert!(
336 clone_github_with_gh("sample/project", &cfg, &gh)
337 .await
338 .unwrap_err()
339 .to_string()
340 .contains("nonempty roots")
341 );
342 }
343
344 #[tokio::test]
345 async fn acquisition_retries_after_failure_without_a_destination() {
346 let tmp = tempfile::tempdir().unwrap();
347 let gh = script(
348 tmp.path(),
349 "gh",
350 "echo '{\"nameWithOwner\":\"sample/project\"}'",
351 );
352 let mut cfg = crate::config::Repos {
353 roots: vec![tmp.path().join("root")],
354 clone_command: Some(vec![
355 tmp.path().join("missing-command").display().to_string(),
356 ]),
357 ..Default::default()
358 };
359 let dest = cfg.roots[0].join("github.com/sample/project");
360 assert!(
361 clone_github_with_gh("sample/project", &cfg, &gh)
362 .await
363 .is_err()
364 );
365 assert!(!dest.exists());
366 assert!(!acquisition_incomplete(&dest));
367 let clone = script(
368 tmp.path(),
369 "clone",
370 r#"if [ ! -f "$0.attempted" ]; then
371 touch "$0.attempted"
372 echo transient-failure >&2
373 exit 9
374fi
375git init -q "$1""#,
376 );
377 cfg.clone_command = Some(vec![clone, "{dest}".into()]);
378 let error = clone_github_with_gh("sample/project", &cfg, &gh)
379 .await
380 .unwrap_err();
381 assert!(error.to_string().contains("transient-failure"));
382 assert!(!dest.exists());
383 assert!(!acquisition_incomplete(&dest));
384 let acquired = clone_github_with_gh("sample/project", &cfg, &gh)
385 .await
386 .unwrap();
387 assert_eq!(acquired, dest.canonicalize().unwrap());
388 assert!(!acquisition_incomplete(&dest));
389
390 cfg.roots = vec![tmp.path().join("partial")];
392 let partial = cfg.roots[0].join("github.com/sample/project");
393 let clone = script(tmp.path(), "partial-clone", "git init -q \"$1\"; exit 9");
394 cfg.clone_command = Some(vec![clone, "{dest}".into()]);
395 assert!(
396 clone_github_with_gh("sample/project", &cfg, &gh)
397 .await
398 .is_err()
399 );
400 assert!(partial.join(".git").exists());
401 assert!(acquisition_incomplete(&partial));
402 assert!(crate::repos::scan(&cfg.roots).is_empty());
403 assert!(
404 clone_github_with_gh("sample/project", &cfg, &gh)
405 .await
406 .unwrap_err()
407 .to_string()
408 .contains("already exists")
409 );
410 }
411
412 #[tokio::test]
413 async fn acquisition_default_command_uses_the_exact_destination() {
414 let tmp = tempfile::tempdir().unwrap();
415 let gh = script(
416 tmp.path(),
417 "gh",
418 r#"export GH_HOST=enterprise.invalid
419if [ "$2" = view ]; then
420 [ "$3" = https://github.com/sample/project ] || exit 8
421 echo '{"nameWithOwner":"Sample/Project"}'
422else
423 [ "$1" = repo ] && [ "$2" = clone ] && [ "$3" = https://github.com/sample/project.git ] || exit 8
424 git init -q "$4"
425fi"#,
426 );
427 let cfg = crate::config::Repos {
428 roots: vec![tmp.path().join("root with spaces")],
429 ..Default::default()
430 };
431 let path = clone_github_with_gh("sample/project", &cfg, &gh)
432 .await
433 .unwrap();
434 assert_eq!(
435 path,
436 cfg.roots[0]
437 .join("github.com/sample/project")
438 .canonicalize()
439 .unwrap()
440 );
441 }
442
443 #[tokio::test]
444 async fn configured_clone_preserves_spaces_in_substituted_arguments() {
445 let tmp = tempfile::tempdir().unwrap();
446 let gh = script(
447 tmp.path(),
448 "gh",
449 "echo '{\"nameWithOwner\":\"sample/project\"}'",
450 );
451 let clone = script(
452 tmp.path(),
453 "clone with spaces",
454 r#"[ "$#" = 4 ] && [ "$1" = 'sample x' ] && [ "$2" = project ] && [ "$3" = sample/project ] || exit 8
455git init -q "$4""#,
456 );
457 let cfg = crate::config::Repos {
458 roots: vec![tmp.path().join("root with spaces")],
459 clone_command: Some(vec![
460 clone,
461 "{owner} x".into(),
462 "{name}".into(),
463 "{repo}".into(),
464 "{dest}".into(),
465 ]),
466 ..Default::default()
467 };
468 let path = clone_github_with_gh("sample/project", &cfg, &gh)
469 .await
470 .unwrap();
471 assert_eq!(
472 path,
473 cfg.roots[0]
474 .join("github.com/sample/project")
475 .canonicalize()
476 .unwrap()
477 );
478 }
479
480 #[tokio::test]
481 async fn clone_lock_is_exclusive_and_releases_on_drop() {
482 let tmp = tempfile::tempdir().unwrap();
483 let cfg = crate::config::Repos {
484 roots: vec![tmp.path().to_owned()],
485 ..Default::default()
486 };
487 let first = clone_lock(&cfg).await.unwrap();
488 assert!(
489 tokio::time::timeout(Duration::from_millis(100), clone_lock(&cfg))
490 .await
491 .is_err()
492 );
493 drop(first);
494 tokio::time::timeout(Duration::from_secs(1), clone_lock(&cfg))
495 .await
496 .unwrap()
497 .unwrap();
498 }
499}