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