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