Skip to main content

git_policy_cli/candidate/
candidate_attribute.rs

1//! What:
2//!  Which candidate paths have their `text` Git attribute unset (`-text`, which the `binary` macro includes),
3//!  resolved by `git check-attr` against the same state the candidates were listed from.
4//! Why:
5//!  A path whose `text` attribute is unset declares that its exact bytes matter,
6//!  as a fuzz seed's trailing bytes do.
7//!  The attribute lives in the package's own `.gitattributes`,
8//!  so it moves with the package,
9//!  unlike a path list kept elsewhere.
10//!  Staged and projected candidates are answered from the private index,
11//!  so a `.gitattributes` staged by the same command applies to it
12//!  and an unstaged worktree edit does not;
13//!  landed and pushed candidates are answered from the tree of the commit they came from.
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! // const unset = await attributes.textUnset([Buffer.from('seed/case')]);
18//! ```
19
20/// Import the layer's failure type and its causes.
21use crate::candidate_error::{CandidateError, CandidateFailure};
22/// Import the validated object name a tree source is named by.
23use crate::candidate_object::ObjectId;
24/// Import the captured-query runner and the byte helpers.
25use crate::git_metadata::{MetadataOutput, path_from_git_bytes, run_metadata_git, strip_git_line};
26/// What:
27///  `HashMap<K, V>` is a key-to-value table and `HashSet<T>` a collection of distinct values.
28/// Why:
29///   Paths are grouped by the tree that answers for them and returned as a set to test membership.
30///
31/// In TS you'd write (pseudocode):
32/// ```ts
33/// const byTree = new Map<string, Buffer[]>(); const unset = new Set<string>();
34/// ```
35use std::collections::{HashMap, HashSet};
36/// Names and paths of the Git child.
37use std::ffi::OsString;
38/// `PathBuf` is an owned filesystem path.
39use std::path::PathBuf;
40
41/// How many paths one `git check-attr` command names,
42///  as many as one pathspec command of the candidate layer.
43///  Exempt from a citation under `LQM` (class: chunk size): it splits one command into several starts of the same
44///  command, so the result is the same and only the number of starts changes. Classed once in
45///  `doc/planning/cli-git-shipped-limits-audit.md`, "Exempt limits"; the coordinator's pick under the owner's delegation,
46///  open to the owner's veto.
47pub const ATTRIBUTE_CHUNK_SIZE: usize = crate::candidate_prediction::PATHSPEC_CHUNK_SIZE;
48
49/// What:
50///  Where the attributes of the candidates are read from.
51///  `Index` is the private index the candidates were prepared on;
52///  `Tree` is one commit or tree for every candidate;
53///  `PerPath` names the commit or tree each path was first listed from.
54/// Why:
55///   Staged candidates are judged by what is staged.
56///  A landed commit is judged by its own tree,
57///  and a push lists several commits and trees,
58///  each of which is judged by itself.
59///
60/// In TS you'd write (pseudocode):
61/// ```ts
62/// type AttributeSource = { kind: 'index' } | { kind: 'tree'; tree: ObjectId } | { kind: 'per-path'; trees: Map<string, ObjectId> };
63/// ```
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub enum AttributeSource {
66    /// The `.gitattributes` files of the private index.
67    Index,
68    /// The `.gitattributes` files of one commit or tree.
69    Tree(ObjectId),
70    /// The commit or tree each pathname was first listed from.
71    PerPath(HashMap<Vec<u8>, ObjectId>),
72}
73
74/// What:
75///  The resolver of the `text` attribute for one prepared set of candidates.
76///  It owns copies of the Git executable,
77///  the global options and the child environment,
78///  which already name the private index.
79/// Why:
80///   The policy asks lazily,
81///  after the prepared candidates were handed over,
82///  so the resolver cannot borrow the provider that prepared them.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// class CandidateAttributes { constructor(git, prefix, overlay, source) {} textUnset(paths: Buffer[]): Promise<Set<string>> {} }
87/// ```
88#[derive(Clone, Debug)]
89pub struct CandidateAttributes {
90    /// The selected real Git executable.
91    real_git: PathBuf,
92    /// The arguments before the subcommand.
93    global_prefix: Vec<OsString>,
94    /// The child environment additions,
95    ///  including `GIT_INDEX_FILE` of the private index.
96    overlay: Vec<(OsString, OsString)>,
97    /// Where the attributes are read from.
98    source: AttributeSource,
99}
100
101/// What:
102///  The failure of an attribute lookup,
103///  worded like the layer's other Git failures and naming no candidate.
104/// Why:
105///   The message reaches the person as the policy's failure.
106///
107/// In TS you'd write (pseudocode):
108/// ```ts
109/// const attributeFailure = (failure, detail) => new CandidateError(failure, `cli-git could not read the text attribute of candidate files: ${detail}`);
110/// ```
111fn attribute_failure(failure: CandidateFailure, detail: &str) -> CandidateError {
112    return CandidateError::new(
113        failure,
114        format!("cli-git could not read the text attribute of candidate files: {detail}").as_str(),
115    );
116}
117
118/// What:
119///  Parse the `-z` output of `git check-attr text` for the paths asked about,
120///  and return the asked paths whose value is `unset`.
121///  `asked` holds the arguments given to Git (the top level and the path),
122///  `paths` the candidate pathnames in the same order.
123/// Why:
124///   Each record is three NUL-terminated fields,
125///  path, attribute name and value,
126///  in the order asked.
127///  Only the exact value `unset` preserves a file:
128///  `set`, `auto`, `unspecified` and any other value leave it checked.
129///  A record that does not match what was asked is an error,
130///  never a default to text or to binary.
131///
132/// In TS you'd write (pseudocode):
133/// ```ts
134/// function parseTextUnset(output: Buffer, asked: Buffer[], paths: Buffer[]): Buffer[]; // throws CandidateError
135/// ```
136pub fn parse_text_unset(
137    output: &[u8],
138    asked: &[Vec<u8>],
139    paths: &[&[u8]],
140) -> Result<Vec<Vec<u8>>, CandidateError> {
141    // `.split(..)` yields the NUL-separated fields; the last one is empty after the final terminator.
142    let mut fields: Vec<&[u8]> = output.split(|byte| { return *byte == 0; }).collect();
143    if fields.pop() != Some(b"".as_slice()) || fields.len() != asked.len() * 3 {
144        return Err(attribute_failure(
145            CandidateFailure::ListingMalformed,
146            "git check-attr did not print one record for each path it was asked about.",
147        ));
148    }
149    let mut unset: Vec<Vec<u8>> = Vec::new();
150    // `.chunks(3)` yields each record's path, attribute name and value.
151    for (position, record) in fields.chunks(3).enumerate() {
152        if record[0] != asked[position].as_slice() || record[1] != b"text" {
153            return Err(attribute_failure(
154                CandidateFailure::ListingMalformed,
155                "git check-attr printed a record for a different path or attribute than it was asked about.",
156            ));
157        }
158        if record[2] == b"unset" {
159            unset.push(paths[position].to_vec());
160        }
161    }
162    return Ok(unset);
163}
164
165/// What:
166///  `impl CandidateAttributes { ... }` attaches the constructor and the lookup.
167/// Why:
168///   One owner builds the Git commands,
169///  so a policy cannot ask against another state than the candidates'.
170///
171/// In TS you'd write (pseudocode):
172/// ```ts
173/// class CandidateAttributes { textUnset(paths) {} }
174/// ```
175impl CandidateAttributes {
176    /// What:
177    ///  A resolver over `source`,
178    ///  running the given Git with the given options and environment.
179    /// Why:
180    ///   The environment is the one that names the candidates' private index.
181    ///
182    /// In TS you'd write (pseudocode):
183    /// ```ts
184    /// constructor(realGit, globalPrefix, overlay, source) {}
185    /// ```
186    pub fn new(
187        real_git: &std::path::Path,
188        global_prefix: &[OsString],
189        overlay: &[(OsString, OsString)],
190        source: AttributeSource,
191    ) -> CandidateAttributes {
192        return CandidateAttributes {
193            real_git: real_git.to_path_buf(),
194            global_prefix: global_prefix.to_vec(),
195            overlay: overlay.to_vec(),
196            source,
197        };
198    }
199
200    /// What:
201    ///  Run one Git command and return its standard output.
202    /// Why:
203    ///   A Git that cannot start or that exits unsuccessfully fails the lookup.
204    ///
205    /// In TS you'd write (pseudocode):
206    /// ```ts
207    /// const stdout = await runGit([...prefix, ...command]);
208    /// ```
209    fn run(&self, command: &[OsString]) -> Result<Vec<u8>, CandidateError> {
210        let mut arguments: Vec<OsString> = self.global_prefix.clone();
211        arguments.extend_from_slice(command);
212        let operation: String = match command.first() {
213            Some(word) => word.to_string_lossy().into_owned(),
214            None => String::from("(no command)"),
215        };
216        let output: MetadataOutput = match run_metadata_git(
217            self.real_git.as_path(),
218            arguments.as_slice(),
219            self.overlay.as_slice(),
220        ) {
221            Ok(captured) => captured,
222            Err(error) => {
223                return Err(attribute_failure(
224                    CandidateFailure::GitNotStarted,
225                    format!("git {operation} could not be started: {error}.").as_str(),
226                ));
227            }
228        };
229        if !output.success {
230            return Err(attribute_failure(
231                CandidateFailure::GitFailed,
232                format!(
233                    "git {operation} failed: {}",
234                    String::from_utf8_lossy(output.stderr.as_slice()).trim()
235                )
236                .as_str(),
237            ));
238        }
239        return Ok(output.stdout);
240    }
241
242    /// What:
243    ///  The absolute path of the worktree's top level,
244    ///  as Git prints it.
245    /// Why:
246    ///   Candidate pathnames are relative to the top level,
247    ///  and `git check-attr` resolves its arguments from the directory it runs in,
248    ///  which may be a subdirectory or,
249    ///  for a command run with `GIT_DIR` and `GIT_WORK_TREE`,
250    ///  a directory outside the worktree.
251    ///  An absolute path inside the worktree means the same file from anywhere.
252    ///
253    /// In TS you'd write (pseudocode):
254    /// ```ts
255    /// const top = (await runGit(['rev-parse', '--show-toplevel'])).trimEnd();
256    /// ```
257    fn top_level(&self) -> Result<Vec<u8>, CandidateError> {
258        let output: Vec<u8> = self.run(&[
259            OsString::from("rev-parse"),
260            OsString::from("--show-toplevel"),
261        ])?;
262        let top: &[u8] = strip_git_line(output.as_slice());
263        if top.is_empty() {
264            return Err(attribute_failure(
265                CandidateFailure::ListingMalformed,
266                "git rev-parse --show-toplevel printed no path.",
267            ));
268        }
269        return Ok(top.to_vec());
270    }
271
272    /// What:
273    ///  The `git check-attr` command for one chunk of names under one source.
274    /// Why:
275    ///   The index source adds `--cached`,
276    ///  which reads `.gitattributes` from the private index and ignores the worktree.
277    ///  A tree source adds `--source`,
278    ///  which reads them from that commit or tree.
279    ///  The names follow `--`, so none can be read as an option.
280    ///
281    /// In TS you'd write (pseudocode):
282    /// ```ts
283    /// const command = ['check-attr', '-z', ...sourceFlags, 'text', '--', ...names];
284    /// ```
285    fn command(&self, tree: Option<&ObjectId>, names: &[Vec<u8>]) -> Result<Vec<OsString>, CandidateError> {
286        let mut command: Vec<OsString> = vec![
287            OsString::from("check-attr"),
288            OsString::from("-z"),
289        ];
290        match tree {
291            Some(object) => command.push(OsString::from(format!("--source={}", object.as_str()))),
292            None => command.push(OsString::from("--cached")),
293        }
294        command.push(OsString::from("text"));
295        command.push(OsString::from("--"));
296        for name in names {
297            let Some(native) = path_from_git_bytes(name.as_slice()) else {
298                return Err(attribute_failure(
299                    CandidateFailure::ListingMalformed,
300                    "a candidate name is not a path on this platform.",
301                ));
302            };
303            command.push(native.into_os_string());
304        }
305        return Ok(command);
306    }
307
308    /// What:
309    ///  The tree each path is answered by,
310    ///  as groups of paths in first-seen order.
311    ///  `None` stands for the private index.
312    /// Why:
313    ///   One Git command asks about every path of a group.
314    ///  A path without a recorded tree cannot be answered and fails the lookup.
315    ///
316    /// In TS you'd write (pseudocode):
317    /// ```ts
318    /// const groups = groupBy(paths, (path) => treeOf(path));
319    /// ```
320    fn groups<'a>(
321        &self,
322        paths: &[&'a [u8]],
323    ) -> Result<Vec<(Option<ObjectId>, Vec<&'a [u8]>)>, CandidateError> {
324        match &self.source {
325            AttributeSource::Index => return Ok(vec![(None, paths.to_vec())]),
326            AttributeSource::Tree(tree) => return Ok(vec![(Some(tree.clone()), paths.to_vec())]),
327            AttributeSource::PerPath(trees) => {
328                let mut groups: Vec<(Option<ObjectId>, Vec<&'a [u8]>)> = Vec::new();
329                for path in paths {
330                    let Some(tree) = trees.get(*path) else {
331                        return Err(attribute_failure(
332                            CandidateFailure::StaleCandidate,
333                            "a candidate was not listed from any commit or tree.",
334                        ));
335                    };
336                    match groups.iter_mut().find(|group| { return group.0.as_ref() == Some(tree); }) {
337                        Some(group) => group.1.push(*path),
338                        None => groups.push((Some(tree.clone()), vec![path])),
339                    }
340                }
341                return Ok(groups);
342            }
343        }
344    }
345
346    /// What:
347    ///  The given candidate pathnames whose `text` attribute is unset.
348    ///  No path means no Git process.
349    /// Why:
350    ///   A policy asks only about the files it would otherwise change,
351    ///  and a commit of canonical files starts no attribute query.
352    ///
353    /// In TS you'd write (pseudocode):
354    /// ```ts
355    /// async textUnset(paths: Buffer[]): Promise<Set<string>>; // throws CandidateError
356    /// ```
357    pub fn text_unset(&self, paths: &[&[u8]]) -> Result<HashSet<Vec<u8>>, CandidateError> {
358        let mut unset: HashSet<Vec<u8>> = HashSet::new();
359        if paths.is_empty() {
360            return Ok(unset);
361        }
362        let top: Vec<u8> = self.top_level()?;
363        for (tree, group) in self.groups(paths)? {
364            for chunk in group.chunks(ATTRIBUTE_CHUNK_SIZE) {
365                // Each asked name is the top level, a separator and the candidate's pathname.
366                let asked: Vec<Vec<u8>> = chunk
367                    .iter()
368                    .map(|path| { return [top.as_slice(), b"/", *path].concat(); })
369                    .collect();
370                let command: Vec<OsString> = self.command(tree.as_ref(), asked.as_slice())?;
371                let output: Vec<u8> = self.run(command.as_slice())?;
372                unset.extend(parse_text_unset(output.as_slice(), asked.as_slice(), chunk)?);
373            }
374        }
375        return Ok(unset);
376    }
377}
378
379/// Parsing controls stay out of the release executable.
380#[cfg(test)]
381#[path = "candidate_attribute_tests.rs"]
382mod tests;