Skip to main content

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;