Skip to main content

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;