Skip to main content

git_policy_cli/
repo_read_layout.rs

1//! What:
2//!  The repository location that `git rev-parse --path-format=absolute --is-bare-repository --git-dir --git-common-dir
3//!  --show-toplevel --show-prefix` prints, found from the file system without a Git process.
4//! Why:
5//!  Every wrapped command asks for it, and it is the only extra Git start of most forwarded commands.
6//!  The reader walks up from the directory looking for `.git`, checks what Git checks for the plain layouts
7//!  (a valid `HEAD`, the objects and refs directories, files ref storage, a known object format, the owner of every
8//!  path, no `core.bare` or `core.worktree` in any scope), and returns `None` for everything else, which sends the
9//!  caller to Git: a bare repository, `GIT_*` variables, a symbolic link, another file system, reftable, an unknown
10//!  extension, a submodule that sets `core.worktree`.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const layout: RepoLayout | undefined = readLayout({ globalPrefix, overlay }); // undefined: ask Git
15//! ```
16
17use super::child_environment::{COUNT_VARIABLE, environment_value, parse_config_count};
18use super::private_storage::read_regular_file;
19use super::repo_read_hook::enabled;
20use super::repo_read_config_scan::{ScopeEnvironment, discovery_key, entries_avoid_keys, outer_scopes_avoid_keys};
21use super::repo_read_config_text::{CONFIG_TEXT_LIMIT, ConfigEntry, last_entry, parse_bool, parse_config_text};
22use std::ffi::OsString;
23use std::path::{Path, PathBuf};
24
25/// The environment variables a plain read tolerates, by prefix; any other `GIT_` variable can move a path or a key.
26const PLAIN_ENVIRONMENT_PREFIXES: [&str; 19] = [
27    "GIT_AUTHOR_",
28    "GIT_COMMITTER_",
29    "GIT_TERMINAL_PROMPT",
30    "GIT_ASKPASS",
31    "GIT_PAGER",
32    "GIT_EDITOR",
33    "GIT_SEQUENCE_EDITOR",
34    "GIT_EXEC_PATH",
35    "GIT_SSH",
36    "GIT_ALLOW_PROTOCOL",
37    "GIT_LFS_",
38    "GIT_CONFIG_NOSYSTEM",
39    "GIT_CONFIG_GLOBAL",
40    "GIT_CONFIG_SYSTEM",
41    "GIT_CONFIG_COUNT",
42    "GIT_CONFIG_KEY_",
43    "GIT_CONFIG_VALUE_",
44    "GIT_MERGE_AUTOEDIT",
45    "GIT_OPTIONAL_LOCKS",
46];
47
48/// The longest `HEAD` or `commondir` or gitfile this reader looks at.
49const SMALL_FILE_LIMIT: u64 = 4096;
50
51/// How many environment-supplied configuration entries the reader inspects before it declines.
52const ENV_CONFIG_ENTRY_LIMIT: u64 = 1024;
53
54/// The hash algorithms of object names.
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
56pub enum ObjectFormat {
57    /// 20-byte names, 40 hexadecimal digits.
58    Sha1,
59    /// 32-byte names, 64 hexadecimal digits.
60    Sha256,
61}
62
63impl ObjectFormat {
64    /// The name `rev-parse --show-object-format` prints.
65    pub fn name(self) -> &'static str {
66        return match self {
67            ObjectFormat::Sha1 => "sha1",
68            ObjectFormat::Sha256 => "sha256",
69        };
70    }
71
72    /// The number of hexadecimal digits of one object name.
73    pub fn hex_length(self) -> usize {
74        return match self {
75            ObjectFormat::Sha1 => 40,
76            ObjectFormat::Sha256 => 64,
77        };
78    }
79}
80
81/// What:
82///  Where one worktree and its repository are, as Git reports them with absolute paths.
83/// Why:
84///   One value answers the location, the Git paths and the object format queries of a command.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// type RepoLayout = { gitDir: string; commonDir: string; worktreeRoot: string; prefix: Uint8Array; objectFormat: 'sha1' | 'sha256' };
89/// ```
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct RepoLayout {
92    /// The real path of the worktree's Git directory.
93    pub git_dir: PathBuf,
94    /// The real path of the directory that holds the objects and the shared refs.
95    pub common_dir: PathBuf,
96    /// The real path of the worktree's top level.
97    pub worktree_root: PathBuf,
98    /// The start directory below the top level, with a trailing `/`, or empty at the top level.
99    pub prefix: Vec<u8>,
100    /// The object hash of the repository.
101    pub object_format: ObjectFormat,
102}
103
104/// The keys an environment-supplied configuration entry may not set without the reader declining.
105fn env_key_moves_discovery(key: &str) -> bool {
106    let lowered: String = key.to_ascii_lowercase();
107    return lowered == "core.bare" || lowered == "core.worktree" || lowered.starts_with("include");
108}
109
110/// Whether the `GIT_CONFIG_COUNT` entries of `variables` (the wrapper injects some) all leave discovery alone.
111fn env_config_is_plain(variables: &[(OsString, OsString)]) -> bool {
112    return env_config_avoids(variables, &env_key_moves_discovery);
113}
114
115/// What:
116///  Whether every environment-supplied configuration entry has a key `declines` does not name;
117///  a malformed set of entries is not plain either.
118/// Why:
119///   The content queries use the same walk with their own key list, so an injected `diff.orderFile` is not missed.
120///
121/// In TS you'd write (pseudocode):
122/// ```ts
123/// envConfigKeys(variables).every((key) => !declines(key)); // false when malformed
124/// ```
125pub fn env_config_avoids(variables: &[(OsString, OsString)], declines: &dyn Fn(&str) -> bool) -> bool {
126    let Some(count_text) = environment_value(variables, COUNT_VARIABLE) else {
127        return true;
128    };
129    let Some(count) = parse_config_count(count_text.as_os_str()) else {
130        return false;
131    };
132    if count > ENV_CONFIG_ENTRY_LIMIT {
133        return false;
134    }
135    for index in 0..count {
136        let Some(key) = environment_value(variables, format!("GIT_CONFIG_KEY_{index}").as_str()) else {
137            return false;
138        };
139        match key.to_str() {
140            Some(text) if !declines(text) => {}
141            _ => return false,
142        }
143    }
144    return true;
145}
146
147/// Whether every `GIT_` variable among `variables` is one that cannot move discovery.
148fn environment_is_plain(variables: &[(OsString, OsString)]) -> bool {
149    for (name, _) in variables {
150        let Some(text) = name.to_str() else {
151            continue;
152        };
153        if text.starts_with("GIT_") && !PLAIN_ENVIRONMENT_PREFIXES.iter().any(|prefix| { return text.starts_with(prefix); }) {
154            return false;
155        }
156    }
157    return env_config_is_plain(variables);
158}
159
160/// The environment Git would see: the process environment with the child overlay applied over it.
161fn effective_environment(overlay: &[(OsString, OsString)]) -> Vec<(OsString, OsString)> {
162    let mut variables: Vec<(OsString, OsString)> = std::env::vars_os()
163        .filter(|(name, _)| { return !overlay.iter().any(|(added, _)| { return added == name; }); })
164        .collect();
165    variables.extend(overlay.iter().cloned());
166    return variables;
167}
168
169/// The directory a command starts in: the process directory moved by leading `-C <path>` options.
170///  Any other global option returns `None`.
171fn start_directory(global_prefix: &[OsString]) -> Option<PathBuf> {
172    let mut directory: PathBuf = std::env::current_dir().ok()?;
173    let mut options = global_prefix.iter();
174    while let Some(option) = options.next() {
175        if option != "-C" {
176            return None;
177        }
178        let path: &OsString = options.next()?;
179        if !path.is_empty() {
180            directory = directory.join(path);
181        }
182        // Git changes directory at each `-C` in turn and fails at the first one that is not a directory.
183        if !std::fs::metadata(&directory).is_ok_and(|found| { return found.is_dir(); }) {
184            return None;
185        }
186    }
187    return std::fs::canonicalize(directory).ok();
188}
189
190/// A small regular file's text, refusing links.
191fn small_file(path: &Path) -> Option<String> {
192    let bytes: Vec<u8> = read_regular_file(path, SMALL_FILE_LIMIT).ok()?;
193    return String::from_utf8(bytes).ok();
194}
195
196/// Whether `directory` holds the three entries Git takes for a Git directory.
197fn has_git_directory_entries(directory: &Path) -> bool {
198    return directory.join("HEAD").exists() && directory.join("objects").is_dir() && directory.join("refs").is_dir();
199}
200
201/// Whether the `HEAD` text is one of the two spellings Git accepts that this reader knows.
202fn head_is_valid(text: &str, format: ObjectFormat) -> bool {
203    let Some(line) = text.strip_suffix('\n') else {
204        return false;
205    };
206    if let Some(name) = line.strip_prefix("ref: ") {
207        return name.starts_with("refs/") && !name.contains('\n');
208    }
209    return line.len() == format.hex_length() && line.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
210}
211
212/// Whether `path` is owned by the effective user, as Git's ownership check requires.
213#[cfg(unix)]
214fn owned_by_current_user(path: &Path) -> bool {
215    use std::os::unix::fs::MetadataExt;
216    let Ok(metadata) = std::fs::symlink_metadata(path) else {
217        return false;
218    };
219    // SAFETY: `geteuid` takes no argument, touches no memory and cannot fail.
220    return metadata.uid() == unsafe { libc::geteuid() };
221}
222
223/// Without user ids, every repository is left to Git.
224#[cfg(not(unix))]
225fn owned_by_current_user(_path: &Path) -> bool {
226    return false;
227}
228
229/// The device a directory lives on, so a walk can stop where Git stops.
230#[cfg(unix)]
231fn device_of(path: &Path) -> Option<u64> {
232    use std::os::unix::fs::MetadataExt;
233    return std::fs::metadata(path).ok().map(|metadata| { return metadata.dev(); });
234}
235
236/// Without device numbers, every walk is left to Git.
237#[cfg(not(unix))]
238fn device_of(_path: &Path) -> Option<u64> {
239    return None;
240}
241
242/// The Git directory a `.git` entry names: the entry itself when it is a directory,
243///  the `gitdir:` line of a plain file, `None` for a link or anything else.
244fn dot_git_target(worktree: &Path) -> Option<Option<(PathBuf, bool)>> {
245    let dot_git: PathBuf = worktree.join(".git");
246    let metadata = match std::fs::symlink_metadata(&dot_git) {
247        Ok(found) => found,
248        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(None),
249        Err(_) => return None,
250    };
251    if metadata.is_dir() {
252        return Some(Some((dot_git, false)));
253    }
254    if !metadata.is_file() {
255        return None;
256    }
257    let text: String = small_file(dot_git.as_path())?;
258    let target: &str = text.strip_prefix("gitdir: ")?.strip_suffix('\n')?;
259    if target.is_empty() || target.contains('\n') {
260        return None;
261    }
262    return Some(Some((worktree.join(target), true)));
263}
264
265/// What:
266///  The repository format keys the layout needs from the repository's own configuration file.
267/// Why:
268///   The object format decides the length of a name; any other extension may change how the repository is read.
269///
270/// In TS you'd write (pseudocode):
271/// ```ts
272/// const format: ObjectFormat | undefined = readFormat(entries); // undefined: ask Git
273/// ```
274fn object_format_of(entries: &[ConfigEntry]) -> Option<ObjectFormat> {
275    let version: Option<&[u8]> = last_entry(entries, "core.repositoryformatversion").and_then(|entry| { return entry.value.as_deref(); });
276    let version_one: bool = match version {
277        None | Some(b"0") => false,
278        Some(b"1") => true,
279        Some(_) => return None,
280    };
281    let mut format: ObjectFormat = ObjectFormat::Sha1;
282    for entry in entries.iter().filter(|entry| { return entry.key.starts_with("extensions."); }) {
283        if !version_one {
284            return None;
285        }
286        let value: &[u8] = entry.value.as_deref().unwrap_or_default();
287        match (entry.key.as_str(), value) {
288            ("extensions.noop", _) => {}
289            ("extensions.preciousobjects", _) if parse_bool(entry.value.as_deref()).is_some() => {}
290            ("extensions.objectformat", b"sha1") => format = ObjectFormat::Sha1,
291            ("extensions.objectformat", b"sha256") => format = ObjectFormat::Sha256,
292            ("extensions.refstorage", b"files") => {}
293            _ => return None,
294        }
295    }
296    return Some(format);
297}
298
299/// What:
300///  Check the discovered Git directory and build the layout.
301/// Why:
302///   Everything beyond the plain layout returns `None`.
303///
304/// In TS you'd write (pseudocode):
305/// ```ts
306/// function finish(start: string, worktree: string, gitDir: string, fromFile: boolean): RepoLayout | undefined;
307/// ```
308fn finish(start: &Path, worktree: &Path, entry: (PathBuf, bool), scope: &ScopeEnvironment) -> Option<RepoLayout> {
309    let (target, from_file) = entry;
310    let git_dir: PathBuf = std::fs::canonicalize(target).ok()?;
311    let dot_git: PathBuf = worktree.join(".git");
312    let owners_ok: bool = owned_by_current_user(worktree)
313        && owned_by_current_user(git_dir.as_path())
314        && (!from_file || owned_by_current_user(dot_git.as_path()));
315    if !owners_ok {
316        return None;
317    }
318    let common_dir: PathBuf = match small_file(git_dir.join("commondir").as_path()) {
319        Some(text) => std::fs::canonicalize(git_dir.join(text.strip_suffix('\n')?)).ok()?,
320        None if git_dir.join("commondir").exists() => return None,
321        None => git_dir.clone(),
322    };
323    if git_dir.join("reftable").exists() || common_dir.join("reftable").exists() || !has_git_directory_entries_split(&git_dir, &common_dir) {
324        return None;
325    }
326    let config_bytes: Vec<u8> = read_regular_file(common_dir.join("config").as_path(), CONFIG_TEXT_LIMIT as u64).ok()?;
327    let entries: Vec<ConfigEntry> = parse_config_text(config_bytes.as_slice())?;
328    let object_format: ObjectFormat = object_format_of(entries.as_slice())?;
329
330    // The repository's own file carries `bare = false` after `init`; that spelling is the plain layout.
331    let discoverable: Vec<ConfigEntry> = entries
332        .iter()
333        .filter(|candidate| { return !(candidate.key == "core.bare" && parse_bool(candidate.value.as_deref()) == Some(false)); })
334        .cloned()
335        .collect();
336    let local_ok: bool = entries_avoid_keys(discoverable.as_slice(), common_dir.join("config").as_path(), scope.home.as_deref(), 0, &discovery_key)?;
337    let worktree_config: bool = last_entry(entries.as_slice(), "extensions.worktreeconfig").is_some();
338    if !local_ok || worktree_config || !outer_scopes_avoid_keys(scope, &discovery_key)? {
339        return None;
340    }
341    let head: String = small_file(git_dir.join("HEAD").as_path())?;
342    if !head_is_valid(head.as_str(), object_format) {
343        return None;
344    }
345    let relative: &Path = start.strip_prefix(worktree).ok()?;
346    let mut prefix: Vec<u8> = Vec::new();
347    for component in relative.components() {
348        prefix.extend_from_slice(component.as_os_str().as_encoded_bytes());
349        prefix.push(b'/');
350    }
351    return Some(RepoLayout { git_dir, common_dir, worktree_root: worktree.to_path_buf(), prefix, object_format });
352}
353
354/// The three-entry check, with `HEAD` in the worktree's directory and the rest in the common directory.
355fn has_git_directory_entries_split(git_dir: &Path, common_dir: &Path) -> bool {
356    return git_dir.join("HEAD").exists() && common_dir.join("objects").is_dir() && common_dir.join("refs").is_dir();
357}
358
359/// What:
360///  The layout of the repository a command run with `global_prefix` and `overlay` starts in,
361///  or `None` when Git must be asked.
362/// Why:
363///   This is the one entry point the callers use; it never errors, because "cannot tell" and "not a repository" both
364///  mean "ask Git", which words the failure itself.
365///
366/// In TS you'd write (pseudocode):
367/// ```ts
368/// const layout = readLayout(globalPrefix, overlay); // undefined: ask Git
369/// ```
370pub fn read_layout(global_prefix: &[OsString], overlay: &[(OsString, OsString)]) -> Option<RepoLayout> {
371    if !enabled(overlay) || !environment_is_plain(effective_environment(overlay).as_slice()) {
372        return None;
373    }
374    let start: PathBuf = start_directory(global_prefix)?;
375    return walk_up(start.as_path(), &ScopeEnvironment::from_variables(effective_environment(overlay).as_slice()));
376}
377
378/// What:
379///  The read with its environment injected: the variables Git would see and the configuration scope facts.
380/// Why:
381///   The controls run in a process whose own environment and home they must not depend on.
382///
383/// In TS you'd write (pseudocode):
384/// ```ts
385/// const layout = readLayoutWith(start, variables, scope); // undefined: ask Git
386/// ```
387pub fn read_layout_with(start: &Path, variables: &[(OsString, OsString)], scope: &ScopeEnvironment) -> Option<RepoLayout> {
388    if !environment_is_plain(variables) {
389        return None;
390    }
391    return walk_up(std::fs::canonicalize(start).ok()?.as_path(), scope);
392}
393
394/// What:
395///  The same read for a command run in `directory` without global options.
396/// Why:
397///   The transaction Git requests name their directory and leave the invocation's global options out.
398///
399/// In TS you'd write (pseudocode):
400/// ```ts
401/// const layout = readLayoutIn(directory, overlay); // undefined: ask Git
402/// ```
403pub fn read_layout_in(directory: &Path, overlay: &[(OsString, OsString)]) -> Option<RepoLayout> {
404    if !enabled(overlay) {
405        return None;
406    }
407    let variables: Vec<(OsString, OsString)> = effective_environment(overlay);
408    return read_layout_with(directory, variables.as_slice(), &ScopeEnvironment::from_variables(variables.as_slice()));
409}
410
411/// Walk from `start` to the root looking for the first `.git`, as Git does, and build the layout there.
412fn walk_up(start: &Path, scope: &ScopeEnvironment) -> Option<RepoLayout> {
413    let mut directory: PathBuf = start.to_path_buf();
414    loop {
415        if let Some(entry) = dot_git_target(directory.as_path())? {
416            return finish(start, directory.as_path(), entry, scope);
417        }
418        if has_git_directory_entries(directory.as_path()) {
419            return None;
420        }
421        let parent: PathBuf = directory.parent()?.to_path_buf();
422        if device_of(parent.as_path())? != device_of(directory.as_path())? {
423            return None;
424        }
425        directory = parent;
426    }
427}
428
429/// What:
430///  The absolute path `git rev-parse --path-format=absolute --git-path <name>` prints for the names this reader knows
431///  in a repository whose worktree owns its Git directory.
432/// Why:
433///   A linked worktree splits names between its own directory and the common one by a table in Git;
434///  those answers stay with Git.
435///
436/// In TS you'd write (pseudocode):
437/// ```ts
438/// const gitPath = (layout, name) => layout.gitDir === layout.commonDir && KNOWN.has(name) ? join(layout.gitDir, name) : undefined;
439/// ```
440pub fn git_path(layout: &RepoLayout, name: &str) -> Option<PathBuf> {
441    const KNOWN: [&str; 9] = [
442        "index",
443        "objects",
444        "HEAD",
445        "MERGE_HEAD",
446        "CHERRY_PICK_HEAD",
447        "REVERT_HEAD",
448        "cli-git-transactions",
449        "packed-refs",
450        "config",
451    ];
452    if layout.git_dir != layout.common_dir || !KNOWN.contains(&name) {
453        return None;
454    }
455    let path: PathBuf = layout.git_dir.join(name);
456    // With `--path-format=absolute` Git prints the target of a link, so a linked name is left to Git.
457    if std::fs::symlink_metadata(path.as_path()).is_ok_and(|metadata| { return metadata.file_type().is_symlink(); }) {
458        return None;
459    }
460    return Some(path);
461}
462
463#[cfg(test)]
464#[path = "repo_read_layout_tests.rs"]
465mod tests;