1use std::ffi::OsStr;
13use std::fs;
14use std::io::{self, Read};
15use std::path::{Component, Path, PathBuf};
16
17use crate::error::{Error, Result};
18
19const MAX_METADATA_FILE: u64 = 64 * 1024;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Discovery {
25 Repository(PathBuf),
27 NotInRepository,
28}
29
30pub fn canonical(path: &Path) -> io::Result<PathBuf> {
32 fs::canonicalize(path).map(|path| strip_verbatim(&path))
33}
34
35#[cfg(windows)]
38pub fn strip_verbatim(path: &Path) -> PathBuf {
39 use std::path::Prefix;
40 let mut components = path.components();
41 let Some(Component::Prefix(prefix)) = components.next() else {
42 return path.to_path_buf();
43 };
44 let mut stripped = match prefix.kind() {
45 Prefix::VerbatimDisk(letter) => PathBuf::from(format!("{}:", char::from(letter))),
46 Prefix::VerbatimUNC(server, share) => {
47 let mut unc = std::ffi::OsString::from(r"\\");
48 unc.push(server);
49 unc.push(r"\");
50 unc.push(share);
51 PathBuf::from(unc)
52 }
53 _ => return path.to_path_buf(),
54 };
55 stripped.push(components.as_path());
56 stripped
57}
58
59#[cfg(not(windows))]
61pub fn strip_verbatim(path: &Path) -> PathBuf {
62 path.to_path_buf()
63}
64
65pub fn is_proper_ancestor(key: &Path, path: &Path) -> bool {
67 path.starts_with(key) && key != path
68}
69
70pub fn target_allowed(target: &Path, repository_dir: &Path) -> bool {
73 if target.as_os_str().as_encoded_bytes().contains(&0) {
74 return false;
75 }
76 prefix_allowed(target, repository_dir)
77}
78
79#[cfg(windows)]
80fn prefix_allowed(target: &Path, repository_dir: &Path) -> bool {
81 use std::path::Prefix;
82 fn share(path: &Path) -> Option<(&OsStr, &OsStr)> {
83 match path.components().next() {
84 Some(Component::Prefix(prefix)) => match prefix.kind() {
85 Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
86 Some((server, share))
87 }
88 _ => None,
89 },
90 _ => None,
91 }
92 }
93 match target.components().next() {
94 Some(Component::Prefix(prefix)) => match prefix.kind() {
95 Prefix::Disk(_) | Prefix::VerbatimDisk(_) => true,
96 Prefix::UNC(..) | Prefix::VerbatimUNC(..) => {
97 match (share(target), share(repository_dir)) {
98 (Some((server, name)), Some((repo_server, repo_name))) => {
99 server.eq_ignore_ascii_case(repo_server)
100 && name.eq_ignore_ascii_case(repo_name)
101 }
102 _ => false,
103 }
104 }
105 _ => false,
106 },
107 _ => false,
108 }
109}
110
111#[cfg(not(windows))]
112fn prefix_allowed(_target: &Path, _repository_dir: &Path) -> bool {
113 true
114}
115
116pub fn discover(start: &Path) -> Result<Discovery> {
118 let canonical_start = canonical(start)
119 .map_err(|error| repository(start, format!("cannot resolve the directory: {error}")))?;
120 match fs::metadata(&canonical_start) {
121 Ok(metadata) if metadata.is_dir() => {}
122 Ok(_) => return Err(repository(start, "not a directory")),
123 Err(error) => {
124 return Err(repository(start, format!("cannot resolve the directory: {error}")));
125 }
126 }
127 for dir in canonical_start.ancestors() {
128 if examine(dir)? {
129 return Ok(Discovery::Repository(dir.to_path_buf()));
130 }
131 }
132 Ok(Discovery::NotInRepository)
133}
134
135fn examine(dir: &Path) -> Result<bool> {
137 let dot_git = dir.join(".git");
138 let metadata = match fs::metadata(&dot_git) {
139 Ok(metadata) => metadata,
140 Err(error) if error.kind() == io::ErrorKind::NotFound => {
141 return match fs::symlink_metadata(&dot_git) {
142 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
143 Ok(_) => Err(repository(&dot_git, ".git is a broken symbolic link")),
144 Err(error) => Err(repository(&dot_git, format!("cannot read .git: {error}"))),
145 };
146 }
147 Err(error) => return Err(repository(&dot_git, format!("cannot read .git: {error}"))),
148 };
149 if metadata.is_dir() {
150 return if is_file(&dot_git.join("HEAD")) {
151 Ok(true)
152 } else {
153 Err(repository(&dot_git, "invalid .git directory: no HEAD file"))
154 };
155 }
156 if !metadata.is_file() {
157 return Err(repository(&dot_git, ".git is neither a directory nor a file"));
158 }
159 let bytes = read_capped(&dot_git)
160 .map_err(|error| repository(&dot_git, format!("cannot read .git: {error}")))?
161 .ok_or_else(|| repository(&dot_git, "invalid .git file: too large"))?;
162 let text = std::str::from_utf8(&bytes)
163 .map_err(|_| repository(&dot_git, "invalid .git file: not UTF-8"))?;
164 let value = first_line(text)
165 .strip_prefix("gitdir: ")
166 .ok_or_else(|| repository(&dot_git, "invalid .git file: no gitdir line"))?;
167
168 let target = dir.join(value);
169 if !target_allowed(&target, dir) {
170 return Err(repository(
171 &dot_git,
172 format!("gitdir points to a network or device path: {}", target.display()),
173 ));
174 }
175 let missing = || {
176 repository(
177 &dot_git,
178 format!("gitdir {} is missing or is not a Git directory", target.display()),
179 )
180 };
181 let gitdir = canonical(&target).map_err(|_| missing())?;
182 if !fs::metadata(&gitdir).is_ok_and(|metadata| metadata.is_dir())
183 || !is_file(&gitdir.join("HEAD"))
184 {
185 return Err(missing());
186 }
187 check_commondir(dir, &gitdir)?;
188 Ok(true)
189}
190
191fn check_commondir(dir: &Path, gitdir: &Path) -> Result<()> {
193 let file = gitdir.join("commondir");
194 match fs::symlink_metadata(&file) {
195 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
196 _ => {}
197 }
198 let invalid = |detail: String| repository(&file, format!("invalid commondir file: {detail}"));
199 match fs::metadata(&file) {
200 Ok(metadata) if metadata.is_file() => {}
201 Ok(_) => return Err(invalid("not a regular file".to_owned())),
202 Err(error) => return Err(invalid(format!("cannot read: {error}"))),
203 }
204 let bytes = read_capped(&file)
205 .map_err(|error| invalid(format!("cannot read: {error}")))?
206 .ok_or_else(|| invalid("too large".to_owned()))?;
207 let text = std::str::from_utf8(&bytes).map_err(|_| invalid("not UTF-8".to_owned()))?;
208 let value = first_line(text);
209 if value.is_empty() {
210 return Err(invalid("empty".to_owned()));
211 }
212 let target = gitdir.join(value);
213 if !target_allowed(&target, dir) {
214 return Err(repository(
215 &file,
216 format!("commondir points to a network or device path: {}", target.display()),
217 ));
218 }
219 if !fs::metadata(&target).is_ok_and(|metadata| metadata.is_dir()) {
220 return Err(repository(
221 &file,
222 format!("commondir {} is missing or is not a directory", target.display()),
223 ));
224 }
225 Ok(())
226}
227
228pub fn unlink_keys(cwd: &Path, repo: &OsStr) -> Vec<PathBuf> {
230 let joined = cwd.join(repo);
231 let mut keys = Vec::new();
232 let mut push = |key: PathBuf| {
233 if !keys.contains(&key) {
234 keys.push(key);
235 }
236 };
237 if let Ok(key) = canonical(&joined) {
238 push(key);
239 }
240 if let Some(key) = resolved(&joined) {
241 push(key);
242 }
243 push(strip_verbatim(&joined));
244 keys
245}
246
247fn resolved(path: &Path) -> Option<PathBuf> {
250 let components: Vec<Component<'_>> = path.components().collect();
251 for split in (1..=components.len()).rev() {
252 let base: PathBuf = components[..split].iter().collect();
253 let Ok(mut key) = canonical(&base) else { continue };
254 for component in &components[split..] {
255 match component {
256 Component::CurDir => {}
257 Component::ParentDir => {
258 key.pop();
259 }
260 Component::Normal(name) => key.push(name),
261 Component::Prefix(_) | Component::RootDir => return None,
262 }
263 }
264 return Some(key);
265 }
266 None
267}
268
269fn repository(path: &Path, reason: impl Into<String>) -> Error {
270 Error::Repository { path: path.to_path_buf(), reason: reason.into() }
271}
272
273fn is_file(path: &Path) -> bool {
274 fs::metadata(path).is_ok_and(|metadata| metadata.is_file())
275}
276
277fn read_capped(path: &Path) -> io::Result<Option<Vec<u8>>> {
279 let mut bytes = Vec::new();
280 fs::File::open(path)?.take(MAX_METADATA_FILE + 1).read_to_end(&mut bytes)?;
281 Ok((bytes.len() as u64 <= MAX_METADATA_FILE).then_some(bytes))
282}
283
284fn first_line(text: &str) -> &str {
286 text.split('\n').next().unwrap_or_default().trim_end_matches('\r')
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use std::process::Command;
293
294 fn guarded() -> tempfile::TempDir {
296 let dir = tempfile::tempdir().unwrap();
297 for ancestor in dir.path().ancestors() {
298 assert!(
299 fs::symlink_metadata(ancestor.join(".git")).is_err(),
300 "{} has a .git entry, so this test cannot build a layout outside a repository",
301 ancestor.display()
302 );
303 }
304 dir
305 }
306
307 fn root_of(dir: &tempfile::TempDir) -> PathBuf {
308 canonical(dir.path()).unwrap()
309 }
310
311 fn git_dir(path: &Path) {
312 fs::create_dir_all(path.join(".git")).unwrap();
313 fs::write(path.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
314 }
315
316 fn write(path: &Path, contents: impl AsRef<[u8]>) {
317 fs::create_dir_all(path.parent().unwrap()).unwrap();
318 fs::write(path, contents).unwrap();
319 }
320
321 fn found(start: &Path) -> PathBuf {
322 match discover(start) {
323 Ok(Discovery::Repository(root)) => root,
324 other => panic!("{}: {other:?}", start.display()),
325 }
326 }
327
328 fn refused(start: &Path, path: &Path, reason: &str) {
330 match discover(start) {
331 Err(Error::Repository { path: actual, reason: actual_reason }) => {
332 assert_eq!(actual, path, "{actual_reason}");
333 assert!(actual_reason.starts_with(reason), "{actual_reason:?} !~ {reason:?}");
334 }
335 other => panic!("{}: {other:?}", start.display()),
336 }
337 }
338
339 #[test]
340 fn a_git_directory_with_head_is_the_root_from_the_root_and_a_deep_subdirectory() {
341 let dir = guarded();
342 let root = root_of(&dir);
343 git_dir(&root);
344 fs::create_dir_all(root.join("a").join("b").join("c")).unwrap();
345 assert_eq!(found(&root), root);
346 assert_eq!(found(&root.join("a").join("b").join("c")), root);
347 }
348
349 #[test]
350 fn a_worktree_git_file_with_gitdir_and_commondir_is_a_root() {
351 let dir = guarded();
352 let root = root_of(&dir);
353 let main = root.join("main");
354 git_dir(&main);
355 let admin = main.join(".git").join("worktrees").join("wt");
356 write(&admin.join("HEAD"), "ref: refs/heads/wt\n");
357 write(&admin.join("commondir"), "../..\n");
358 let worktree = root.join("wt");
359 write(&worktree.join(".git"), format!("gitdir: {}\n", admin.to_str().unwrap()));
360 fs::create_dir_all(worktree.join("src")).unwrap();
361 assert_eq!(found(&worktree.join("src")), worktree);
362 assert_eq!(found(&main), main);
363 }
364
365 #[test]
366 fn a_submodule_git_file_into_modules_is_a_root() {
367 let dir = guarded();
368 let root = root_of(&dir);
369 git_dir(&root);
370 write(&root.join(".git").join("modules").join("x").join("HEAD"), "0123\n");
371 let submodule = root.join("x");
372 write(&submodule.join(".git"), "gitdir: ../.git/modules/x\n");
373 assert_eq!(found(&submodule), submodule);
374 }
375
376 #[test]
377 fn a_nested_repository_is_its_own_root() {
378 let dir = guarded();
379 let root = root_of(&dir);
380 git_dir(&root);
381 let nested = root.join("vendor").join("lib");
382 git_dir(&nested);
383 assert_eq!(found(&nested), nested);
384 assert_eq!(found(&root.join("vendor")), root);
385 }
386
387 #[test]
388 fn a_start_inside_the_git_directory_reaches_the_repository() {
389 let dir = guarded();
390 let root = root_of(&dir);
391 git_dir(&root);
392 let objects = root.join(".git").join("objects");
393 fs::create_dir_all(&objects).unwrap();
394 assert_eq!(found(&objects), root);
395 }
396
397 #[test]
398 fn no_git_anywhere_is_not_in_a_repository() {
399 let dir = guarded();
400 let deep = root_of(&dir).join("a").join("b");
401 fs::create_dir_all(&deep).unwrap();
402 assert_eq!(discover(&deep).unwrap(), Discovery::NotInRepository);
403 }
404
405 #[test]
406 fn a_missing_start_and_a_file_start_are_repository_errors() {
407 let dir = guarded();
408 let missing = dir.path().join("missing");
409 refused(&missing, &missing, "cannot resolve the directory: ");
410 let file = dir.path().join("file");
411 fs::write(&file, b"x").unwrap();
412 refused(&file, &file, "not a directory");
413 }
414
415 #[test]
416 fn broken_git_entries_are_errors_never_the_parent_root() {
417 let big = "gitdir: x\n".to_owned() + &"#".repeat(MAX_METADATA_FILE as usize);
418 type Layout = Box<dyn Fn(&Path, &Path)>;
419 let cases: Vec<(&str, Layout, &str, &str)> = vec![
421 (
422 "empty .git directory",
423 Box::new(|_, inner| fs::create_dir_all(inner.join(".git")).unwrap()),
424 ".git",
425 "invalid .git directory: no HEAD file",
426 ),
427 (
428 "HEAD is a directory",
429 Box::new(|_, inner| fs::create_dir_all(inner.join(".git").join("HEAD")).unwrap()),
430 ".git",
431 "invalid .git directory: no HEAD file",
432 ),
433 (
434 "garbage .git file",
435 Box::new(|_, inner| write(&inner.join(".git"), "garbage\n")),
436 ".git",
437 "invalid .git file: no gitdir line",
438 ),
439 (
440 "gitdir that does not exist",
441 Box::new(|_, inner| write(&inner.join(".git"), "gitdir: ../nowhere\n")),
442 ".git",
443 "gitdir ",
444 ),
445 (
446 "gitdir without HEAD",
447 Box::new(|outer, inner| {
448 fs::create_dir_all(outer.join("admin")).unwrap();
449 write(&inner.join(".git"), "gitdir: ../admin\n");
450 }),
451 ".git",
452 "gitdir ",
453 ),
454 (
455 "commondir target missing",
456 Box::new(|outer, inner| {
457 write(&outer.join("admin").join("HEAD"), "x\n");
458 write(&outer.join("admin").join("commondir"), "../gone\n");
459 write(&inner.join(".git"), "gitdir: ../admin\n");
460 }),
461 "admin/commondir",
462 "commondir ",
463 ),
464 (
465 ".git file over 64 KiB",
466 Box::new(move |_, inner| write(&inner.join(".git"), &big)),
467 ".git",
468 "invalid .git file: too large",
469 ),
470 (
471 "commondir over 64 KiB",
472 Box::new(|outer, inner| {
473 write(&outer.join("admin").join("HEAD"), "x\n");
474 write(
475 &outer.join("admin").join("commondir"),
476 "#".repeat(MAX_METADATA_FILE as usize + 1),
477 );
478 write(&inner.join(".git"), "gitdir: ../admin\n");
479 }),
480 "admin/commondir",
481 "invalid commondir file: too large",
482 ),
483 (
484 "commondir is a directory",
485 Box::new(|outer, inner| {
486 write(&outer.join("admin").join("HEAD"), "x\n");
487 fs::create_dir_all(outer.join("admin").join("commondir")).unwrap();
488 write(&inner.join(".git"), "gitdir: ../admin\n");
489 }),
490 "admin/commondir",
491 "invalid commondir file: not a regular file",
492 ),
493 (
494 "empty commondir",
495 Box::new(|outer, inner| {
496 write(&outer.join("admin").join("HEAD"), "x\n");
497 write(&outer.join("admin").join("commondir"), "\n");
498 write(&inner.join(".git"), "gitdir: ../admin\n");
499 }),
500 "admin/commondir",
501 "invalid commondir file: empty",
502 ),
503 (
504 "gitdir without the space",
505 Box::new(|outer, inner| {
506 write(&outer.join("admin").join("HEAD"), "x\n");
508 write(&inner.join(".git"), "gitdir:../admin\n");
509 }),
510 ".git",
511 "invalid .git file: no gitdir line",
512 ),
513 (
514 "non-UTF-8 .git file",
515 Box::new(|_, inner| write(&inner.join(".git"), b"gitdir: \xff\n")),
516 ".git",
517 "invalid .git file: not UTF-8",
518 ),
519 (
520 "non-UTF-8 commondir",
521 Box::new(|outer, inner| {
522 write(&outer.join("admin").join("HEAD"), "x\n");
523 write(&outer.join("admin").join("commondir"), b"\xff\n");
524 write(&inner.join(".git"), "gitdir: ../admin\n");
525 }),
526 "admin/commondir",
527 "invalid commondir file: not UTF-8",
528 ),
529 (
530 "gitdir with a NUL",
531 Box::new(|_, inner| write(&inner.join(".git"), "gitdir: ../a\0b\n")),
532 ".git",
533 "gitdir points to a network or device path: ",
534 ),
535 (
536 "commondir with a NUL",
537 Box::new(|outer, inner| {
538 write(&outer.join("admin").join("HEAD"), "x\n");
539 write(&outer.join("admin").join("commondir"), "../a\0b\n");
540 write(&inner.join(".git"), "gitdir: ../admin\n");
541 }),
542 "admin/commondir",
543 "commondir points to a network or device path: ",
544 ),
545 ];
546 for (name, layout, file, reason) in cases {
547 let dir = guarded();
548 let outer = root_of(&dir);
549 git_dir(&outer);
550 let inner = outer.join("inner");
551 fs::create_dir_all(&inner).unwrap();
552 layout(&outer, &inner);
553 let expected = if file == ".git" {
554 inner.join(".git")
555 } else {
556 outer.join("admin").join("commondir")
557 };
558 match discover(&inner) {
559 Err(Error::Repository { path, reason: actual }) => {
560 assert_eq!(path, expected, "{name}: {actual}");
561 assert!(actual.starts_with(reason), "{name}: {actual:?} !~ {reason:?}");
562 }
563 other => panic!("{name}: {other:?}"),
564 }
565 }
566 }
567
568 #[test]
571 fn a_git_file_and_a_commondir_of_exactly_64_kib_are_accepted() {
572 let dir = guarded();
573 let root = root_of(&dir);
574 let padded = |first: &str| {
575 let mut text = first.to_owned();
576 text.push_str(&"#".repeat(MAX_METADATA_FILE as usize - text.len()));
577 assert_eq!(text.len() as u64, MAX_METADATA_FILE);
578 text
579 };
580 write(&root.join("admin").join("HEAD"), "x\n");
581 write(&root.join("admin").join("commondir"), padded(".\n"));
582 let worktree = root.join("wt");
583 write(&worktree.join(".git"), padded("gitdir: ../admin\n"));
584 assert_eq!(found(&worktree), worktree);
585 }
586
587 #[test]
588 fn a_git_file_with_crlf_line_endings_is_accepted() {
589 let dir = guarded();
590 let root = root_of(&dir);
591 write(&root.join("admin").join("HEAD"), "x\r\n");
592 write(&root.join("admin").join("commondir"), ".\r\n");
593 let worktree = root.join("wt");
594 write(&worktree.join(".git"), "gitdir: ../admin\r\nsecond line\r\n");
595 assert_eq!(found(&worktree), worktree);
596 }
597
598 #[test]
599 fn a_gitdir_or_commondir_error_names_the_joined_target() {
600 let dir = guarded();
601 let root = root_of(&dir);
602 let worktree = root.join("wt");
603 write(&worktree.join(".git"), "gitdir: ../nowhere\n");
604 let target = worktree.join("../nowhere");
605 refused(
606 &worktree,
607 &worktree.join(".git"),
608 &format!("gitdir {} is missing or is not a Git directory", target.display()),
609 );
610 write(&root.join("admin").join("HEAD"), "x\n");
611 write(&root.join("admin").join("commondir"), "../gone\n");
612 write(&worktree.join(".git"), "gitdir: ../admin\n");
613 let target = root.join("admin").join("../gone");
614 refused(
615 &worktree,
616 &root.join("admin").join("commondir"),
617 &format!("commondir {} is missing or is not a directory", target.display()),
618 );
619 }
620
621 #[test]
622 fn a_nul_target_is_refused_on_every_platform() {
623 assert!(!target_allowed(Path::new("a\0b"), Path::new("repo")));
624 }
625
626 #[cfg(unix)]
627 #[test]
628 fn a_symlinked_start_resolves_to_the_real_root() {
629 let dir = guarded();
630 let root = root_of(&dir);
631 let real = root.join("real");
632 git_dir(&real);
633 std::os::unix::fs::symlink(&real, root.join("link")).unwrap();
634 assert_eq!(found(&root.join("link")), real);
635 }
636
637 #[cfg(unix)]
638 #[test]
639 fn a_dangling_git_symlink_is_an_error() {
640 let dir = guarded();
641 let root = root_of(&dir);
642 std::os::unix::fs::symlink(root.join("nowhere"), root.join(".git")).unwrap();
643 refused(&root, &root.join(".git"), ".git is a broken symbolic link");
644 }
645
646 #[cfg(unix)]
647 #[test]
648 fn an_unreadable_commondir_is_an_error() {
649 use std::os::unix::fs::PermissionsExt;
650 let dir = guarded();
651 let root = root_of(&dir);
652 let commondir = root.join("admin").join("commondir");
653 write(&root.join("admin").join("HEAD"), "x\n");
654 write(&commondir, "..\n");
655 write(&root.join("wt").join(".git"), "gitdir: ../admin\n");
656 fs::set_permissions(&commondir, fs::Permissions::from_mode(0o000)).unwrap();
657 if fs::read(&commondir).is_ok() {
658 eprintln!("skipped: running as a user that ignores file permissions");
659 return;
660 }
661 refused(&root.join("wt"), &commondir, "invalid commondir file: cannot read: ");
662 }
663
664 #[cfg(unix)]
665 #[test]
666 fn a_fifo_commondir_is_refused_without_blocking() {
667 let dir = guarded();
668 let root = root_of(&dir);
669 let commondir = root.join("admin").join("commondir");
670 write(&root.join("admin").join("HEAD"), "x\n");
671 let status = Command::new("mkfifo").arg(&commondir).status().unwrap();
672 assert!(status.success());
673 write(&root.join("wt").join(".git"), "gitdir: ../admin\n");
674 let start = root.join("wt");
675 let (sender, receiver) = std::sync::mpsc::channel();
676 std::thread::spawn(move || sender.send(discover(&start)).unwrap());
677 let result = receiver
678 .recv_timeout(std::time::Duration::from_secs(10))
679 .expect("discovery blocked on a FIFO");
680 match result {
681 Err(Error::Repository { path, reason }) => {
682 assert_eq!(path, commondir);
683 assert_eq!(reason, "invalid commondir file: not a regular file");
684 }
685 other => panic!("{other:?}"),
686 }
687 }
688
689 #[cfg(unix)]
690 #[test]
691 fn a_git_entry_that_is_neither_a_directory_nor_a_file_is_an_error() {
692 let dir = guarded();
693 let root = root_of(&dir);
694 let status = Command::new("mkfifo").arg(root.join(".git")).status().unwrap();
695 assert!(status.success());
696 fs::create_dir_all(root.join("sub")).unwrap();
697 refused(&root.join("sub"), &root.join(".git"), ".git is neither a directory nor a file");
698 }
699
700 #[cfg(windows)]
701 #[test]
702 fn a_junction_start_resolves_to_the_real_root() {
703 let dir = guarded();
704 let root = root_of(&dir);
705 let real = root.join("real");
706 git_dir(&real);
707 let junction = root.join("junction");
708 let status = Command::new("cmd")
709 .arg("/C")
710 .arg("mklink")
711 .arg("/J")
712 .arg(&junction)
713 .arg(&real)
714 .stdout(std::process::Stdio::null())
715 .status()
716 .unwrap();
717 assert!(status.success());
718 assert_eq!(found(&junction.join(".")), real);
719 }
720
721 #[cfg(windows)]
722 #[test]
723 fn strip_verbatim_handles_drive_unc_and_other_verbatim_forms() {
724 for (input, expected) in [
725 (r"\\?\C:\x\y", r"C:\x\y"),
726 (r"\\?\C:\", r"C:\"),
727 (r"\\?\UNC\server\share\x", r"\\server\share\x"),
728 (r"\\?\Volume{0f0e}\x", r"\\?\Volume{0f0e}\x"),
729 (r"\\?\GLOBALROOT\Device\x", r"\\?\GLOBALROOT\Device\x"),
730 (r"C:\plain", r"C:\plain"),
731 (r"\\server\share\x", r"\\server\share\x"),
732 ] {
733 assert_eq!(strip_verbatim(Path::new(input)), Path::new(expected), "{input}");
734 assert_eq!(
735 strip_verbatim(Path::new(input)).as_os_str(),
736 OsStr::new(expected),
737 "{input}"
738 );
739 }
740 }
741
742 #[cfg(windows)]
743 #[test]
744 fn target_allowed_accepts_local_drives_and_the_repository_share_only() {
745 let local = Path::new(r"C:\repo");
746 let shared = Path::new(r"\\server\share\repo");
747 for (target, repository_dir, allowed) in [
748 (r"\\server\share\x", local, false),
749 (r"//server/share/x", local, false),
750 (r"\/server/share/x", local, false),
751 (r"//./pipe/x", local, false),
752 (r"\\.\pipe\x", local, false),
753 (r"\\?\UNC\s\x", local, false),
754 (r"\\?\Volume{0f0e}\x", local, false),
755 (r"\\?\GLOBALROOT\x", local, false),
756 (r"\\evil\\share\x", local, false),
757 (r"C:\??\UNC\s\x", local, true),
758 (r"C:\x", local, true),
759 (r"\\?\C:\x", local, true),
760 (r"\\SERVER\Share\other", shared, true),
761 (r"\\?\UNC\server\share\x", shared, true),
762 (r"\\server\elsewhere\x", shared, false),
763 (r"C:\a\0b", local, true),
764 ("C:\\a\0b", local, false),
765 ] {
766 assert_eq!(
767 target_allowed(Path::new(target), repository_dir),
768 allowed,
769 "{target:?} from {}",
770 repository_dir.display()
771 );
772 }
773 #[allow(clippy::join_absolute_paths)]
775 let joined = local.join(r"\??\UNC\s\x");
776 assert_eq!(joined, Path::new(r"C:\??\UNC\s\x"));
777 }
778
779 #[cfg(windows)]
780 #[test]
781 fn network_and_device_gitdir_targets_are_refused_before_any_filesystem_call() {
782 for value in
783 [r"\\server\share\x", r"\\.\pipe\x", r"\\?\Volume{0f0e}\x", r"//server/share/x"]
784 {
785 let dir = guarded();
786 let root = root_of(&dir);
787 write(&root.join(".git"), format!("gitdir: {value}\n"));
788 refused(
789 &root,
790 &root.join(".git"),
791 &format!(
792 "gitdir points to a network or device path: {}",
793 root.join(value).display()
794 ),
795 );
796 write(&root.join("admin").join("HEAD"), "x\n");
797 write(&root.join("admin").join("commondir"), format!("{value}\n"));
798 write(&root.join(".git"), "gitdir: admin\n");
799 refused(
800 &root,
801 &root.join("admin").join("commondir"),
802 "commondir points to a network or device path: ",
803 );
804 }
805 }
806
807 fn git(cwd: &Path, args: &[&str]) -> String {
809 let empty = cwd.parent().unwrap().join("empty-gitconfig");
810 fs::write(&empty, b"").unwrap();
811 let mut command = Command::new("git");
812 for (name, _) in std::env::vars_os() {
813 if name.to_string_lossy().starts_with("GIT_") {
814 command.env_remove(name);
815 }
816 }
817 let output = command
818 .current_dir(cwd)
819 .env("GIT_CONFIG_GLOBAL", &empty)
820 .env("GIT_CONFIG_NOSYSTEM", "1")
821 .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
822 .args(args)
823 .output()
824 .unwrap();
825 assert!(
826 output.status.success(),
827 "git {args:?}: {}",
828 String::from_utf8_lossy(&output.stderr)
829 );
830 String::from_utf8(output.stdout).unwrap()
831 }
832
833 fn git_toplevel(cwd: &Path) -> PathBuf {
834 canonical(Path::new(git(cwd, &["rev-parse", "--show-toplevel"]).trim_end())).unwrap()
835 }
836
837 fn git_repository(path: &Path) {
838 fs::create_dir_all(path).unwrap();
839 git(path, &["init", "-q"]);
840 git(path, &["commit", "-q", "--allow-empty", "-m", "init"]);
841 }
842
843 #[test]
844 fn discovery_matches_git_for_init_worktree_and_submodule() {
845 let dir = guarded();
846 let root = root_of(&dir);
847 let main = root.join("main");
848 git_repository(&main);
849 let library = root.join("library");
850 git_repository(&library);
851 git(&main, &["worktree", "add", "-q", "../wt"]);
852 git(
853 &main,
854 &[
855 "-c",
856 "protocol.file.allow=always",
857 "submodule",
858 "add",
859 "-q",
860 library.to_str().unwrap(),
861 "sub",
862 ],
863 );
864 git(&main, &["commit", "-q", "-m", "submodule"]);
865 let deep = main.join("sub").join("nested");
866 fs::create_dir_all(&deep).unwrap();
867 for start in [main.clone(), root.join("wt"), main.join("sub"), deep] {
868 assert_eq!(found(&start), git_toplevel(&start), "{}", start.display());
869 }
870 }
871
872 #[test]
873 fn broken_nested_git_entries_inside_a_real_repository_are_errors() {
874 for (name, layout) in [
875 ("gitdir to nowhere", "gitdir: ../nowhere\n"),
876 ("garbage", "garbage\n"),
877 ("empty directory", ""),
878 ] {
879 let dir = guarded();
880 let main = root_of(&dir).join("main");
881 git_repository(&main);
882 let nested = main.join("nested");
883 fs::create_dir_all(&nested).unwrap();
884 if layout.is_empty() {
885 fs::create_dir(nested.join(".git")).unwrap();
886 } else {
887 fs::write(nested.join(".git"), layout).unwrap();
888 }
889 assert!(matches!(discover(&nested), Err(Error::Repository { .. })), "{name}");
890 }
891 }
892
893 #[test]
894 fn unlink_keys_prefer_the_canonical_path_then_the_resolved_then_the_literal() {
895 let dir = guarded();
896 let root = root_of(&dir);
897 let repo = root.join("repo");
898 fs::create_dir_all(&repo).unwrap();
899 assert_eq!(unlink_keys(&root, OsStr::new("repo")), [repo]);
900
901 let cwd = root.join("cwd");
902 fs::create_dir_all(&cwd).unwrap();
903 assert_eq!(
904 unlink_keys(&cwd, OsStr::new("../gone")),
905 [root.join("gone"), cwd.join("../gone")]
906 );
907 assert_eq!(
908 unlink_keys(&cwd, OsStr::new("./missing/../gone")),
909 [cwd.join("gone"), cwd.join("./missing/../gone")]
910 );
911 }
912
913 #[test]
914 fn unlink_keys_resolve_a_deleted_directory_under_a_non_canonical_ancestor() {
915 let dir = guarded();
916 let spelled = dir.path().join("sub").join("..").join("gone");
917 let keys = unlink_keys(dir.path(), OsStr::new("sub/../gone"));
918 assert_eq!(keys[0], root_of(&dir).join("gone"), "{keys:?}");
919 assert_eq!(keys.last().unwrap(), &strip_verbatim(&spelled), "{keys:?}");
920 }
921}