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;