1use super::child_environment::{COUNT_VARIABLE, environment_value, parse_config_count};
22use super::private_storage::read_regular_file;
23use super::repo_read_hook::enabled;
24use super::repo_read_config_scan::{ScopeEnvironment, discovery_key, entries_avoid_keys, outer_scopes_avoid_keys, read_following};
25use super::repo_read_config_text::{CONFIG_TEXT_LIMIT, ConfigEntry, last_entry, parse_bool, parse_config_text};
26use super::repo_read_config_typed::{entries_are_accepted, environment_is_accepted, outer_scopes_are_accepted};
27use std::ffi::OsString;
28use std::path::{Path, PathBuf};
29
30const PLAIN_ENVIRONMENT_PREFIXES: [&str; 19] = [
32 "GIT_AUTHOR_",
33 "GIT_COMMITTER_",
34 "GIT_TERMINAL_PROMPT",
35 "GIT_ASKPASS",
36 "GIT_PAGER",
37 "GIT_EDITOR",
38 "GIT_SEQUENCE_EDITOR",
39 "GIT_EXEC_PATH",
40 "GIT_SSH",
41 "GIT_ALLOW_PROTOCOL",
42 "GIT_LFS_",
43 "GIT_CONFIG_NOSYSTEM",
44 "GIT_CONFIG_GLOBAL",
45 "GIT_CONFIG_SYSTEM",
46 "GIT_CONFIG_COUNT",
47 "GIT_CONFIG_KEY_",
48 "GIT_CONFIG_VALUE_",
49 "GIT_MERGE_AUTOEDIT",
50 "GIT_OPTIONAL_LOCKS",
51];
52
53pub const WRAPPER_ENVIRONMENT_PREFIX: &str = "GIT_POLICY_";
56
57const WORKTREE_CONFIG_NAME: &str = "config.worktree";
59
60const SMALL_FILE_LIMIT: u64 = 4096;
62
63const ENV_CONFIG_ENTRY_LIMIT: u64 = 1024;
65
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum ObjectFormat {
69 Sha1,
71 Sha256,
73}
74
75impl ObjectFormat {
76 pub fn name(self) -> &'static str {
78 return match self {
79 ObjectFormat::Sha1 => "sha1",
80 ObjectFormat::Sha256 => "sha256",
81 };
82 }
83
84 pub fn hex_length(self) -> usize {
86 return match self {
87 ObjectFormat::Sha1 => 40,
88 ObjectFormat::Sha256 => 64,
89 };
90 }
91}
92
93#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct RepoLayout {
104 pub git_dir: PathBuf,
106 pub common_dir: PathBuf,
108 pub worktree_root: PathBuf,
110 pub prefix: Vec<u8>,
112 pub object_format: ObjectFormat,
114}
115
116fn env_key_moves_discovery(key: &str) -> bool {
118 let lowered: String = key.to_ascii_lowercase();
119 return lowered == "core.bare" || lowered == "core.worktree" || lowered.starts_with("include");
120}
121
122fn env_config_is_plain(variables: &[(OsString, OsString)]) -> bool {
124 return env_config_avoids(variables, &env_key_moves_discovery);
125}
126
127pub fn env_config_avoids(variables: &[(OsString, OsString)], declines: &dyn Fn(&str) -> bool) -> bool {
138 let Some(count_text) = environment_value(variables, COUNT_VARIABLE) else {
139 return true;
140 };
141 let Some(count) = parse_config_count(count_text.as_os_str()) else {
142 return false;
143 };
144 if count > ENV_CONFIG_ENTRY_LIMIT {
145 return false;
146 }
147 for index in 0..count {
148 let Some(key) = environment_value(variables, format!("GIT_CONFIG_KEY_{index}").as_str()) else {
149 return false;
150 };
151 match key.to_str() {
152 Some(text) if !declines(text) => {}
153 _ => return false,
154 }
155 }
156 return true;
157}
158
159fn environment_is_plain(variables: &[(OsString, OsString)]) -> bool {
162 for (name, _) in variables {
163 let Some(text) = name.to_str() else {
164 continue;
165 };
166 let tolerated: bool = text.starts_with(WRAPPER_ENVIRONMENT_PREFIX) || PLAIN_ENVIRONMENT_PREFIXES.iter().any(|prefix| { return text.starts_with(prefix); });
167 if text.starts_with("GIT_") && !tolerated {
168 return false;
169 }
170 }
171 return env_config_is_plain(variables);
172}
173
174fn effective_environment(overlay: &[(OsString, OsString)]) -> Vec<(OsString, OsString)> {
176 let mut variables: Vec<(OsString, OsString)> = std::env::vars_os()
177 .filter(|(name, _)| { return !overlay.iter().any(|(added, _)| { return added == name; }); })
178 .collect();
179 variables.extend(overlay.iter().cloned());
180 return variables;
181}
182
183fn start_directory(global_prefix: &[OsString]) -> Option<PathBuf> {
186 let mut directory: PathBuf = std::env::current_dir().ok()?;
187 let mut options = global_prefix.iter();
188 while let Some(option) = options.next() {
189 if option != "-C" {
190 return None;
191 }
192 let path: &OsString = options.next()?;
193 if !path.is_empty() {
194 directory = directory.join(path);
195 }
196 if !std::fs::metadata(&directory).is_ok_and(|found| { return found.is_dir(); }) {
198 return None;
199 }
200 }
201 return std::fs::canonicalize(directory).ok();
202}
203
204fn small_file(path: &Path) -> Option<String> {
206 let bytes: Vec<u8> = read_regular_file(path, SMALL_FILE_LIMIT).ok()?;
207 return String::from_utf8(bytes).ok();
208}
209
210fn has_git_directory_entries(directory: &Path) -> bool {
212 return directory.join("HEAD").exists() && directory.join("objects").is_dir() && directory.join("refs").is_dir();
213}
214
215fn head_is_valid(text: &str, format: ObjectFormat) -> bool {
217 let Some(line) = text.strip_suffix('\n') else {
218 return false;
219 };
220 if let Some(name) = line.strip_prefix("ref: ") {
221 return name.starts_with("refs/") && !name.contains('\n');
222 }
223 return line.len() == format.hex_length() && line.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
224}
225
226#[cfg(unix)]
228fn owned_by_current_user(path: &Path) -> bool {
229 use std::os::unix::fs::MetadataExt;
230 let Ok(metadata) = std::fs::symlink_metadata(path) else {
231 return false;
232 };
233 return metadata.uid() == unsafe { libc::geteuid() };
235}
236
237#[cfg(not(unix))]
239fn owned_by_current_user(_path: &Path) -> bool {
240 return false;
241}
242
243#[cfg(unix)]
245fn device_of(path: &Path) -> Option<u64> {
246 use std::os::unix::fs::MetadataExt;
247 return std::fs::metadata(path).ok().map(|metadata| { return metadata.dev(); });
248}
249
250#[cfg(not(unix))]
252fn device_of(_path: &Path) -> Option<u64> {
253 return None;
254}
255
256fn dot_git_target(worktree: &Path) -> Option<Option<(PathBuf, bool)>> {
259 let dot_git: PathBuf = worktree.join(".git");
260 let metadata = match std::fs::symlink_metadata(&dot_git) {
261 Ok(found) => found,
262 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(None),
263 Err(_) => return None,
264 };
265 if metadata.is_dir() {
266 return Some(Some((dot_git, false)));
267 }
268 if !metadata.is_file() {
269 return None;
270 }
271 let text: String = small_file(dot_git.as_path())?;
272 let target: &str = text.strip_prefix("gitdir: ")?.strip_suffix('\n')?;
273 if target.is_empty() || target.contains('\n') {
274 return None;
275 }
276 return Some(Some((worktree.join(target), true)));
277}
278
279fn format_of(entries: &[ConfigEntry]) -> Option<(ObjectFormat, bool)> {
292 let version: Option<&[u8]> = match last_entry(entries, "core.repositoryformatversion") {
294 Some(entry) => Some(entry.value.as_deref()?),
295 None => None,
296 };
297 let version_one: bool = match version {
298 None | Some(b"0") => false,
299 Some(b"1") => true,
300 Some(_) => return None,
301 };
302 let mut format: ObjectFormat = ObjectFormat::Sha1;
303 let mut worktree_config: bool = false;
304 for entry in entries.iter().filter(|entry| { return entry.key.starts_with("extensions."); }) {
305 if entry.key == "extensions.worktreeconfig" {
306 worktree_config = parse_bool(entry.value.as_deref())?;
307 continue;
308 }
309 if !version_one {
310 return None;
311 }
312 let value: &[u8] = entry.value.as_deref().unwrap_or_default();
313 match (entry.key.as_str(), value) {
314 ("extensions.noop", _) => {}
315 ("extensions.preciousobjects", _) if parse_bool(entry.value.as_deref()).is_some() => {}
316 ("extensions.objectformat", b"sha1") => format = ObjectFormat::Sha1,
317 ("extensions.objectformat", b"sha256") => format = ObjectFormat::Sha256,
318 ("extensions.refstorage", b"files") => {}
319 _ => return None,
320 }
321 }
322 return Some((format, worktree_config));
323}
324
325fn worktree_entries(git_dir: &Path, enabled: bool) -> Option<Vec<ConfigEntry>> {
337 if !enabled {
338 return Some(Vec::new());
339 }
340 return match read_following(git_dir.join(WORKTREE_CONFIG_NAME).as_path())? {
341 Some(bytes) => parse_config_text(bytes.as_slice()),
342 None => Some(Vec::new()),
343 };
344}
345
346fn without_false_bare(files: [&[ConfigEntry]; 2]) -> Option<[Vec<ConfigEntry>; 2]> {
358 let mut last: Option<bool> = None;
359 for entry in files.iter().flat_map(|entries| { return entries.iter(); }).filter(|entry| { return entry.key == "core.bare"; }) {
360 last = Some(parse_bool(entry.value.as_deref())?);
361 }
362 if last == Some(true) {
363 return None;
364 }
365 return Some(files.map(|entries| {
366 return entries.iter().filter(|entry| { return entry.key != "core.bare"; }).cloned().collect::<Vec<ConfigEntry>>();
367 }));
368}
369
370fn finish(start: &Path, worktree: &Path, entry: (PathBuf, bool), scope: &ScopeEnvironment) -> Option<RepoLayout> {
380 let (target, from_file) = entry;
381 let git_dir: PathBuf = std::fs::canonicalize(target).ok()?;
382 let dot_git: PathBuf = worktree.join(".git");
383 let owners_ok: bool = owned_by_current_user(worktree)
384 && owned_by_current_user(git_dir.as_path())
385 && (!from_file || owned_by_current_user(dot_git.as_path()));
386 if !owners_ok {
387 return None;
388 }
389 let common_dir: PathBuf = match small_file(git_dir.join("commondir").as_path()) {
390 Some(text) => std::fs::canonicalize(git_dir.join(text.strip_suffix('\n')?)).ok()?,
391 None if git_dir.join("commondir").exists() => return None,
392 None => git_dir.clone(),
393 };
394 if git_dir.join("reftable").exists() || common_dir.join("reftable").exists() || !has_git_directory_entries_split(&git_dir, &common_dir) {
395 return None;
396 }
397 let config_bytes: Vec<u8> = read_regular_file(common_dir.join("config").as_path(), CONFIG_TEXT_LIMIT as u64).ok()?;
398 let entries: Vec<ConfigEntry> = parse_config_text(config_bytes.as_slice())?;
399 let (object_format, worktree_config): (ObjectFormat, bool) = format_of(entries.as_slice())?;
400 let worktree_path: PathBuf = git_dir.join(WORKTREE_CONFIG_NAME);
401 let per_worktree: Vec<ConfigEntry> = worktree_entries(git_dir.as_path(), worktree_config)?;
402
403 let [discoverable, worktree_discoverable] = without_false_bare([entries.as_slice(), per_worktree.as_slice()])?;
406 let local_ok: bool = entries_avoid_keys(discoverable.as_slice(), common_dir.join("config").as_path(), scope.home.as_deref(), 0, &discovery_key)?
407 && entries_avoid_keys(worktree_discoverable.as_slice(), worktree_path.as_path(), scope.home.as_deref(), 0, &discovery_key)?;
408 if !local_ok || !outer_scopes_avoid_keys(scope, &discovery_key)? {
409 return None;
410 }
411 let typed_ok: bool = entries_are_accepted(entries.as_slice(), common_dir.join("config").as_path(), scope.home.as_deref(), 0)?
414 && entries_are_accepted(per_worktree.as_slice(), worktree_path.as_path(), scope.home.as_deref(), 0)?
415 && outer_scopes_are_accepted(scope)?;
416 if !typed_ok {
417 return None;
418 }
419 let head: String = small_file(git_dir.join("HEAD").as_path())?;
420 if !head_is_valid(head.as_str(), object_format) {
421 return None;
422 }
423 let relative: &Path = start.strip_prefix(worktree).ok()?;
424 let mut prefix: Vec<u8> = Vec::new();
425 for component in relative.components() {
426 prefix.extend_from_slice(component.as_os_str().as_encoded_bytes());
427 prefix.push(b'/');
428 }
429 return Some(RepoLayout { git_dir, common_dir, worktree_root: worktree.to_path_buf(), prefix, object_format });
430}
431
432fn has_git_directory_entries_split(git_dir: &Path, common_dir: &Path) -> bool {
434 return git_dir.join("HEAD").exists() && common_dir.join("objects").is_dir() && common_dir.join("refs").is_dir();
435}
436
437pub fn read_layout(global_prefix: &[OsString], overlay: &[(OsString, OsString)]) -> Option<RepoLayout> {
449 if !enabled(overlay) {
450 return None;
451 }
452 let variables: Vec<(OsString, OsString)> = effective_environment(overlay);
453 if !environment_is_plain(variables.as_slice()) || !environment_is_accepted(variables.as_slice()) {
454 return None;
455 }
456 let start: PathBuf = start_directory(global_prefix)?;
457 return walk_up(start.as_path(), &ScopeEnvironment::from_variables(variables.as_slice()));
458}
459
460pub fn read_layout_with(start: &Path, variables: &[(OsString, OsString)], scope: &ScopeEnvironment) -> Option<RepoLayout> {
470 if !environment_is_plain(variables) || !environment_is_accepted(variables) {
471 return None;
472 }
473 return walk_up(std::fs::canonicalize(start).ok()?.as_path(), scope);
474}
475
476pub fn read_layout_in(directory: &Path, overlay: &[(OsString, OsString)]) -> Option<RepoLayout> {
486 if !enabled(overlay) {
487 return None;
488 }
489 let variables: Vec<(OsString, OsString)> = effective_environment(overlay);
490 return read_layout_with(directory, variables.as_slice(), &ScopeEnvironment::from_variables(variables.as_slice()));
491}
492
493fn walk_up(start: &Path, scope: &ScopeEnvironment) -> Option<RepoLayout> {
495 let mut directory: PathBuf = start.to_path_buf();
496 loop {
497 if let Some(entry) = dot_git_target(directory.as_path())? {
498 return finish(start, directory.as_path(), entry, scope);
499 }
500 if has_git_directory_entries(directory.as_path()) {
501 return None;
502 }
503 let parent: PathBuf = directory.parent()?.to_path_buf();
504 if device_of(parent.as_path())? != device_of(directory.as_path())? {
505 return None;
506 }
507 directory = parent;
508 }
509}
510
511pub fn git_path(layout: &RepoLayout, name: &str) -> Option<PathBuf> {
523 const KNOWN: [&str; 9] = [
524 "index",
525 "objects",
526 "HEAD",
527 "MERGE_HEAD",
528 "CHERRY_PICK_HEAD",
529 "REVERT_HEAD",
530 "cli-git-transactions",
531 "packed-refs",
532 "config",
533 ];
534 if layout.git_dir != layout.common_dir || !KNOWN.contains(&name) {
535 return None;
536 }
537 let path: PathBuf = layout.git_dir.join(name);
538 if std::fs::symlink_metadata(path.as_path()).is_ok_and(|metadata| { return metadata.file_type().is_symlink(); }) {
540 return None;
541 }
542 return Some(path);
543}
544
545#[cfg(test)]
546#[path = "repo_read_layout_tests.rs"]
547mod tests;
548
549#[cfg(test)]
550#[path = "repo_read_layout_worktree_tests.rs"]
551mod worktree_tests;