Skip to main content

agent_profile/
repo.rs

1// Copyright (c) 2026 Costas Kirgoussios
2//
3// This software is licensed under the PolyForm Noncommercial License 1.0.0.
4// You may obtain a copy of the License at:
5// https://polyformproject.org/licenses/noncommercial/1.0.0/
6
7//! Repository discovery (spec §13) and canonical repository identity (spec §14). SP3 design §5.
8//!
9//! Discovery never runs Git and never reads Git configuration: it reads the `.git` entry, the existence of
10//! `HEAD`, and the capped contents of a `.git` file and its `commondir` (design D1).
11
12use std::ffi::OsStr;
13use std::fs;
14use std::io::{self, Read};
15use std::path::{Component, Path, PathBuf};
16
17use crate::error::{Error, Result};
18
19/// The largest `.git` file or `commondir` file discovery reads.
20const MAX_METADATA_FILE: u64 = 64 * 1024;
21
22/// The outcome of discovery.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Discovery {
25    /// The canonical working-tree root.
26    Repository(PathBuf),
27    NotInRepository,
28}
29
30/// `fs::canonicalize`, then `strip_verbatim` (design §5.4).
31pub fn canonical(path: &Path) -> io::Result<PathBuf> {
32    fs::canonicalize(path).map(|path| strip_verbatim(&path))
33}
34
35/// `\\?\C:\x` becomes `C:\x` and `\\?\UNC\server\share\x` becomes `\\server\share\x`; any other path is
36/// returned unchanged.
37#[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/// Identity: Unix paths have no verbatim form.
60#[cfg(not(windows))]
61pub fn strip_verbatim(path: &Path) -> PathBuf {
62    path.to_path_buf()
63}
64
65/// Whether `key` is an ancestor directory above `path` (and not `path` itself).
66pub fn is_proper_ancestor(key: &Path, path: &Path) -> bool {
67    path.starts_with(key) && key != path
68}
69
70/// Whether discovery may touch `target`, a `gitdir` or `commondir` target joined to its base (design §5.3).
71/// An allow-list: on Windows only a local drive, or the network share `repository_dir` itself lives on.
72pub 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
116/// Finds the repository containing `start` (design §5.1-§5.2).
117pub 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
135/// Whether `dir` is a repository root; `false` when `dir/.git` does not exist (design §5.2).
136fn 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
191/// A `commondir` entry, when present, must name an existing directory (design §5.2).
192fn 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
228/// The candidate mapping keys for `unlink --repo <repo>`, in order, duplicates dropped (design §6.2).
229pub 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
247/// The canonical deepest existing ancestor of `path`, followed by the remaining components with `.` dropped
248/// and `..` applied lexically.
249fn 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
277/// The file's bytes, or `None` when it is larger than `MAX_METADATA_FILE`.
278fn 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
284/// The first line without its line ending.
285fn 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    /// A temp directory that no enclosing `.git` can turn into a repository (design §8.5).
295    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    /// Asserts `Error::Repository` with `path` and a reason starting with `reason`.
329    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        // Each layout builds a nested `inner` inside the repository `outer`; `(file, reason)` is expected.
420        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                    // The gitdir target is valid, so only the missing space can refuse this layout.
507                    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    /// The cap is "at most 64 KiB" (design §5.2), so a file of exactly that size is still read. The refusals one
569    /// byte over are in the table above.
570    #[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        // Rust joins a rooted value onto the drive of the base, so the value stays local.
774        #[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    /// `git` isolated from the developer's and the machine's configuration (design §8.2).
808    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}