Skip to main content

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