Skip to main content

git_policy_cli/
ref_files.rs

1//! What:
2//!  Reads of the files ref storage that answer a question without a Git process,
3//!  and refuse to answer anything they cannot prove equal to Git's answer.
4//! Why:
5//!  `git symbolic-ref --quiet HEAD` only reads the worktree's `HEAD` file and,
6//!  for a branch, checks that the branch is not itself a symbolic ref.
7//!  A strict reader of the plain spellings saves a process on every commit,
8//!  and every other spelling (a different ref name shape, a link,
9//!  a symbolic chain,
10//!  reftable storage) returns `None`,
11//!  which sends the caller to Git.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const head = readHeadFile(layout); // undefined means "ask Git"
16//! ```
17
18/// Bounded no-follow reading of a regular file.
19use super::private_storage::read_regular_file;
20/// The ref storage backend and the symbolic `HEAD` answer.
21use super::transaction_journal::{RefFormat, SymbolicHead};
22/// Borrowed filesystem paths.
23use std::path::Path;
24
25/// The longest `HEAD` file this reader looks at;
26///  a real one is a short line.
27const HEAD_LIMIT: u64 = 512;
28
29/// The longest loose branch file this reader looks at;
30///  a real one is an object name and a newline.
31const LOOSE_REF_LIMIT: u64 = 256;
32
33/// What:
34///  Where one worktree's ref files live and what format they are in.
35/// Why:
36///   The per-worktree `HEAD` is in the Git directory,
37///  the branches are in the common directory,
38///  and the length of an object name depends on the object format.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type RefFilesLayout = { gitDir: string; commonDir: string; refFormat: 'files' | 'reftable'; objectFormat: string };
43/// ```
44#[derive(Clone, Copy, Debug)]
45pub struct RefFilesLayout<'a> {
46    /// Absolute Git directory of the owning worktree.
47    pub git_dir: &'a Path,
48    /// Absolute common Git directory.
49    pub common_dir: &'a Path,
50    /// Ref storage backend.
51    pub ref_format: RefFormat,
52    /// Object hash algorithm name as `rev-parse --show-object-format` spells it.
53    pub object_format: &'a str,
54}
55
56/// What:
57///  The number of hexadecimal digits of an object name under an object format,
58///  or `None` for a format this reader does not know.
59/// Why:
60///   Git accepts a detached `HEAD` only with the repository's own name length.
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// ({ sha1: 40, sha256: 64 } as const)[objectFormat]
65/// ```
66fn hex_length(object_format: &str) -> Option<usize> {
67    return match object_format {
68        "sha1" => Some(40),
69        "sha256" => Some(64),
70        _ => None,
71    };
72}
73
74/// What:
75///  Whether `text` is exactly one object name of `length` lowercase hexadecimal digits that is not the null name.
76/// Why:
77///   Git also accepts uppercase digits and trailing spaces;
78///  refusing them only costs a fallback,
79///  and the null name has a meaning of its own.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// new RegExp(`^[0-9a-f]{${length}}$`).test(text) && !/^0+$/.test(text)
84/// ```
85fn is_object_name(text: &str, length: usize) -> bool {
86    if text.len() != length {
87        return false;
88    }
89    let all_hex: bool = text.bytes().all(|byte| { return matches!(byte, b'0'..=b'9' | b'a'..=b'f'); });
90    let all_zero: bool = text.bytes().all(|byte| { return byte == b'0'; });
91    return all_hex && !all_zero;
92}
93
94/// What:
95///  Whether `name` is a branch ref name of a plain, always valid shape:
96///  `refs/heads/` and then components of ASCII letters,
97///  digits, `.`, `_` and `-`.
98/// Why:
99///   Git refuses names with a component that starts with `.` or ends with `.lock`,
100///  a `..`, an empty component or a trailing `.`;
101///  the plain alphabet needs no other rule,
102///  and any other spelling is left to Git.
103///
104/// In TS you'd write (pseudocode):
105/// ```ts
106/// /^refs\/heads\/([A-Za-z0-9_-][A-Za-z0-9._-]*)(\/[A-Za-z0-9_-][A-Za-z0-9._-]*)*$/.test(name) && ...
107/// ```
108pub fn is_plain_branch_name(name: &str) -> bool {
109    let Some(rest) = name.strip_prefix("refs/heads/") else {
110        return false;
111    };
112    if rest.is_empty() || rest.ends_with('.') || rest.contains("..") {
113        return false;
114    }
115    for component in rest.split('/') {
116        if component.is_empty() || component.starts_with('.') || component.ends_with(".lock") {
117            return false;
118        }
119        let plain: bool = component
120            .bytes()
121            .all(|byte| { return byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'); });
122        if !plain {
123            return false;
124        }
125    }
126    return true;
127}
128
129/// What:
130///  Whether the loose file of a branch, if there is one, is a plain object name.
131/// Why:
132///   `symbolic-ref` follows a branch that is itself a symbolic ref and fails on a broken one;
133///  a missing loose file is an unborn or packed branch,
134///  and packed refs cannot be symbolic.
135///
136/// In TS you'd write (pseudocode):
137/// ```ts
138/// const text = await readFile(join(commonDir, name)).catch(notFound); return text === undefined || isObjectName(text.trimEnd('\n'));
139/// ```
140fn loose_branch_is_plain(layout: &RefFilesLayout<'_>, name: &str, length: usize) -> bool {
141    let path = layout.common_dir.join(name);
142    let metadata: std::fs::Metadata = match std::fs::symlink_metadata(&path) {
143        Ok(found) => found,
144        Err(error) => return error.kind() == std::io::ErrorKind::NotFound,
145    };
146    if !metadata.is_file() {
147        return false;
148    }
149    let Ok(bytes) = read_regular_file(path.as_path(), LOOSE_REF_LIMIT) else {
150        return false;
151    };
152    let Ok(text) = std::str::from_utf8(bytes.as_slice()) else {
153        return false;
154    };
155    let Some(line) = text.strip_suffix('\n') else {
156        return false;
157    };
158    return is_object_name(line, length);
159}
160
161/// What:
162///  `git symbolic-ref --quiet HEAD` answered from the worktree's `HEAD` file,
163///  or `None` when the file is not one of the two plain spellings.
164/// Why:
165///   `ref: <plain branch>` and a lone object name are the only shapes the commit path meets;
166///  the file is read afresh on every call,
167///  so a landing that calls this under its lock sees the file as it is then.
168///
169/// In TS you'd write (pseudocode):
170/// ```ts
171/// const text = await readFile(join(gitDir, 'HEAD'), 'utf8'); // 'ref: refs/heads/x\n' or '<oid>\n'
172/// ```
173pub fn read_symbolic_head(layout: &RefFilesLayout<'_>) -> Option<SymbolicHead> {
174    if layout.ref_format != RefFormat::Files {
175        return None;
176    }
177    let length: usize = hex_length(layout.object_format)?;
178    let bytes: Vec<u8> = read_regular_file(layout.git_dir.join("HEAD").as_path(), HEAD_LIMIT).ok()?;
179    let text: &str = std::str::from_utf8(bytes.as_slice()).ok()?;
180    let line: &str = text.strip_suffix('\n')?;
181    if let Some(name) = line.strip_prefix("ref: ") {
182        if is_plain_branch_name(name) && loose_branch_is_plain(layout, name, length) {
183            return Some(SymbolicHead::Branch(String::from(name)));
184        }
185        return None;
186    }
187    if is_object_name(line, length) {
188        return Some(SymbolicHead::Detached);
189    }
190    return None;
191}
192
193/// Files ref reader controls stay out of the release executable.
194#[cfg(test)]
195#[path = "ref_files_tests.rs"]
196pub(crate) mod tests;