Skip to main content

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;