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;