1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
use std::{env, ffi::OsStr, path::PathBuf};

/// The error returned by [`gix_discover::upwards()`][crate::upwards()].
#[derive(Debug, thiserror::Error)]
#[allow(missing_docs)]
pub enum Error {
    #[error("Could not obtain the current working directory")]
    CurrentDir(#[from] std::io::Error),
    #[error("Relative path \"{}\"tries to reach beyond root filesystem", directory.display())]
    InvalidInput { directory: PathBuf },
    #[error("Failed to access a directory, or path is not a directory: '{}'", .path.display())]
    InaccessibleDirectory { path: PathBuf },
    #[error("Could not find a git repository in '{}' or in any of its parents", .path.display())]
    NoGitRepository { path: PathBuf },
    #[error("Could not find a git repository in '{}' or in any of its parents within ceiling height of {}", .path.display(), .ceiling_height)]
    NoGitRepositoryWithinCeiling { path: PathBuf, ceiling_height: usize },
    #[error("Could not find a git repository in '{}' or in any of its parents within device limits below '{}'", .path.display(), .limit.display())]
    NoGitRepositoryWithinFs { path: PathBuf, limit: PathBuf },
    #[error("None of the passed ceiling directories prefixed the git-dir candidate, making them ineffective.")]
    NoMatchingCeilingDir,
    #[error("Could not find a trusted git repository in '{}' or in any of its parents, candidate at '{}' discarded", .path.display(), .candidate.display())]
    NoTrustedGitRepository {
        path: PathBuf,
        candidate: PathBuf,
        required: gix_sec::Trust,
    },
    #[error("Could not determine trust level for path '{}'.", .path.display())]
    CheckTrust {
        path: PathBuf,
        #[source]
        err: std::io::Error,
    },
}

/// Options to help guide the [discovery][crate::upwards()] of repositories, along with their options
/// when instantiated.
pub struct Options<'a> {
    /// When discovering a repository, assure it has at least this trust level or ignore it otherwise.
    ///
    /// This defaults to [`Reduced`][gix_sec::Trust::Reduced] as our default settings are geared towards avoiding abuse.
    /// Set it to `Full` to only see repositories that [are owned by the current user][gix_sec::Trust::from_path_ownership()].
    pub required_trust: gix_sec::Trust,
    /// When discovering a repository, ignore any repositories that are located in these directories or any of their parents.
    ///
    /// Note that we ignore ceiling directories if the search directory is directly on top of one, which by default is an error
    /// if `match_ceiling_dir_or_error` is true, the default.
    pub ceiling_dirs: Vec<PathBuf>,
    /// If true, default true, and `ceiling_dirs` is not empty, we expect at least one ceiling directory to
    /// contain our search dir or else there will be an error.
    pub match_ceiling_dir_or_error: bool,
    /// if `true` avoid crossing filesystem boundaries.
    /// Only supported on Unix-like systems.
    // TODO: test on Linux
    // TODO: Handle WASI once https://github.com/rust-lang/rust/issues/71213 is resolved
    pub cross_fs: bool,
    /// If true, limit discovery to `.git` directories.
    ///
    /// This  will fail to find typical bare repositories, but would find them if they happen to be named `.git`.
    /// Use this option if repos with worktrees are the only kind of repositories you are interested in for
    /// optimal discovery performance.
    pub dot_git_only: bool,
    /// If set, the _current working directory_ (absolute path) to use when resolving relative paths. Note that
    /// that this is merely an optimization for those who discover a lot of repositories in the same process.
    ///
    /// If unset, the current working directory will be obtained automatically.
    /// Note that the path here might or might not contained decomposed unicode, which may end up in a path
    /// relevant us, like the git-dir or the worktree-dir. However, when opening the repository, it will
    /// change decomposed unicode to precomposed unicode based on the value of `core.precomposeUnicode`, and we
    /// don't have to deal with that value here just yet.
    pub current_dir: Option<&'a std::path::Path>,
}

impl Default for Options<'_> {
    fn default() -> Self {
        Options {
            required_trust: gix_sec::Trust::Reduced,
            ceiling_dirs: vec![],
            match_ceiling_dir_or_error: true,
            cross_fs: false,
            dot_git_only: false,
            current_dir: None,
        }
    }
}

impl Options<'_> {
    /// Loads discovery options overrides from the environment.
    ///
    /// The environment variables are:
    /// - `GIT_CEILING_DIRECTORIES` for `ceiling_dirs`
    ///
    /// Note that `GIT_DISCOVERY_ACROSS_FILESYSTEM` for `cross_fs` is **not** read,
    /// as it requires parsing of `git-config` style boolean values.
    // TODO: test
    pub fn apply_environment(mut self) -> Self {
        let name = "GIT_CEILING_DIRECTORIES";
        if let Some(ceiling_dirs) = env::var_os(name) {
            self.ceiling_dirs = parse_ceiling_dirs(&ceiling_dirs);
        }
        self
    }
}

/// Parse a byte-string of `:`-separated paths into `Vec<PathBuf>`.
/// On Windows, paths are separated by `;`.
/// Non-absolute paths are discarded.
/// To match git, all paths are normalized, until an empty path is encountered.
pub(crate) fn parse_ceiling_dirs(ceiling_dirs: &OsStr) -> Vec<PathBuf> {
    let mut should_normalize = true;
    let mut out = Vec::new();
    for ceiling_dir in std::env::split_paths(ceiling_dirs) {
        if ceiling_dir.as_os_str().is_empty() {
            should_normalize = false;
            continue;
        }

        // Only absolute paths are allowed
        if ceiling_dir.is_relative() {
            continue;
        }

        let mut dir = ceiling_dir;
        if should_normalize {
            if let Ok(normalized) = gix_path::realpath(&dir) {
                dir = normalized;
            }
        }
        out.push(dir);
    }
    out
}

#[cfg(test)]
mod tests {

    #[test]
    #[cfg(unix)]
    fn parse_ceiling_dirs_from_environment_format() -> std::io::Result<()> {
        use std::{fs, os::unix::fs::symlink};

        use super::*;

        // Setup filesystem
        let dir = tempfile::tempdir().expect("success creating temp dir");
        let direct_path = dir.path().join("direct");
        let symlink_path = dir.path().join("symlink");
        fs::create_dir(&direct_path)?;
        symlink(&direct_path, &symlink_path)?;

        // Parse & build ceiling dirs string
        let symlink_str = symlink_path.to_str().expect("symlink path is valid utf8");
        let ceiling_dir_string = format!("{symlink_str}:relative::{symlink_str}");
        let ceiling_dirs = parse_ceiling_dirs(OsStr::new(ceiling_dir_string.as_str()));

        assert_eq!(ceiling_dirs.len(), 2, "Relative path is discarded");
        assert_eq!(
            ceiling_dirs[0],
            symlink_path.canonicalize().expect("symlink path exists"),
            "Symlinks are resolved"
        );
        assert_eq!(
            ceiling_dirs[1], symlink_path,
            "Symlink are not resolved after empty item"
        );

        dir.close()
    }

    #[test]
    #[cfg(windows)]
    fn parse_ceiling_dirs_from_environment_format() -> std::io::Result<()> {
        use std::{fs, os::windows::fs::symlink_dir};

        use super::*;

        // Setup filesystem
        let dir = tempfile::tempdir().expect("success creating temp dir");
        let direct_path = dir.path().join("direct");
        let symlink_path = dir.path().join("symlink");
        fs::create_dir(&direct_path)?;
        symlink_dir(&direct_path, &symlink_path)?;

        // Parse & build ceiling dirs string
        let symlink_str = symlink_path.to_str().expect("symlink path is valid utf8");
        let ceiling_dir_string = format!("{};relative;;{}", symlink_str, symlink_str);
        let ceiling_dirs = parse_ceiling_dirs(OsStr::new(ceiling_dir_string.as_str()));

        assert_eq!(ceiling_dirs.len(), 2, "Relative path is discarded");
        assert_eq!(ceiling_dirs[0], direct_path, "Symlinks are resolved");
        assert_eq!(
            ceiling_dirs[1], symlink_path,
            "Symlink are not resolved after empty item"
        );

        dir.close()
    }
}