git_policy_cli/candidate_tree.rs
1//! What: Parse the NUL-delimited records of `git ls-tree -r -z --full-tree <commit>`: every
2//! file of one commit with its mode and object.
3//! Why: The dependent-version policy compares the candidate state with `HEAD`: it reads
4//! each workspace manifest's `HEAD` bytes, and a direct fix may change a tracked file
5//! it did not select only when `HEAD` holds that file as the same ordinary blob.
6//! One listing answers both for every path. Pathnames stay the raw bytes Git printed.
7//!
8//! Git 2.56.0 (`builtin/ls-tree.c`, `show_tree_default`) prints, with `-r -z`:
9//! `<mode> SP <type> SP <object> TAB <path> NUL`.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! // const records = parseTreeRecords(stdout); const head = new Map(records.map((r) => [r.path, r]));
14//! ```
15
16/// Import the layer's failure type and its closed list of causes.
17use super::candidate_error::{CandidateError, CandidateFailure};
18/// Import the candidate modes, the validated object name and their parsers.
19use super::candidate_object::{CandidateMode, ObjectId, mode_from_git, parse_object_id};
20
21/// What: One file of a commit's tree. `#[derive(...)]` generates cloning, debug printing
22/// and `==`.
23/// Why: A record holds what a comparison with an index entry needs: mode and object.
24///
25/// In TS you'd write (pseudocode):
26/// ```ts
27/// type TreeRecord = { mode: CandidateMode; object: ObjectId; path: Buffer };
28/// ```
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct TreeRecord {
31 /// The file's mode.
32 pub mode: CandidateMode,
33 /// The blob, or for a submodule the commit, the entry names.
34 pub object: ObjectId,
35 /// Repository-relative pathname, exactly as Git printed it.
36 pub path: Vec<u8>,
37}
38
39/// What: Build the failure for one malformed tree record. `position` is the record's
40/// zero-based place in the listing.
41/// Why: A record is named by its position, never by its pathname, which can itself be a
42/// forbidden string.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// function treeFailure(failure: CandidateFailure, position: number, detail: string): CandidateError;
47/// ```
48fn tree_failure(failure: CandidateFailure, position: usize, detail: &str) -> CandidateError {
49 return CandidateError::new(
50 failure,
51 format!(
52 "cli-git could not read the HEAD commit's files: tree record {position} {detail}. \
53 Inspect the entries with `git ls-tree -r HEAD`."
54 )
55 .as_str(),
56 );
57}
58
59/// What: Whether one byte is NUL, the record terminator.
60/// Why: A named predicate for `split`, because the repository bans anonymous functions.
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// const isNul = (byte: number) => byte === 0;
65/// ```
66fn is_nul(byte: &u8) -> bool {
67 return *byte == 0;
68}
69
70/// What: Whether one byte is a space, the metadata field separator.
71/// Why: A named predicate for `split`.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// const isSpace = (byte: number) => byte === 0x20;
76/// ```
77fn is_space(byte: &u8) -> bool {
78 return *byte == b' ';
79}
80
81/// What: Whether one byte is a tab, which ends the metadata.
82/// Why: A named predicate for `position`.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// const isTab = (byte: number) => byte === 0x09;
87/// ```
88fn is_tab(byte: &u8) -> bool {
89 return *byte == b'\t';
90}
91
92/// What: The object type Git prints for an entry of `mode`.
93/// Why: A recursive listing names files only: a submodule is a commit, every other file
94/// a blob. Any other pairing means the output is not such a listing.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// const expectedType = (mode: CandidateMode) => mode === 'gitlink' ? 'commit' : 'blob';
99/// ```
100fn expected_type(mode: CandidateMode) -> &'static [u8] {
101 match mode {
102 CandidateMode::Gitlink => return b"commit",
103 CandidateMode::Regular | CandidateMode::Executable | CandidateMode::Symlink => {
104 return b"blob";
105 }
106 }
107}
108
109/// What: Parse one record from its zero-based position and its bytes, terminator removed.
110/// Why: The first tab ends the metadata; the pathname is every byte after it, tabs
111/// included, and must not be empty.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// function parseTreeRecord(position: number, record: Buffer): TreeRecord;
116/// ```
117fn parse_tree_record(position: usize, record: &[u8]) -> Result<TreeRecord, CandidateError> {
118 let Some(tab) = record.iter().position(is_tab) else {
119 return Err(tree_failure(
120 CandidateFailure::ListingMalformed,
121 position,
122 "has no tab before its pathname",
123 ));
124 };
125 // `&record[..tab]` borrows the bytes before the tab; `&record[tab + 1..]` those after it.
126 let path: &[u8] = &record[tab + 1..];
127 let fields: Vec<&[u8]> = record[..tab].split(is_space).collect();
128 if fields.len() != 3 || path.is_empty() {
129 return Err(tree_failure(
130 CandidateFailure::ListingMalformed,
131 position,
132 "does not have three metadata fields and a pathname",
133 ));
134 }
135 let Some(mode) = mode_from_git(fields[0]) else {
136 return Err(tree_failure(
137 CandidateFailure::UnsupportedMode,
138 position,
139 "has a file mode other than 100644, 100755, 120000 or 160000",
140 ));
141 };
142 if fields[1] != expected_type(mode) {
143 return Err(tree_failure(
144 CandidateFailure::ListingMalformed,
145 position,
146 "has an object type that does not match its mode",
147 ));
148 }
149 let Some(object) = parse_object_id(fields[2]) else {
150 return Err(tree_failure(
151 CandidateFailure::ListingMalformed,
152 position,
153 "does not carry a complete object name",
154 ));
155 };
156 // `.to_vec()` copies the borrowed pathname into storage the record owns.
157 return Ok(TreeRecord {
158 mode,
159 object,
160 path: path.to_vec(),
161 });
162}
163
164/// What: Parse a complete `git ls-tree -r -z` listing.
165/// `Result<Vec<TreeRecord>, CandidateError>` is "every record, or the first failure".
166/// Why: A listing that does not end with NUL was cut short; a partial list would read a
167/// file `HEAD` holds as new, so the whole listing is refused.
168///
169/// In TS you'd write (pseudocode):
170/// ```ts
171/// function parseTreeRecords(output: Buffer): TreeRecord[];
172/// ```
173pub fn parse_tree_records(output: &[u8]) -> Result<Vec<TreeRecord>, CandidateError> {
174 let mut records: Vec<TreeRecord> = Vec::new();
175 if output.is_empty() {
176 return Ok(records);
177 }
178 // `.strip_suffix(&[0_u8])` is `Some(rest)` when the output ends with a NUL byte.
179 let Some(body) = output.strip_suffix(&[0_u8]) else {
180 return Err(tree_failure(
181 CandidateFailure::ListingMalformed,
182 0,
183 "is cut short: the listing does not end with a NUL byte",
184 ));
185 };
186 // `.split(is_nul).enumerate()` yields each record with its zero-based position.
187 for (position, record) in body.split(is_nul).enumerate() {
188 records.push(parse_tree_record(position, record)?);
189 }
190 return Ok(records);
191}
192
193/// Parser controls stay out of the release executable.
194#[cfg(test)]
195#[path = "candidate_tree_tests.rs"]
196mod tests;