diff --git a/Cargo.lock b/Cargo.lock index e72c7e2c5ba..61422f532a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2215,11 +2215,12 @@ name = "gix-path" version = "0.10.10" dependencies = [ "bstr", + "gix-testtools", "gix-trace 0.1.9", "home", "known-folders", "once_cell", - "tempfile", + "serial_test", "thiserror", "windows 0.58.0", "winreg", diff --git a/gix-path/Cargo.toml b/gix-path/Cargo.toml index ccadfacdff4..a661140b427 100644 --- a/gix-path/Cargo.toml +++ b/gix-path/Cargo.toml @@ -24,7 +24,8 @@ once_cell = "1.17.1" home = "0.5.5" [dev-dependencies] -tempfile = "3.3.0" +gix-testtools = { path = "../tests/tools" } +serial_test = { version = "3.1.0", default-features = false } [target.'cfg(windows)'.dev-dependencies] known-folders = "1.1.0" diff --git a/gix-path/src/env/git/mod.rs b/gix-path/src/env/git/mod.rs index f99c3ba871c..04cc376f8a9 100644 --- a/gix-path/src/env/git/mod.rs +++ b/gix-path/src/env/git/mod.rs @@ -80,21 +80,14 @@ pub(super) static EXE_NAME: &str = "git"; /// Invoke the git executable to obtain the origin configuration, which is cached and returned. /// /// The git executable is the one found in PATH or an alternative location. -pub(super) static EXE_INFO: Lazy> = Lazy::new(|| { - let git_cmd = |executable: PathBuf| { - let mut cmd = Command::new(executable); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - cmd.creation_flags(CREATE_NO_WINDOW); - } - cmd.args(["config", "-l", "--show-origin"]) - .current_dir(env::temp_dir()) - .stdin(Stdio::null()) - .stderr(Stdio::null()); - cmd - }; +pub(super) static EXE_INFO: Lazy> = Lazy::new(exe_info); + +#[cfg(windows)] +static NULL_DEVICE: &str = "NUL"; +#[cfg(not(windows))] +static NULL_DEVICE: &str = "/dev/null"; + +fn exe_info() -> Option { let mut cmd = git_cmd(EXE_NAME.into()); gix_trace::debug!(cmd = ?cmd, "invoking git for installation config path"); let cmd_output = match cmd.output() { @@ -112,7 +105,55 @@ pub(super) static EXE_INFO: Lazy> = Lazy::new(|| { }; first_file_from_config_with_origin(cmd_output.as_slice().into()).map(ToOwned::to_owned) -}); +} + +fn git_cmd(executable: PathBuf) -> Command { + let mut cmd = Command::new(executable); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + // We will try to run `git` from a location fairly high in the filesystem, in the hope it may + // be faster if we are deeply nested, on a slow disk, or in a directory that has been deleted. + let cwd = if cfg!(windows) { + // We try the Windows directory (usually `C:\Windows`) first. It is given by `SystemRoot`, + // except in rare cases where our own parent has not passed down that environment variable. + env::var_os("SystemRoot") + .or_else(|| env::var_os("windir")) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(env::temp_dir) + } else { + "/".into() + }; + // Git 2.8.0 and higher support --show-origin. The -l, -z, and --name-only options were + // supported even before that. In contrast, --show-scope was introduced later, in Git 2.26.0. + // Low versions of Git are still sometimes used, and this is sometimes reasonable because + // downstream distributions often backport security patches without adding most new features. + // So for now, we forgo the convenience of --show-scope for greater backward compatibility. + // + // Separately from that, we can't use --system here, because scopes treated higher than the + // system scope are possible. This commonly happens on macOS with Apple Git, where the config + // file under `/Library` is shown as an "unknown" scope but takes precedence over the system + // scope. Although `GIT_CONFIG_NOSYSTEM` will suppress this as well, passing --system omits it. + cmd.args(["config", "-lz", "--show-origin", "--name-only"]) + .current_dir(cwd) + .env_remove("GIT_COMMON_DIR") // We are setting `GIT_DIR`. + .env_remove("GIT_DISCOVERY_ACROSS_FILESYSTEM") + .env("GIT_DIR", NULL_DEVICE) // Avoid getting local-scope config. + .env("GIT_WORK_TREE", NULL_DEVICE) // Avoid confusion when debugging. + .stdin(Stdio::null()) + .stderr(Stdio::null()); + cmd +} + +fn first_file_from_config_with_origin(source: &BStr) -> Option<&BStr> { + let file = source.strip_prefix(b"file:")?; + let end_pos = file.find_byte(b'\0')?; + file[..end_pos].as_bstr().into() +} /// Try to find the file that contains git configuration coming with the git installation. /// @@ -135,12 +176,6 @@ pub(super) fn install_config_path() -> Option<&'static BStr> { PATH.as_ref().map(AsRef::as_ref) } -fn first_file_from_config_with_origin(source: &BStr) -> Option<&BStr> { - let file = source.strip_prefix(b"file:")?; - let end_pos = file.find_byte(b'\t')?; - file[..end_pos].trim_with(|c| c == '"').as_bstr().into() -} - /// Given `config_path` as obtained from `install_config_path()`, return the path of the git installation base. pub(super) fn config_to_base_path(config_path: &Path) -> &Path { config_path diff --git a/gix-path/src/env/git/tests.rs b/gix-path/src/env/git/tests.rs index 033b6f0b983..e7cf48310bc 100644 --- a/gix-path/src/env/git/tests.rs +++ b/gix-path/src/env/git/tests.rs @@ -1,50 +1,5 @@ use std::path::Path; -#[test] -fn config_to_base_path() { - for (input, expected) in [ - ( - "/Applications/Xcode.app/Contents/Developer/usr/share/git-core/gitconfig", - "/Applications/Xcode.app/Contents/Developer/usr/share/git-core", - ), - ("C:/git-sdk-64/etc/gitconfig", "C:/git-sdk-64/etc"), - ("C:\\ProgramData/Git/config", "C:\\ProgramData/Git"), - ("C:/Program Files/Git/etc/gitconfig", "C:/Program Files/Git/etc"), - ] { - assert_eq!(super::config_to_base_path(Path::new(input)), Path::new(expected)); - } -} - -#[test] -fn first_file_from_config_with_origin() { - let macos = "file:/Applications/Xcode.app/Contents/Developer/usr/share/git-core/gitconfig credential.helper=osxkeychain\nfile:/Users/byron/.gitconfig push.default=simple\n"; - let win_msys = - "file:C:/git-sdk-64/etc/gitconfig core.symlinks=false\r\nfile:C:/git-sdk-64/etc/gitconfig core.autocrlf=true"; - let win_cmd = "file:C:/Program Files/Git/etc/gitconfig diff.astextplain.textconv=astextplain\r\nfile:C:/Program Files/Git/etc/gitconfig filter.lfs.clean=gix-lfs clean -- %f\r\n"; - let win_msys_old = "file:\"C:\\ProgramData/Git/config\" diff.astextplain.textconv=astextplain\r\nfile:\"C:\\ProgramData/Git/config\" filter.lfs.clean=git-lfs clean -- %f\r\n"; - let linux = "file:/home/parallels/.gitconfig core.excludesfile=~/.gitignore\n"; - let bogus = "something unexpected"; - let empty = ""; - - for (source, expected) in [ - ( - macos, - Some("/Applications/Xcode.app/Contents/Developer/usr/share/git-core/gitconfig"), - ), - (win_msys, Some("C:/git-sdk-64/etc/gitconfig")), - (win_msys_old, Some("C:\\ProgramData/Git/config")), - (win_cmd, Some("C:/Program Files/Git/etc/gitconfig")), - (linux, Some("/home/parallels/.gitconfig")), - (bogus, None), - (empty, None), - ] { - assert_eq!( - super::first_file_from_config_with_origin(source.into()), - expected.map(Into::into) - ); - } -} - #[cfg(windows)] mod locations { use std::ffi::{OsStr, OsString}; @@ -401,3 +356,250 @@ mod locations { assert!(super::super::ALTERNATIVE_LOCATIONS.is_empty()); } } + +mod exe_info { + use std::path::{Path, PathBuf}; + + use crate::env::git::{exe_info, NULL_DEVICE}; + use gix_testtools::tempfile; + use serial_test::serial; + + /// Wrapper for a valid path to a plausible location, kept from accidentally existing (until drop). + #[derive(Debug)] + struct NonexistentLocation { + _empty: tempfile::TempDir, + nonexistent: PathBuf, + } + + impl NonexistentLocation { + fn new() -> Self { + let empty = tempfile::tempdir().expect("can create new temporary subdirectory"); + + let nonexistent = empty + .path() + .canonicalize() + .expect("path to the new directory works") + .join("nonexistent"); + + assert!(!nonexistent.exists(), "Test bug: Need nonexistent directory"); + + Self { + _empty: empty, + nonexistent, + } + } + + fn path(&self) -> &Path { + &self.nonexistent + } + } + + fn set_temp_env_vars<'a>(path: &Path) -> gix_testtools::Env<'a> { + let path_str = path.to_str().expect("valid Unicode"); + + let env = gix_testtools::Env::new() + .set("TMPDIR", path_str) // Mainly for Unix. + .set("TMP", path_str) // Mainly for Windows. + .set("TEMP", path_str); // Mainly for Windows, too. + + assert_eq!( + std::env::temp_dir(), + path, + "Possible test bug: Temp dir path may not have been customized successfully" + ); + + env + } + + fn unset_windows_directory_vars<'a>() -> gix_testtools::Env<'a> { + gix_testtools::Env::new().unset("windir").unset("SystemRoot") + } + + fn check_exe_info() { + let path = exe_info() + .map(crate::from_bstring) + .expect("It is present in the test environment (nonempty config)"); + + assert!( + path.is_absolute(), + "It is absolute (unless overridden such as with GIT_CONFIG_SYSTEM)" + ); + assert!( + path.exists(), + "It should exist on disk, since `git config` just found an entry there" + ); + } + + #[test] + #[serial] + fn with_unmodified_environment() { + check_exe_info(); + } + + #[test] + #[serial] + fn tolerates_broken_temp() { + let non = NonexistentLocation::new(); + let _env = set_temp_env_vars(non.path()); + check_exe_info(); + } + + #[test] + #[serial] + fn tolerates_oversanitized_env() { + // This test runs on all systems, but it is only checking for a Windows regression. Also, on + // Windows, having both a broken temp dir and an over-sanitized environment is not supported. + let _env = unset_windows_directory_vars(); + check_exe_info(); + } + + #[test] + #[serial] + fn same_result_with_broken_temp() { + let with_unmodified_temp = exe_info(); + + let with_nonexistent_temp = { + let non = NonexistentLocation::new(); + let _env = set_temp_env_vars(non.path()); + exe_info() + }; + + assert_eq!(with_unmodified_temp, with_nonexistent_temp); + } + + #[test] + #[serial] + fn same_result_with_oversanitized_env() { + let with_unmodified_env = exe_info(); + + let with_oversanitized_env = { + let _env = unset_windows_directory_vars(); + exe_info() + }; + + assert_eq!(with_unmodified_env, with_oversanitized_env); + } + + #[test] + #[serial] + #[cfg(not(target_os = "macos"))] // Assumes no higher "unknown" scope. The `nosystem` case works. + fn never_from_local_scope() { + let repo = gix_testtools::scripted_fixture_read_only("local_config.sh").expect("script succeeds"); + + let _cwd = gix_testtools::set_current_dir(repo).expect("can change to repo dir"); + let _env = gix_testtools::Env::new() + .set("GIT_CONFIG_SYSTEM", NULL_DEVICE) + .set("GIT_CONFIG_GLOBAL", NULL_DEVICE); + + let maybe_path = exe_info(); + assert_eq!( + maybe_path, None, + "Should find no config path if the config would be local (empty system config)" + ); + } + + #[test] + #[serial] + fn never_from_local_scope_nosystem() { + let repo = gix_testtools::scripted_fixture_read_only("local_config.sh").expect("script succeeds"); + + let _cwd = gix_testtools::set_current_dir(repo).expect("can change to repo dir"); + let _env = gix_testtools::Env::new() + .set("GIT_CONFIG_NOSYSTEM", "1") + .set("GIT_CONFIG_GLOBAL", NULL_DEVICE); + + let maybe_path = exe_info(); + assert_eq!( + maybe_path, None, + "Should find no config path if the config would be local (suppressed system config)" + ); + } + + #[test] + #[serial] + #[cfg(not(target_os = "macos"))] // Assumes no higher "unknown" scope. The `nosystem` case works. + fn never_from_local_scope_even_if_temp_is_here() { + let repo = gix_testtools::scripted_fixture_read_only("local_config.sh") + .expect("script succeeds") + .canonicalize() + .expect("repo path is valid and exists"); + + let _cwd = gix_testtools::set_current_dir(&repo).expect("can change to repo dir"); + let _env = set_temp_env_vars(&repo) + .set("GIT_CONFIG_SYSTEM", NULL_DEVICE) + .set("GIT_CONFIG_GLOBAL", NULL_DEVICE); + + let maybe_path = exe_info(); + assert_eq!( + maybe_path, None, + "Should find no config path if the config would be local even in a `/tmp`-like dir (empty system config)" + ); + } + + #[test] + #[serial] + fn never_from_local_scope_even_if_temp_is_here_nosystem() { + let repo = gix_testtools::scripted_fixture_read_only("local_config.sh") + .expect("script succeeds") + .canonicalize() + .expect("repo path is valid and exists"); + + let _cwd = gix_testtools::set_current_dir(&repo).expect("can change to repo dir"); + let _env = set_temp_env_vars(&repo) + .set("GIT_CONFIG_NOSYSTEM", "1") + .set("GIT_CONFIG_GLOBAL", NULL_DEVICE); + + let maybe_path = exe_info(); + assert_eq!( + maybe_path, None, + "Should find no config path if the config would be local even in a `/tmp`-like dir (suppressed system config)" + ); + } +} + +#[test] +fn first_file_from_config_with_origin() { + let macos = + "file:/Applications/Xcode.app/Contents/Developer/usr/share/git-core/gitconfig\0credential.helper\0file:/Users/byron/.gitconfig\0push.default\0"; + let win_msys = "file:C:/git-sdk-64/etc/gitconfig\0core.symlinks\0file:C:/git-sdk-64/etc/gitconfig\0core.autocrlf\0"; + let win_cmd = + "file:C:/Program Files/Git/etc/gitconfig\0diff.astextplain.textconv\0file:C:/Program Files/Git/etc/gitconfig\0filter.lfs.clean\0"; + let win_msys_old = + "file:C:\\ProgramData/Git/config\0diff.astextplain.textconv\0file:C:\\ProgramData/Git/config\0filter.lfs.clean\0"; + let linux = "file:/home/parallels/.gitconfig\0core.excludesfile\0"; + let bogus = "something unexpected"; + let empty = ""; + + for (source, expected) in [ + ( + macos, + Some("/Applications/Xcode.app/Contents/Developer/usr/share/git-core/gitconfig"), + ), + (win_msys, Some("C:/git-sdk-64/etc/gitconfig")), + (win_msys_old, Some("C:\\ProgramData/Git/config")), + (win_cmd, Some("C:/Program Files/Git/etc/gitconfig")), + (linux, Some("/home/parallels/.gitconfig")), + (bogus, None), + (empty, None), + ] { + assert_eq!( + super::first_file_from_config_with_origin(source.into()), + expected.map(Into::into) + ); + } +} + +#[test] +fn config_to_base_path() { + for (input, expected) in [ + ( + "/Applications/Xcode.app/Contents/Developer/usr/share/git-core/gitconfig", + "/Applications/Xcode.app/Contents/Developer/usr/share/git-core", + ), + ("C:/git-sdk-64/etc/gitconfig", "C:/git-sdk-64/etc"), + ("C:\\ProgramData/Git/config", "C:\\ProgramData/Git"), + ("C:/Program Files/Git/etc/gitconfig", "C:/Program Files/Git/etc"), + ] { + assert_eq!(super::config_to_base_path(Path::new(input)), Path::new(expected)); + } +} diff --git a/gix-path/tests/fixtures/generated-archives/local_config.tar b/gix-path/tests/fixtures/generated-archives/local_config.tar new file mode 100644 index 00000000000..7a915f5d054 Binary files /dev/null and b/gix-path/tests/fixtures/generated-archives/local_config.tar differ diff --git a/gix-path/tests/fixtures/local_config.sh b/gix-path/tests/fixtures/local_config.sh new file mode 100755 index 00000000000..26dda271a0d --- /dev/null +++ b/gix-path/tests/fixtures/local_config.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q + +# Shouldn't be necessary, as a repo starts with some config vars, but this removes any doubt. +git config --local foo.bar baz diff --git a/gix-path/tests/realpath/mod.rs b/gix-path/tests/realpath/mod.rs index 331f86dff3f..321ca67fcb7 100644 --- a/gix-path/tests/realpath/mod.rs +++ b/gix-path/tests/realpath/mod.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use bstr::ByteVec; -use tempfile::tempdir; +use gix_testtools::tempfile; use gix_path::{realpath::Error, realpath_opts}; @@ -28,7 +28,7 @@ fn fuzzed_timeout() -> crate::Result { #[test] fn assorted() -> crate::Result { - let cwd = tempdir()?; + let cwd = tempfile::tempdir()?; let cwd = cwd.path(); let symlinks_disabled = 0;