Skip to main content

git_policy_cli/worktree/
worktree_clean_content.rs

1//! What:
2//!  The decision, by content and without a Git process, whether a tracked file that its index entry's stat data cannot
3//!  vouch for (a racily clean entry, a stale stat) still equals the committed blob: hash the file as a blob and compare it
4//!  with the entry's object name, refusing where a content conversion could make the two differ.
5//! Why:
6//!  Git settles those entries by hashing the file; stat data alone cannot tell a modified file from a copied or cloned one
7//!  that kept the index's old inode and times. The hash of the raw bytes is conclusive only when Git's own conversion on the
8//!  way into the index (`filter`, `ident`, `working-tree-encoding`, or a line-ending conversion that strips a carriage
9//!  return) leaves the bytes alone, so a path such an attribute applies to, and a file with a carriage return, are refused
10//!  with that reason instead of guessed. The file is read through a handle opened below the worktree root without following a
11//!  link, its type and mode are judged on that handle, and its state is compared before and after the read, so the bytes that
12//!  were hashed are the bytes of the one file that was judged.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const judge = new ContentJudge({ root, gitDir, commonDir, environment, format });
17//! judge.agrees({ directory, parents, name, entry, id, indexSeconds }); // throws the reason the file cannot be proven equal
18//! ```
19
20use crate::fs_beneath::{entry_metadata_at, open_directory_beneath, open_file_at, read_link_at};
21use crate::git_system_config::system_attributes_for;
22use crate::repo_read_config_list::list_config;
23use crate::repo_read_config_scan::ScopeEnvironment;
24use crate::repo_read_config_text::{ConfigEntry, last_entry, parse_bool};
25use crate::repo_read_layout::ObjectFormat;
26use crate::subvolume_sha::{sha1, sha256};
27use crate::worktree_attributes::{AttributeLine, CONVERTING_ATTRIBUTES, attribute_name, may_apply, parse_attribute_lines};
28use crate::worktree_clean::{StatEntry, StatVerdict, metadata_verdict};
29use std::collections::{HashMap, HashSet, VecDeque};
30use std::ffi::{OsStr, OsString};
31use std::io::Read;
32use std::os::unix::ffi::{OsStrExt, OsStringExt};
33use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
34use std::path::{Path, PathBuf};
35
36/// The largest file read into memory to be hashed; a larger one is refused with that reason.
37pub const CONTENT_LIMIT: u64 = 32 << 20;
38
39/// The largest attributes file read; Git ignores a larger one, which here is a refusal.
40pub const ATTRIBUTES_LIMIT: u64 = 4 << 20;
41
42/// Where Git reads the system attributes file from, as far as it can be established.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub enum SystemAttributes {
45    /// `GIT_ATTR_NOSYSTEM` turns the file off.
46    Disabled,
47    /// The file the executable reads, at its own prefix.
48    File(PathBuf),
49    /// The location, or the variable's value, could not be established without running Git.
50    Unknown,
51}
52
53/// What:
54///  The environment facts the content decision depends on: the configuration files Git reads and the system attributes file.
55/// Why:
56///   A control injects both, so a decision never depends on the machine it runs on.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// type ConversionEnvironment = { scope: ScopeEnvironment; systemAttributes: 'disabled' | { file: string } | 'unknown'; alternateAttributeSource?: string };
61/// ```
62#[derive(Clone, Debug)]
63pub struct ConversionEnvironment {
64    /// Which system and global configuration files Git reads.
65    pub scope: ScopeEnvironment,
66    /// Where the system attributes file is.
67    pub system_attributes: SystemAttributes,
68    /// Why Git would take its attributes from a tree instead of the worktree files read here (`GIT_ATTR_SOURCE`, `--attr-source`,
69    ///  `attr.tree`), when it would; the content shortcut is then refused.
70    pub alternate_attribute_source: Option<String>,
71    /// The variables the facts were read from, which the configuration listing reads its `GIT_CONFIG_COUNT` entries from too, so
72    ///  one environment decides every fact; empty for a stand-in that names none.
73    pub variables: Vec<(OsString, OsString)>,
74}
75
76impl ConversionEnvironment {
77    /// The facts of an environment given as variables. The variables name the real Git (the forward-target variable) when the
78    ///  system files are to be learned from it; without it the system files are unknown and the content decision refuses.
79    pub fn from_variables(variables: &[(OsString, OsString)]) -> ConversionEnvironment {
80        let selected: Option<String> = variables.iter().any(|(name, _value): &(OsString, OsString)| { return name == "GIT_ATTR_SOURCE"; }).then(|| { return String::from("GIT_ATTR_SOURCE is set"); });
81        return ConversionEnvironment { scope: ScopeEnvironment::from_variables(variables), system_attributes: system_attributes_from(variables), alternate_attribute_source: selected, variables: variables.to_vec() };
82    }
83}
84
85/// What:
86///  Where the system attributes file is for a Git started with `variables`.
87/// Why:
88///   `GIT_ATTR_NOSYSTEM` is read as Git reads a boolean environment variable (empty is false); the file is where the
89///  executable says it is, never derived from the system configuration path, which `GIT_CONFIG_SYSTEM` moves alone.
90///
91/// In TS you'd write (pseudocode):
92/// ```ts
93/// function systemAttributesFrom(variables: [string, string][]): SystemAttributes;
94/// ```
95pub fn system_attributes_from(variables: &[(OsString, OsString)]) -> SystemAttributes {
96    let switch: Option<&OsString> = variables.iter().rev().find(|(name, _value): &&(OsString, OsString)| { return name == "GIT_ATTR_NOSYSTEM"; }).map(|(_name, value): &(OsString, OsString)| { return value; });
97    let disabled: Option<bool> = match switch {
98        None => Some(false),
99        Some(value) if value.is_empty() => Some(false),
100        Some(value) => parse_bool(Some(value.as_bytes())),
101    };
102    return match disabled {
103        Some(true) => SystemAttributes::Disabled,
104        Some(false) => system_attributes_for(variables).map_or(SystemAttributes::Unknown, SystemAttributes::File),
105        None => SystemAttributes::Unknown,
106    };
107}
108
109/// One attributes file as it was read.
110#[derive(Clone, Debug, Eq, PartialEq)]
111struct AttributeFile {
112    /// How the reason names the file.
113    label: String,
114    /// The directory of the file relative to the worktree root, `/` separated, empty for the root.
115    base: Vec<u8>,
116    /// Its lines.
117    lines: Vec<AttributeLine>,
118}
119
120/// The attributes files that apply whatever the path is, and the macros among them that set a converting attribute.
121#[derive(Clone, Debug)]
122struct FixedSources {
123    /// The files, in no particular order: any of them may apply.
124    files: Vec<AttributeFile>,
125    /// Macro names that set a converting attribute, directly or through another macro.
126    macros: HashSet<Vec<u8>>,
127}
128
129/// The object name of `data` stored as a blob.
130fn blob_name(data: &[u8], format: ObjectFormat) -> Vec<u8> {
131    let mut framed: Vec<u8> = format!("blob {}\0", data.len()).into_bytes();
132    framed.extend_from_slice(data);
133    return match format {
134        ObjectFormat::Sha1 => sha1(framed.as_slice()).to_vec(),
135        ObjectFormat::Sha256 => sha256(framed.as_slice()).to_vec(),
136    };
137}
138
139/// What:
140///  Everything `reader` yields, or nothing when it yields more than `limit` bytes; it is never asked for more than
141///  `limit + 1`.
142/// Why:
143///   A file can grow after its size was looked at; the bound is on the read, not on an earlier look.
144///
145/// In TS you'd write (pseudocode):
146/// ```ts
147/// function readAtMost(reader: Reader, limit: number): Buffer | undefined;
148/// ```
149pub fn read_at_most(reader: impl Read, limit: u64) -> std::io::Result<Option<Vec<u8>>> {
150    let mut data: Vec<u8> = Vec::new();
151    reader.take(limit + 1).read_to_end(&mut data)?;
152    return Ok(if data.len() as u64 > limit { None } else { Some(data) });
153}
154
155/// Whether two metadata describe one unchanged file: same object, size, mode and times.
156fn same_state(first: &std::fs::Metadata, second: &std::fs::Metadata) -> bool {
157    return first.dev() == second.dev()
158        && first.ino() == second.ino()
159        && first.size() == second.size()
160        && first.mode() == second.mode()
161        && first.mtime() == second.mtime()
162        && first.mtime_nsec() == second.mtime_nsec()
163        && first.ctime() == second.ctime()
164        && first.ctime_nsec() == second.ctime_nsec();
165}
166
167/// Whether a token or a macro name can change a file's bytes on the way into the index.
168fn converts(token: &[u8], macros: &HashSet<Vec<u8>>) -> bool {
169    let name: &[u8] = attribute_name(token);
170    return CONVERTING_ATTRIBUTES.contains(&name) || macros.contains(name);
171}
172
173/// What:
174///  The names of the macros that set a converting attribute, directly or through other macros.
175/// Why:
176///   Reverse edges (attribute name to the macros that mention it) and a queue visit every definition once, whatever the order of
177///  the lines, so a long chain costs work in proportion to its length.
178///
179/// In TS you'd write (pseudocode):
180/// ```ts
181/// function convertingMacros(files: AttributeFile[]): Set<string>;
182/// ```
183fn converting_macros(files: &[AttributeFile]) -> HashSet<Vec<u8>> {
184    let mut mentioned_by: HashMap<Vec<u8>, Vec<Vec<u8>>> = HashMap::new();
185    for line in files.iter().flat_map(|file: &AttributeFile| { return file.lines.iter(); }).filter(|line: &&AttributeLine| { return line.is_macro; }) {
186        for token in &line.tokens {
187            mentioned_by.entry(attribute_name(token.as_slice()).to_vec()).or_default().push(line.subject.clone());
188        }
189    }
190    let mut known: HashSet<Vec<u8>> = HashSet::new();
191    let mut queue: VecDeque<Vec<u8>> = CONVERTING_ATTRIBUTES.iter().map(|name: &&[u8]| { return name.to_vec(); }).collect();
192    while let Some(name) = queue.pop_front() {
193        for macro_name in mentioned_by.get(&name).into_iter().flatten() {
194            if known.insert(macro_name.clone()) {
195                queue.push_back(macro_name.clone());
196            }
197        }
198    }
199    return known;
200}
201
202/// What:
203///  Decides, one entry at a time, whether a file equals its committed blob by content.
204/// Why:
205///   The attributes files are read once, on the first entry that needs them, and the per-directory ones are cached.
206///
207/// In TS you'd write (pseudocode):
208/// ```ts
209/// class ContentJudge { agrees(args: Place & { entry: StatEntry; id: Uint8Array; indexSeconds: number }): void /* throws the reason */ }
210/// ```
211pub struct ContentJudge<'a> {
212    /// The worktree root, held open.
213    root: &'a std::fs::File,
214    /// The worktree's Git directory.
215    git_dir: &'a Path,
216    /// The shared Git directory.
217    common_dir: &'a Path,
218    /// Which files Git reads in this environment.
219    environment: &'a ConversionEnvironment,
220    /// The object format of the repository.
221    format: ObjectFormat,
222    /// The attributes files that do not depend on the path.
223    fixed: Option<Result<FixedSources, String>>,
224    /// The `.gitattributes` of each directory, keyed by its path, read at most once; `None` when it has none.
225    directories: HashMap<Vec<u8>, Option<AttributeFile>>,
226}
227
228/// The moments of a judgment at which a control changes the file under it.
229pub struct ReadSeams<'a> {
230    /// Called after the file's state was first read and before it is opened.
231    pub before_open: &'a mut dyn FnMut(),
232    /// Called after the open handle was judged and before the bytes are read.
233    pub before_read: &'a mut dyn FnMut(),
234    /// Called after the bytes were read and before the state is read again.
235    pub after_read: &'a mut dyn FnMut(),
236}
237
238/// Where a tracked file is: the held directory that contains it, the names above it and its own name.
239pub struct Place<'a> {
240    /// The directory holding the file, opened below the root without following a link.
241    pub directory: &'a std::fs::File,
242    /// The names of the directories between the root and the file.
243    pub parents: &'a [OsString],
244    /// The file's own name.
245    pub name: &'a OsStr,
246}
247
248impl<'a> ContentJudge<'a> {
249    /// A judge for the worktree held open as `root`.
250    pub fn new(root: &'a std::fs::File, git_dir: &'a Path, common_dir: &'a Path, environment: &'a ConversionEnvironment, format: ObjectFormat) -> ContentJudge<'a> {
251        return ContentJudge { root, git_dir, common_dir, environment, format, fixed: None, directories: HashMap::new() };
252    }
253
254    /// Read an attributes file at an absolute path the way Git does, following a link; absent is `None`.
255    fn read_outside(path: &Path, label: String) -> Result<Option<AttributeFile>, String> {
256        // Without blocking, so a named pipe is found out by its type instead of waiting for a writer.
257        let file = match std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC).open(path) {
258            Ok(opened) => opened,
259            Err(error) if matches!(error.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory) => return Ok(None),
260            Err(error) => return Err(format!("the attributes file {} cannot be read ({error})", path.display())),
261        };
262        return Self::parse_open(file, label, Vec::new());
263    }
264
265    /// Read the `.gitattributes` of the held directory `directory`; absent is `None`, a link or anything but a file is a refusal.
266    fn read_in(directory: &std::fs::File, label: String, base: Vec<u8>) -> Result<Option<AttributeFile>, String> {
267        let file = match open_file_at(directory, OsStr::new(".gitattributes")) {
268            Ok(opened) => opened,
269            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
270            Err(error) => return Err(format!("{label} cannot be read without following a link ({error}), so the attributes it sets cannot be ruled out")),
271        };
272        return Self::parse_open(file, label, base);
273    }
274
275    /// Parse an attributes file that is open, refusing one that is not a plain file or is larger than the limit.
276    fn parse_open(file: std::fs::File, label: String, base: Vec<u8>) -> Result<Option<AttributeFile>, String> {
277        let metadata: std::fs::Metadata = file.metadata().map_err(|error: std::io::Error| { return format!("{label} cannot be examined ({error})"); })?;
278        if !metadata.file_type().is_file() {
279            return Err(format!("{label} is not a plain file (a named pipe, a device or a directory), so the attributes it sets cannot be ruled out"));
280        }
281        let bytes: Vec<u8> = read_at_most(&file, ATTRIBUTES_LIMIT)
282            .map_err(|error: std::io::Error| { return format!("{label} cannot be read ({error})"); })?
283            .ok_or_else(|| { return format!("{label} is larger than {ATTRIBUTES_LIMIT} bytes, so the attributes it sets cannot be ruled out"); })?;
284        return Ok(Some(AttributeFile { label, base, lines: parse_attribute_lines(bytes.as_slice()) }));
285    }
286
287    /// The configuration Git would list, read in process.
288    fn config_entries(&self) -> Result<Vec<ConfigEntry>, String> {
289        let listed = list_config(self.common_dir, self.git_dir, &self.environment.scope, self.environment.variables.as_slice())
290            .ok_or_else(|| { return String::from("the Git configuration cannot be read without running Git, so a global attributes file cannot be ruled out"); })?;
291        return Ok(listed.into_iter().map(|item| { return item.entry; }).collect());
292    }
293
294    /// The attributes file `core.attributesFile` names, or the default one, from the configuration entries.
295    fn global_attributes_path(&self, entries: &[ConfigEntry]) -> Result<Option<PathBuf>, String> {
296        if let Some(entry) = last_entry(entries, "core.attributesfile") {
297            let value: &[u8] = entry.value.as_deref().ok_or_else(|| { return String::from("core.attributesFile has no value"); })?;
298            if let Some(rest) = value.strip_prefix(b"~/") {
299                let home: &Path = self.environment.scope.home.as_deref().ok_or_else(|| { return String::from("core.attributesFile starts with ~/ but HOME is not set"); })?;
300                return Ok(Some(home.join(OsStr::from_bytes(rest))));
301            }
302            let path: PathBuf = PathBuf::from(OsStr::from_bytes(value));
303            if !path.is_absolute() {
304                return Err(format!("core.attributesFile is {:?}, which is not an absolute path", String::from_utf8_lossy(value)));
305            }
306            return Ok(Some(path));
307        }
308        return Ok(match (&self.environment.scope.xdg, &self.environment.scope.home) {
309            (Some(directory), _) => Some(directory.join("git/attributes")),
310            (None, Some(home)) => Some(home.join(".config/git/attributes")),
311            (None, None) => None,
312        });
313    }
314
315    /// The attributes files that apply whatever the path is, with the macros among them that set a converting attribute.
316    fn load_fixed(&self) -> Result<FixedSources, String> {
317        let unmodelled = |reason: &str| -> String { return format!("{reason}, so Git takes its attributes from a tree and not from the files read here, and what it sets cannot be ruled out"); };
318        if let Some(reason) = self.environment.alternate_attribute_source.as_deref() {
319            return Err(unmodelled(reason));
320        }
321        let entries: Vec<ConfigEntry> = self.config_entries()?;
322        if last_entry(entries.as_slice(), "attr.tree").is_some() {
323            return Err(unmodelled("attr.tree is set"));
324        }
325        let mut files: Vec<AttributeFile> = Vec::new();
326        if let Some(root_file) = Self::read_in(self.root, String::from(".gitattributes"), Vec::new())? {
327            files.push(root_file);
328        }
329        let mut outside: Vec<(PathBuf, String)> = vec![(self.common_dir.join("info/attributes"), String::from("info/attributes"))];
330        match &self.environment.system_attributes {
331            SystemAttributes::Disabled => {}
332            SystemAttributes::File(path) => outside.push((path.clone(), path.display().to_string())),
333            SystemAttributes::Unknown => return Err(String::from("the location of the system attributes file cannot be established without running Git, so what it sets cannot be ruled out")),
334        }
335        if let Some(path) = self.global_attributes_path(entries.as_slice())? {
336            outside.push((path.clone(), path.display().to_string()));
337        }
338        for (path, label) in outside {
339            if let Some(file) = Self::read_outside(path.as_path(), label)? {
340                files.push(file);
341            }
342        }
343        let macros: HashSet<Vec<u8>> = converting_macros(files.as_slice());
344        return Ok(FixedSources { files, macros });
345    }
346
347    /// The `.gitattributes` of the directory below the root named by `parents`, read once.
348    fn directory_file(&mut self, parents: &[OsString]) -> Result<Option<AttributeFile>, String> {
349        let key: Vec<u8> = parents.iter().map(|name: &OsString| { return name.as_bytes().to_vec(); }).collect::<Vec<Vec<u8>>>().join(&b'/');
350        if !self.directories.contains_key(&key) {
351            let directory = open_directory_beneath(self.root, parents).map_err(|error: std::io::Error| { return format!("{} cannot be opened below the worktree ({error})", String::from_utf8_lossy(key.as_slice())); })?;
352            let label: String = format!("{}/.gitattributes", String::from_utf8_lossy(key.as_slice()));
353            let file: Option<AttributeFile> = Self::read_in(&directory, label, key.clone())?;
354            self.directories.insert(key.clone(), file);
355        }
356        return Ok(self.directories.get(&key).cloned().flatten());
357    }
358
359    /// The first converting attribute that may apply to the file at `path` and where it is set, or nothing.
360    fn converting_attribute(&mut self, path: &[u8], parents: &[OsString]) -> Result<Option<(String, String)>, String> {
361        if self.fixed.is_none() {
362            self.fixed = Some(self.load_fixed());
363        }
364        let fixed: FixedSources = self.fixed.clone().ok_or_else(|| { return String::from("the attributes were not loaded"); })??;
365        let mut files: Vec<AttributeFile> = fixed.files;
366        for depth in 1..=parents.len() {
367            if let Some(file) = self.directory_file(&parents[..depth])? {
368                files.push(file);
369            }
370        }
371        for file in &files {
372            let below: bool = file.base.is_empty() || path.strip_prefix(file.base.as_slice()).is_some_and(|rest: &[u8]| { return rest.starts_with(b"/"); });
373            if below && file.lines.iter().any(|line: &AttributeLine| { return line.quoted; }) {
374                return Ok(Some((String::from("a quoted line"), file.label.clone())));
375            }
376            for line in file.lines.iter().filter(|line: &&AttributeLine| { return !line.is_macro; }) {
377                let Some(token) = line.tokens.iter().find(|token: &&Vec<u8>| { return converts(token.as_slice(), &fixed.macros); }) else { continue };
378                if may_apply(line.subject.as_slice(), file.base.as_slice(), path) {
379                    return Ok(Some((String::from_utf8_lossy(token.as_slice()).into_owned(), file.label.clone())));
380                }
381            }
382        }
383        return Ok(None);
384    }
385
386    /// What:
387    ///  The bytes of the file at `place`, judged to be the one file its entry describes: opened below the held directory
388    ///  without following a link, its type and mode judged on the open handle, and its state the same before and after.
389    /// Why:
390    ///   A swap, a `chmod` or a write while the file is read must refuse instead of passing, and the read is bounded.
391    ///
392    /// In TS you'd write (pseudocode):
393    /// ```ts
394    /// function readStable(place: Place, entry: StatEntry, indexSeconds: number): Buffer; // throws the reason
395    /// ```
396    fn read_stable(place: &Place<'_>, entry: &StatEntry, index_seconds: u32, shown: &str, seams: &mut ReadSeams<'_>) -> Result<Vec<u8>, String> {
397        let changed = || -> String { return format!("{shown} changed while it was examined, so it cannot be proven equal to the committed content"); };
398        let named: std::fs::Metadata = entry_metadata_at(place.directory, place.name).map_err(|error: std::io::Error| { return format!("{shown} cannot be examined ({error})"); })?;
399        if let StatVerdict::Differs(reason) = metadata_verdict(entry, &named, index_seconds) {
400            return Err(reason);
401        }
402        if named.file_type().is_symlink() {
403            let target: PathBuf = read_link_at(place.directory, place.name).map_err(|error: std::io::Error| { return format!("{shown} cannot be read ({error})"); })?;
404            let again: std::fs::Metadata = entry_metadata_at(place.directory, place.name).map_err(|_error: std::io::Error| { return changed(); })?;
405            if !same_state(&named, &again) {
406                return Err(changed());
407            }
408            return Ok(target.into_os_string().into_vec());
409        }
410        (seams.before_open)();
411        let file: std::fs::File = open_file_at(place.directory, place.name).map_err(|error: std::io::Error| { return format!("{shown} cannot be opened without following a link ({error})"); })?;
412        let held: std::fs::Metadata = file.metadata().map_err(|error: std::io::Error| { return format!("{shown} cannot be examined ({error})"); })?;
413        if held.dev() != named.dev() || held.ino() != named.ino() {
414            return Err(changed());
415        }
416        if let StatVerdict::Differs(reason) = metadata_verdict(entry, &held, index_seconds) {
417            return Err(reason);
418        }
419        let too_large = || -> String { return format!("{shown} is larger than {CONTENT_LIMIT} bytes, which is more than is read to compare it by content"); };
420        if held.len() > CONTENT_LIMIT {
421            return Err(too_large());
422        }
423        (seams.before_read)();
424        let data: Vec<u8> = read_at_most(&file, CONTENT_LIMIT).map_err(|error: std::io::Error| { return format!("{shown} cannot be read ({error})"); })?.ok_or_else(too_large)?;
425        (seams.after_read)();
426        let after: std::fs::Metadata = file.metadata().map_err(|_error: std::io::Error| { return changed(); })?;
427        let by_name: std::fs::Metadata = entry_metadata_at(place.directory, place.name).map_err(|_error: std::io::Error| { return changed(); })?;
428        if !same_state(&held, &after) || !same_state(&held, &by_name) {
429            return Err(changed());
430        }
431        return Ok(data);
432    }
433
434    /// What:
435    ///  Whether the file at `place` equals the blob named `id`; the reason it cannot be proven so when it does not.
436    /// Why:
437    ///   Where a conversion applies, or the file has a carriage return, the bytes alone prove nothing and the answer is a
438    ///  refusal that says so; a file that really differs says it differs.
439    ///
440    /// In TS you'd write (pseudocode):
441    /// ```ts
442    /// judge.agrees(place, entry, id, indexSeconds); // throws the reason
443    /// ```
444    pub fn agrees(&mut self, place: &Place<'_>, entry: &StatEntry, id: &[u8], index_seconds: u32) -> Result<(), String> {
445        return self.agrees_seamed(place, entry, id, index_seconds, &mut ReadSeams { before_open: &mut || {}, before_read: &mut || {}, after_read: &mut || {} });
446    }
447
448    /// `agrees` with the moments at which a control may change the file.
449    pub fn agrees_seamed(&mut self, place: &Place<'_>, entry: &StatEntry, id: &[u8], index_seconds: u32, seams: &mut ReadSeams<'_>) -> Result<(), String> {
450        let shown: String = String::from_utf8_lossy(entry.path.as_slice()).into_owned();
451        if let Some((token, label)) = self.converting_attribute(entry.path.as_slice(), place.parents)? {
452            return Err(if token == "a quoted line" {
453                format!("{shown} cannot be compared by content: {label} has a quoted line, whose pattern and attributes are not modelled, so what it sets cannot be ruled out")
454            } else {
455                format!("{shown} cannot be compared by content: the attribute {token} from {label} may change its bytes on the way into the index")
456            });
457        }
458        let data: Vec<u8> = Self::read_stable(place, entry, index_seconds, shown.as_str(), seams)?;
459        if blob_name(data.as_slice(), self.format) != id {
460            return Err(format!("{shown} differs from the committed content (its recorded size and times could not vouch for it, so its content was compared)"));
461        }
462        if data.contains(&b'\r') {
463            return Err(format!("{shown} cannot be compared by content: it contains a carriage return, so a line-ending conversion may change its bytes on the way into the index"));
464        }
465        return Ok(());
466    }
467}
468
469#[cfg(test)]
470#[path = "worktree_clean_content_tests.rs"]
471mod tests;
472
473/// Controls for the findings of the review of the content decision, on real Git repositories.
474#[cfg(test)]
475#[path = "worktree_clean_content_safety_tests.rs"]
476mod safety_tests;