1use 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
36pub const CONTENT_LIMIT: u64 = 32 << 20;
38
39pub const ATTRIBUTES_LIMIT: u64 = 4 << 20;
41
42#[derive(Clone, Debug, Eq, PartialEq)]
44pub enum SystemAttributes {
45 Disabled,
47 File(PathBuf),
49 Unknown,
51}
52
53#[derive(Clone, Debug)]
63pub struct ConversionEnvironment {
64 pub scope: ScopeEnvironment,
66 pub system_attributes: SystemAttributes,
68 pub alternate_attribute_source: Option<String>,
71 pub variables: Vec<(OsString, OsString)>,
74}
75
76impl ConversionEnvironment {
77 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
85pub 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#[derive(Clone, Debug, Eq, PartialEq)]
111struct AttributeFile {
112 label: String,
114 base: Vec<u8>,
116 lines: Vec<AttributeLine>,
118}
119
120#[derive(Clone, Debug)]
122struct FixedSources {
123 files: Vec<AttributeFile>,
125 macros: HashSet<Vec<u8>>,
127}
128
129fn 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
139pub 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
155fn 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
167fn 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
173fn 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
202pub struct ContentJudge<'a> {
212 root: &'a std::fs::File,
214 git_dir: &'a Path,
216 common_dir: &'a Path,
218 environment: &'a ConversionEnvironment,
220 format: ObjectFormat,
222 fixed: Option<Result<FixedSources, String>>,
224 directories: HashMap<Vec<u8>, Option<AttributeFile>>,
226}
227
228pub struct ReadSeams<'a> {
230 pub before_open: &'a mut dyn FnMut(),
232 pub before_read: &'a mut dyn FnMut(),
234 pub after_read: &'a mut dyn FnMut(),
236}
237
238pub struct Place<'a> {
240 pub directory: &'a std::fs::File,
242 pub parents: &'a [OsString],
244 pub name: &'a OsStr,
246}
247
248impl<'a> ContentJudge<'a> {
249 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 fn read_outside(path: &Path, label: String) -> Result<Option<AttributeFile>, String> {
256 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 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 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 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 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 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 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 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 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 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 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#[cfg(test)]
475#[path = "worktree_clean_content_safety_tests.rs"]
476mod safety_tests;