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;