git_policy_cli/candidate_record.rs
1//! What:
2//! Parse the NUL-delimited raw records of `git diff-index` and `git diff-tree`.
3//! Why:
4//! One listing process names every changed path with its mode,
5//! object and change
6//! kind,
7//! so no per-path Git process is ever needed.
8//! Pathnames are kept as the raw
9//! bytes Git printed;
10//! they are never decoded.
11//!
12//! Git 2.56.0 (`Documentation/diff-format.adoc`,
13//! "RAW OUTPUT FORMAT") prints,
14//! with `-z`:
15//! `:<old mode> SP <new mode> SP <old oid> SP <new oid> SP <status> NUL <path> NUL`.
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! // const records = parseRawRecords(stdout); // [{ path, mode, change, object }]
20//! ```
21
22/// Import the layer's failure type and its closed list of causes.
23use super::candidate_error::{CandidateError, CandidateFailure};
24/// Import the validated object name,
25/// the candidate modes and their parsers.
26use super::candidate_object::{CandidateMode, ObjectId, mode_from_git, parse_object_id};
27
28/// What:
29/// How a path differs from the baseline it was compared with.
30/// `#[derive(...)]` generates copying,
31/// debug printing and `==`.
32/// Why:
33/// A deleted path has no content to read,
34/// and policies may treat new files
35/// differently from changed ones.
36///
37/// In TS you'd write (pseudocode):
38/// ```ts
39/// type CandidateChange = 'added' | 'modified' | 'deleted';
40/// ```
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub enum CandidateChange {
43 /// The baseline lacks the path.
44 Added,
45 /// Both sides hold the path with different content,
46 /// mode or type.
47 Modified,
48 /// The baseline holds the path and the candidate state does not.
49 Deleted,
50}
51
52/// What:
53/// One listed path.
54/// `Vec<u8>` is an owned byte list (sibling `String` would require UTF-8).
55/// `Option<ObjectId>` is "an object name or nothing".
56/// Why:
57/// Git pathnames are arbitrary bytes on Unix and must round-trip exactly.
58/// A
59/// deleted path has no object on the candidate side,
60/// which the type states
61/// instead of carrying Git's all-zero placeholder name.
62///
63/// In TS you'd write (pseudocode):
64/// ```ts
65/// type CandidateRecord = { path: Buffer; mode: CandidateMode; change: CandidateChange; object?: ObjectId };
66/// ```
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct CandidateRecord {
69 /// Repository-relative pathname with `/` separators,
70 /// exactly as Git printed it.
71 pub path: Vec<u8>,
72 /// Mode on the candidate side,
73 /// or the baseline's mode for a deleted path.
74 pub mode: CandidateMode,
75 /// Change against the baseline.
76 pub change: CandidateChange,
77 /// Object on the candidate side;
78 /// absent for a deleted path.
79 pub object: Option<ObjectId>,
80}
81
82/// What:
83/// Build a failure naming the record by position.
84/// `usize` is the unsigned integer every index uses (siblings `u32`,
85/// `u64`).
86/// Why:
87/// The position identifies the entry in the same listing a person can rerun,
88/// without printing the pathname,
89/// which may itself be a forbidden string.
90///
91/// In TS you'd write (pseudocode):
92/// ```ts
93/// function recordFailure(failure: CandidateFailure, position: number, detail: string): CandidateError;
94/// ```
95fn record_failure(failure: CandidateFailure, position: usize, detail: &str) -> CandidateError {
96 return CandidateError::new(
97 failure,
98 format!(
99 "cli-git could not list candidate files: changed-path record {position} {detail}. \
100 Inspect the entries with `git diff --cached --raw` or `git diff-tree --raw -r <commit>`."
101 )
102 .as_str(),
103 );
104}
105
106/// Named predicate for splitting output into tokens;
107/// `&u8` borrows one byte.
108fn is_nul(byte: &u8) -> bool {
109 return *byte == 0;
110}
111
112/// Named predicate for splitting record metadata into fields.
113fn is_space(byte: &u8) -> bool {
114 return *byte == b' ';
115}
116
117/// What:
118/// Turn one metadata token and its path token into a record.
119/// `Result<T, E>` is "a value or a failure".
120/// Why:
121/// Only the statuses a listing without rename detection can print are
122/// interpreted:
123/// `A`,
124/// `M`,
125/// `T` (type change) and `D`.
126/// `U` is a conflicted index
127/// entry,
128/// which has no single content to check.
129/// Anything else is refused.
130///
131/// In TS you'd write (pseudocode):
132/// ```ts
133/// function parseRecord(position: number, metadata: Buffer, path: Buffer): CandidateRecord;
134/// ```
135fn parse_record(
136 position: usize,
137 metadata: &[u8],
138 path: &[u8],
139) -> Result<CandidateRecord, CandidateError> {
140 // What: `.strip_prefix(b":")` is `Some(rest)` when the token starts with a colon;
141 // `let Some(..) = .. else { .. }` unwraps it or leaves with a failure.
142 // Why: Every raw record starts with a colon; its absence means the tokens are out of step.
143 //
144 // In TS you'd write (pseudocode):
145 // ```ts
146 // if (!metadata.startsWith(':')) throw malformed();
147 // ```
148 let Some(fields_text) = metadata.strip_prefix(b":") else {
149 // `Err(...)` is the failure variant.
150 return Err(record_failure(
151 CandidateFailure::ListingMalformed,
152 position,
153 "does not start with a colon",
154 ));
155 };
156 // `.split(is_space)` cuts at every space; `.collect()` gathers the borrowed pieces.
157 let fields: Vec<&[u8]> = fields_text.split(is_space).collect();
158 if fields.len() != 5 || path.is_empty() {
159 return Err(record_failure(
160 CandidateFailure::ListingMalformed,
161 position,
162 "does not have five metadata fields and a pathname",
163 ));
164 }
165 let status: &[u8] = fields[4];
166 if status == b"U" {
167 return Err(record_failure(
168 CandidateFailure::UnmergedPath,
169 position,
170 "is an unmerged (conflicted) index entry; resolve the conflict and stage the result",
171 ));
172 }
173 let deleted: bool = status == b"D";
174 if !deleted && status != b"A" && status != b"M" && status != b"T" {
175 return Err(record_failure(
176 CandidateFailure::UnsupportedStatus,
177 position,
178 "has a change status other than A, M, T or D",
179 ));
180 }
181 // A deleted path is described by its baseline side: fields 0 and 2. Every other status uses the new side.
182 let mode_text: &[u8] = if deleted { fields[0] } else { fields[1] };
183 let Some(mode) = mode_from_git(mode_text) else {
184 return Err(record_failure(
185 CandidateFailure::UnsupportedMode,
186 position,
187 "has a file mode other than 100644, 100755, 120000 or 160000",
188 ));
189 };
190 // Both names are validated even though a deleted path keeps neither: a bad one means the output is not raw records.
191 let (Some(_old_object), Some(new_object)) =
192 (parse_object_id(fields[2]), parse_object_id(fields[3]))
193 else {
194 return Err(record_failure(
195 CandidateFailure::ListingMalformed,
196 position,
197 "does not carry two complete object names",
198 ));
199 };
200 // `None` is the "absent" variant; `Some(...)` is the "present" variant.
201 let object: Option<ObjectId> = if deleted { None } else { Some(new_object) };
202 // `Ok(...)` is the success variant; `.to_vec()` copies the borrowed path bytes into an owned list.
203 return Ok(CandidateRecord {
204 path: path.to_vec(),
205 mode,
206 change: change_of(status),
207 object,
208 });
209}
210
211/// Map an already accepted status letter to its change kind:
212/// `A` adds,
213/// `D` deletes,
214/// `M` and `T` modify.
215fn change_of(status: &[u8]) -> CandidateChange {
216 if status == b"A" {
217 return CandidateChange::Added;
218 }
219 if status == b"D" {
220 return CandidateChange::Deleted;
221 }
222 return CandidateChange::Modified;
223}
224
225/// What:
226/// Parse complete raw `-z` output into records,
227/// in Git's order.
228/// `&[u8]` borrows the output;
229/// the records own copies of what they keep.
230/// Why:
231/// Each record is two NUL-terminated tokens.
232/// Output that does not end with a NUL,
233/// or that holds an odd number of tokens,
234/// was cut short or is not raw output,
235/// and is refused whole:
236/// a partial list would leave paths unchecked.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// function parseRawRecords(output: Buffer): CandidateRecord[];
241/// ```
242pub fn parse_raw_records(output: &[u8]) -> Result<Vec<CandidateRecord>, CandidateError> {
243 // `Vec::new()` is an empty growable list; `mut` lets the loop append to it.
244 let mut records: Vec<CandidateRecord> = Vec::new();
245 if output.is_empty() {
246 return Ok(records);
247 }
248 // What: `.strip_suffix(&[0_u8])` is `Some(rest)` when the output ends with a NUL byte
249 // (`0_u8` is the number zero typed as one byte).
250 // Why: Removing the final terminator leaves tokens separated by single NULs.
251 //
252 // In TS you'd write (pseudocode):
253 // ```ts
254 // if (output.at(-1) !== 0) throw malformed();
255 // ```
256 let Some(body) = output.strip_suffix(&[0_u8]) else {
257 return Err(record_failure(
258 CandidateFailure::ListingMalformed,
259 0,
260 "is cut short: the listing does not end with a NUL byte",
261 ));
262 };
263 let tokens: Vec<&[u8]> = body.split(is_nul).collect();
264 // What: `.as_chunks::<2>()` splits the tokens into pairs (arrays of exactly two) plus
265 // whatever is left over when the count is odd; `::<2>` is the pair size.
266 // Why: A leftover token is a record without its pathname.
267 //
268 // In TS you'd write (pseudocode):
269 // ```ts
270 // if (tokens.length % 2 !== 0) throw malformed();
271 // ```
272 let (pairs, leftover): (&[[&[u8]; 2]], &[&[u8]]) = tokens.as_chunks::<2>();
273 if !leftover.is_empty() {
274 return Err(record_failure(
275 CandidateFailure::ListingMalformed,
276 pairs.len(),
277 "has no pathname token",
278 ));
279 }
280 // `.iter().enumerate()` visits each pair with its zero-based position.
281 for (position, pair) in pairs.iter().enumerate() {
282 // A trailing `?` returns the failure to our caller, or unwraps the record.
283 records.push(parse_record(position, pair[0], pair[1])?);
284 }
285 return Ok(records);
286}
287
288/// Record-format controls stay out of the release executable.
289#[cfg(test)]
290#[path = "candidate_record_tests.rs"]
291mod tests;