mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
git-utils: centralize repository authority and trusted Git launch
This commit is contained in:
Generated
+1
@@ -3137,6 +3137,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"pretty_assertions",
|
||||
"regex",
|
||||
"same-file",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"similar",
|
||||
|
||||
@@ -381,6 +381,7 @@ rustls = { version = "0.23", default-features = false, features = [
|
||||
] }
|
||||
rustls-native-certs = "0.8.3"
|
||||
rustls-pki-types = "1.14.0"
|
||||
same-file = "1.0.6"
|
||||
schemars = "0.8.22"
|
||||
seccompiler = "0.5.0"
|
||||
semver = "1.0"
|
||||
|
||||
@@ -19,6 +19,7 @@ futures = { workspace = true, features = ["alloc"] }
|
||||
gix = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
regex = "1"
|
||||
same-file = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
similar = { workspace = true }
|
||||
|
||||
@@ -127,12 +127,11 @@ fn resolve_git_root(
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<PathBuf> {
|
||||
let requested_cwd = std::fs::canonicalize(cwd)?;
|
||||
let mut command = git.command();
|
||||
let mut command = git.command_for_cwd(&requested_cwd)?;
|
||||
command
|
||||
.args(git_config_args)
|
||||
.arg("rev-parse")
|
||||
.arg("--show-toplevel")
|
||||
.current_dir(&requested_cwd);
|
||||
.arg("--show-toplevel");
|
||||
let out = git.output(command)?;
|
||||
let code = out.status.code().unwrap_or(-1);
|
||||
if code != 0 {
|
||||
@@ -198,14 +197,13 @@ pub(crate) fn run_git(
|
||||
git_cfg: &[String],
|
||||
args: &[String],
|
||||
) -> io::Result<(i32, String, String)> {
|
||||
let mut cmd = git.command();
|
||||
let mut cmd = git.command_for_cwd(cwd)?;
|
||||
for p in git_cfg {
|
||||
cmd.arg(p);
|
||||
}
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd.current_dir(cwd);
|
||||
let out = git.output(cmd)?;
|
||||
let code = out.status.code().unwrap_or(-1);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
@@ -666,4 +664,31 @@ diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1
|
||||
assert!(error.to_string().contains("instead of expected worktree"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn apply_propagates_unsafe_repository_metadata_before_git_launch() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
let external = tempfile::tempdir().expect("external metadata parent");
|
||||
let admin = external.path().join("admin");
|
||||
std::fs::rename(root.join(".git"), &admin).expect("move metadata external");
|
||||
symlink(&admin, root.join("switch")).expect("worktree metadata switch");
|
||||
std::fs::write(root.join(".git"), "gitdir: switch\n").expect("write gitdir marker");
|
||||
let (code, _, stderr) = run(root, &["git", "rev-parse", "--absolute-git-dir"]);
|
||||
assert_eq!(code, 0, "native Git fixture: {stderr}");
|
||||
|
||||
let request = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: String::new(),
|
||||
revert: false,
|
||||
preflight: true,
|
||||
};
|
||||
let error = apply_git_patch(&request).expect_err("unsafe metadata route");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert!(error.to_string().contains("Git metadata route crosses"));
|
||||
assert!(!error.to_string().contains("PATH"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,34 @@ pub enum GitToolingError {
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub(crate) enum GitReadError {
|
||||
#[error("no trusted Git executable is available")]
|
||||
#[error(
|
||||
"no trusted native Git executable is available; script-based and non-native Git wrappers are skipped, so install a native Git binary outside the repository and place its directory on PATH"
|
||||
)]
|
||||
NoTrustedGit,
|
||||
#[error(
|
||||
"refusing non-bare linked worktree because its primary worktree cannot be proven from Git metadata: {common_dir}; run the operation from the primary worktree, or use a standard linked-worktree or plain bare-backed layout"
|
||||
)]
|
||||
UnprovenPrimaryAuthority { common_dir: String },
|
||||
#[error("unsafe Git repository metadata at {path:?}: {reason}")]
|
||||
UnsafeRepositoryMetadata { path: PathBuf, reason: String },
|
||||
#[error("invalid or unsupported Git repository metadata at {path:?}: {reason}")]
|
||||
InvalidRepositoryMetadata { path: PathBuf, reason: String },
|
||||
#[error("{path:?} is not a Git repository")]
|
||||
NotRepository { path: PathBuf },
|
||||
}
|
||||
|
||||
impl GitReadError {
|
||||
pub(crate) fn io_kind(&self) -> std::io::ErrorKind {
|
||||
match self {
|
||||
Self::NoTrustedGit | Self::NotRepository { .. } => std::io::ErrorKind::NotFound,
|
||||
Self::UnprovenPrimaryAuthority { .. } | Self::UnsafeRepositoryMetadata { .. } => {
|
||||
std::io::ErrorKind::PermissionDenied
|
||||
}
|
||||
Self::InvalidRepositoryMetadata { .. } => std::io::ErrorKind::InvalidData,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_io_error(self) -> std::io::Error {
|
||||
std::io::Error::new(self.io_kind(), self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,368 +1,132 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
#[cfg(windows)]
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(windows)]
|
||||
use std::path::Prefix;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::errors::GitReadError;
|
||||
use crate::git_config::path_is_within;
|
||||
#[cfg(test)]
|
||||
use crate::git_executable::git_executable_name;
|
||||
use crate::git_executable::harden_git_launch_environment;
|
||||
#[cfg(test)]
|
||||
use crate::git_executable::path_is_untrusted;
|
||||
#[cfg(test)]
|
||||
use crate::git_executable::search_directory_is_untrusted;
|
||||
use crate::git_executable::select_git_executable;
|
||||
#[cfg(all(test, windows))]
|
||||
use crate::git_executable::windows_path_requires_fail_closed;
|
||||
use crate::repository_authority::RepositoryAuthority;
|
||||
#[cfg(test)]
|
||||
use crate::repository_authority::parse_marker_path as parse_git_marker_path;
|
||||
use crate::safe_git::isolate_git_command_environment;
|
||||
|
||||
/// A Git executable outside the repository-controlled roots for one operation.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct GitRunner {
|
||||
/// Canonical executable target pinned at selection time. Never execute the
|
||||
/// mutable PATH spelling after validation.
|
||||
executable: PathBuf,
|
||||
argv0: PathBuf,
|
||||
safe_path: std::ffi::OsString,
|
||||
authority: RepositoryAuthority,
|
||||
}
|
||||
|
||||
struct UntrustedGitLocations {
|
||||
roots: Vec<PathBuf>,
|
||||
common_dirs: Vec<PathBuf>,
|
||||
/// A Git command that can only be spawned through [`GitRunner::output`],
|
||||
/// keeping metadata revalidation and launch hardening at one choke point.
|
||||
pub(crate) struct GitCommand {
|
||||
inner: Command,
|
||||
}
|
||||
|
||||
impl GitCommand {
|
||||
pub(crate) fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
|
||||
self.inner.arg(arg);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn args<I, S>(&mut self, args: I) -> &mut Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
self.inner.args(args);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
|
||||
self.inner.env(key, value);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl GitRunner {
|
||||
pub(crate) fn for_cwd(cwd: &Path) -> Result<Self, GitReadError> {
|
||||
let locations = untrusted_git_locations_for_cwd(cwd)?;
|
||||
let authority = repository_authority_for_cwd(cwd)?;
|
||||
let search_path = std::env::var_os("PATH").ok_or(GitReadError::NoTrustedGit)?;
|
||||
Self::from_search_path(&locations, &search_path)
|
||||
Self::from_search_path(authority, &search_path)
|
||||
}
|
||||
|
||||
pub(crate) fn for_cwd_io(cwd: &Path) -> io::Result<Self> {
|
||||
Self::for_cwd(cwd).map_err(|error| io::Error::new(io::ErrorKind::NotFound, error))
|
||||
Self::for_cwd(cwd).map_err(GitReadError::into_io_error)
|
||||
}
|
||||
|
||||
pub(crate) fn command(&self) -> Command {
|
||||
Command::new(&self.executable)
|
||||
pub(crate) fn command(&self) -> GitCommand {
|
||||
let mut command = Command::new(&self.executable);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
command.arg0(&self.argv0);
|
||||
}
|
||||
if let Some(parent) = self.executable.parent() {
|
||||
command.current_dir(parent);
|
||||
}
|
||||
harden_git_launch_environment(&mut command, &self.safe_path);
|
||||
GitCommand { inner: command }
|
||||
}
|
||||
|
||||
pub(crate) fn output(&self, mut command: Command) -> io::Result<std::process::Output> {
|
||||
isolate_git_command_environment(&mut command);
|
||||
command.envs(crate::local_only_git_env());
|
||||
command.output()
|
||||
pub(crate) fn command_for_cwd(&self, cwd: &Path) -> io::Result<GitCommand> {
|
||||
let cwd = if cwd.is_absolute() {
|
||||
cwd.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()?.join(cwd)
|
||||
};
|
||||
let cwd = self.authority.canonical_command_cwd(&cwd)?;
|
||||
let mut command = self.command();
|
||||
command.arg("-C").arg(cwd);
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub(crate) fn output(&self, mut command: GitCommand) -> io::Result<std::process::Output> {
|
||||
self.revalidate_active_repository_metadata()?;
|
||||
isolate_git_command_environment(&mut command.inner);
|
||||
command.inner.envs(crate::local_only_git_env());
|
||||
harden_git_launch_environment(&mut command.inner, &self.safe_path);
|
||||
command.inner.output()
|
||||
}
|
||||
|
||||
fn revalidate_active_repository_metadata(&self) -> io::Result<()> {
|
||||
self.authority.revalidate_active_repository_metadata()
|
||||
}
|
||||
|
||||
fn from_search_path(
|
||||
untrusted: &UntrustedGitLocations,
|
||||
authority: RepositoryAuthority,
|
||||
search_path: &OsStr,
|
||||
) -> Result<Self, GitReadError> {
|
||||
for directory in std::env::split_paths(search_path) {
|
||||
if !directory.is_absolute() {
|
||||
continue;
|
||||
}
|
||||
// Check the raw PATH spelling before canonicalization. A search
|
||||
// directory can traverse through an untrusted repository and
|
||||
// resolve outside it; checking only the resolved parent or
|
||||
// candidate would lose that provenance.
|
||||
if search_directory_is_untrusted(&directory, untrusted) {
|
||||
continue;
|
||||
}
|
||||
let candidate = directory.join(git_executable_name());
|
||||
if path_is_untrusted(&candidate, untrusted) {
|
||||
continue;
|
||||
}
|
||||
let Ok(canonical_parent) = std::fs::canonicalize(&directory) else {
|
||||
continue;
|
||||
};
|
||||
if path_is_untrusted(&canonical_parent, untrusted) {
|
||||
continue;
|
||||
}
|
||||
let Ok(canonical_candidate) = std::fs::canonicalize(&candidate) else {
|
||||
continue;
|
||||
};
|
||||
if path_is_untrusted(&canonical_candidate, untrusted)
|
||||
|| !is_native_executable_file(&canonical_candidate)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Ok(Self {
|
||||
// Preserve multicall spelling because argv[0] may select mode.
|
||||
executable: candidate,
|
||||
});
|
||||
}
|
||||
Err(GitReadError::NoTrustedGit)
|
||||
authority.ensure_primary_authority()?;
|
||||
let selected = select_git_executable(&authority, search_path)?;
|
||||
Ok(Self {
|
||||
executable: selected.executable,
|
||||
argv0: selected.argv0,
|
||||
safe_path: selected.safe_path,
|
||||
authority,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn untrusted_git_locations_for_cwd(cwd: &Path) -> Result<UntrustedGitLocations, GitReadError> {
|
||||
let lexical_cwd = if cwd.is_absolute() {
|
||||
cwd.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|_| GitReadError::NotRepository {
|
||||
path: cwd.to_path_buf(),
|
||||
})?
|
||||
.join(cwd)
|
||||
};
|
||||
let canonical_cwd = std::fs::canonicalize(cwd).map_err(|_| GitReadError::NotRepository {
|
||||
path: cwd.to_path_buf(),
|
||||
})?;
|
||||
let worktree_root = crate::get_git_repo_root(&canonical_cwd)
|
||||
.and_then(|root| std::fs::canonicalize(root).ok())
|
||||
.unwrap_or_else(|| canonical_cwd.clone());
|
||||
let mut locations = UntrustedGitLocations {
|
||||
roots: Vec::new(),
|
||||
common_dirs: Vec::new(),
|
||||
};
|
||||
record_repository_ancestry(&worktree_root, &mut locations)?;
|
||||
|
||||
// Canonicalization can erase a repository-controlled symlink prefix. Walk
|
||||
// the requested spelling too, deliberately retaining symlink and `..`
|
||||
// components so every lexical enclosing checkout remains untrusted.
|
||||
let lexical_base = if lexical_cwd.is_dir() {
|
||||
lexical_cwd
|
||||
} else {
|
||||
lexical_cwd
|
||||
.parent()
|
||||
.ok_or_else(|| GitReadError::NotRepository {
|
||||
path: cwd.to_path_buf(),
|
||||
})?
|
||||
.to_path_buf()
|
||||
};
|
||||
record_repository_ancestry(&lexical_base, &mut locations)?;
|
||||
|
||||
// Callers commonly obtain their default cwd from `current_dir()`, which
|
||||
// can already have erased a symlink spelling. Recover the standard logical
|
||||
// process cwd only when it is absolute and resolves to both the requested
|
||||
// cwd and the process cwd. Treating extra roots as untrusted cannot widen
|
||||
// executable selection.
|
||||
if let Some(logical_cwd) = validated_logical_process_cwd(&canonical_cwd) {
|
||||
record_repository_ancestry(&logical_cwd, &mut locations)?;
|
||||
}
|
||||
Ok(locations)
|
||||
}
|
||||
|
||||
fn validated_logical_process_cwd(canonical_cwd: &Path) -> Option<PathBuf> {
|
||||
let process_cwd = std::fs::canonicalize(std::env::current_dir().ok()?).ok()?;
|
||||
if !paths_equal(&process_cwd, canonical_cwd) {
|
||||
return None;
|
||||
}
|
||||
let logical_cwd = PathBuf::from(std::env::var_os("PWD")?);
|
||||
if !logical_cwd.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let canonical_logical_cwd = std::fs::canonicalize(&logical_cwd).ok()?;
|
||||
paths_equal(&canonical_logical_cwd, canonical_cwd).then_some(logical_cwd)
|
||||
}
|
||||
|
||||
fn record_repository_ancestry(
|
||||
start: &Path,
|
||||
locations: &mut UntrustedGitLocations,
|
||||
) -> Result<(), GitReadError> {
|
||||
push_unique(&mut locations.roots, start.to_path_buf());
|
||||
record_repository_marker(start, locations)?;
|
||||
for ancestor in start.parent().into_iter().flat_map(Path::ancestors) {
|
||||
let dot_git = ancestor.join(".git");
|
||||
match std::fs::symlink_metadata(&dot_git) {
|
||||
Ok(_) => {
|
||||
push_unique(&mut locations.roots, ancestor.to_path_buf());
|
||||
let canonical_root =
|
||||
std::fs::canonicalize(ancestor).map_err(|_| GitReadError::NoTrustedGit)?;
|
||||
push_unique(&mut locations.roots, canonical_root.clone());
|
||||
record_repository_marker(ancestor, locations)?;
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(_) => return Err(GitReadError::NoTrustedGit),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_repository_marker(
|
||||
worktree_root: &Path,
|
||||
locations: &mut UntrustedGitLocations,
|
||||
) -> Result<(), GitReadError> {
|
||||
let dot_git = worktree_root.join(".git");
|
||||
let common_dir = match std::fs::symlink_metadata(&dot_git) {
|
||||
Ok(_) => resolve_common_git_dir(&dot_git).map_err(|()| GitReadError::NoTrustedGit)?,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(_) => return Err(GitReadError::NoTrustedGit),
|
||||
};
|
||||
if !path_is_within(&common_dir, worktree_root) {
|
||||
push_unique(&mut locations.roots, common_dir.clone());
|
||||
}
|
||||
push_unique(&mut locations.common_dirs, common_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_unique(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
||||
if !paths.iter().any(|existing| paths_equal(existing, &path)) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn path_is_untrusted(path: &Path, locations: &UntrustedGitLocations) -> bool {
|
||||
if locations
|
||||
.roots
|
||||
.iter()
|
||||
.any(|root| path_is_within(path, root))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
locations
|
||||
.common_dirs
|
||||
.iter()
|
||||
.any(|common_dir| path_is_in_worktree_for_common_dir(path, common_dir))
|
||||
}
|
||||
|
||||
fn search_directory_is_untrusted(directory: &Path, locations: &UntrustedGitLocations) -> bool {
|
||||
#[cfg(windows)]
|
||||
if windows_path_requires_fail_closed(directory)
|
||||
|| windows_path_has_untrusted_canonical_ancestor(directory, locations)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
path_is_untrusted(directory, locations)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_path_requires_fail_closed(path: &Path) -> bool {
|
||||
let mut components = path.components();
|
||||
let supported_namespace = match components.next() {
|
||||
Some(Component::Prefix(prefix)) => match prefix.kind() {
|
||||
Prefix::Disk(_)
|
||||
| Prefix::VerbatimDisk(_)
|
||||
| Prefix::UNC(_, _)
|
||||
| Prefix::VerbatimUNC(_, _) => true,
|
||||
Prefix::DeviceNS(device) => windows_device_namespace_is_filesystem(device),
|
||||
Prefix::Verbatim(namespace) => namespace
|
||||
.to_str()
|
||||
.is_some_and(|namespace| namespace.eq_ignore_ascii_case("UNC")),
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
!supported_namespace || components.any(|component| matches!(component, Component::ParentDir))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_device_namespace_is_filesystem(device: &OsStr) -> bool {
|
||||
let bytes = device.as_encoded_bytes();
|
||||
bytes.eq_ignore_ascii_case(b"UNC")
|
||||
|| matches!(bytes, [drive, b':'] if drive.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_path_has_untrusted_canonical_ancestor(
|
||||
path: &Path,
|
||||
locations: &UntrustedGitLocations,
|
||||
) -> bool {
|
||||
let Ok(canonical_path) = std::fs::canonicalize(path) else {
|
||||
return true;
|
||||
};
|
||||
if path_is_untrusted(&canonical_path, locations) {
|
||||
return true;
|
||||
}
|
||||
for ancestor in path.ancestors().skip(1) {
|
||||
let canonical_ancestor = match std::fs::canonicalize(ancestor) {
|
||||
Ok(canonical_ancestor) => canonical_ancestor,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::InvalidInput
|
||||
) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Err(_) => return true,
|
||||
};
|
||||
if path_is_untrusted(&canonical_ancestor, locations) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn path_is_in_worktree_for_common_dir(path: &Path, expected_common_dir: &Path) -> bool {
|
||||
let path = if path.is_dir() {
|
||||
path
|
||||
} else {
|
||||
path.parent().unwrap_or(path)
|
||||
};
|
||||
for ancestor in path.ancestors() {
|
||||
let dot_git = ancestor.join(".git");
|
||||
match std::fs::symlink_metadata(&dot_git) {
|
||||
Ok(_) => match resolve_common_git_dir(&dot_git) {
|
||||
Ok(common_dir) if paths_equal(&common_dir, expected_common_dir) => return true,
|
||||
Ok(_) => {}
|
||||
Err(()) => return true,
|
||||
},
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(_) => return true,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn paths_equal(left: &Path, right: &Path) -> bool {
|
||||
path_is_within(left, right) && path_is_within(right, left)
|
||||
}
|
||||
|
||||
fn resolve_common_git_dir(dot_git: &Path) -> Result<PathBuf, ()> {
|
||||
if dot_git.is_dir() {
|
||||
return std::fs::canonicalize(dot_git).map_err(|_| ());
|
||||
}
|
||||
let contents = std::fs::read_to_string(dot_git).map_err(|_| ())?;
|
||||
let git_dir = contents
|
||||
.trim()
|
||||
.strip_prefix("gitdir:")
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty())
|
||||
.ok_or(())?;
|
||||
let git_dir = canonicalize_from(dot_git.parent().ok_or(())?, git_dir)?;
|
||||
let commondir = git_dir.join("commondir");
|
||||
if commondir.is_file() {
|
||||
let common_dir = std::fs::read_to_string(commondir).map_err(|_| ())?;
|
||||
let common_dir = common_dir.trim();
|
||||
if common_dir.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
return canonicalize_from(&git_dir, common_dir);
|
||||
}
|
||||
if git_dir
|
||||
.parent()
|
||||
.is_some_and(|parent| parent.file_name() == Some(OsStr::new("worktrees")))
|
||||
{
|
||||
return std::fs::canonicalize(git_dir.parent().and_then(Path::parent).ok_or(())?)
|
||||
.map_err(|_| ());
|
||||
}
|
||||
Ok(git_dir)
|
||||
}
|
||||
|
||||
fn canonicalize_from(base: &Path, path: &str) -> Result<PathBuf, ()> {
|
||||
std::fs::canonicalize(base.join(path)).map_err(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn git_executable_name() -> &'static str {
|
||||
"git.exe"
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn git_executable_name() -> &'static str {
|
||||
"git"
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_native_executable_file(path: &Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
std::fs::metadata(path)
|
||||
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_native_executable_file(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
|
||||
&& std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file())
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn is_native_executable_file(path: &Path) -> bool {
|
||||
std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file())
|
||||
pub(crate) fn repository_authority_for_cwd(
|
||||
cwd: &Path,
|
||||
) -> Result<RepositoryAuthority, GitReadError> {
|
||||
RepositoryAuthority::discover(cwd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::errors::GitReadError;
|
||||
use crate::repository_authority::RepositoryAuthority;
|
||||
#[cfg(windows)]
|
||||
use std::path::Component;
|
||||
#[cfg(windows)]
|
||||
use std::path::Prefix;
|
||||
|
||||
pub(crate) struct SelectedGitExecutable {
|
||||
pub(crate) executable: PathBuf,
|
||||
pub(crate) argv0: PathBuf,
|
||||
pub(crate) safe_path: OsString,
|
||||
}
|
||||
|
||||
pub(crate) fn select_git_executable(
|
||||
authority: &RepositoryAuthority,
|
||||
search_path: &OsStr,
|
||||
) -> Result<SelectedGitExecutable, GitReadError> {
|
||||
let mut safe_directories = Vec::new();
|
||||
let mut selected = None;
|
||||
for directory in std::env::split_paths(search_path) {
|
||||
if !directory.is_absolute() || search_directory_is_untrusted(&directory, authority) {
|
||||
continue;
|
||||
}
|
||||
let Ok(canonical_directory) = std::fs::canonicalize(&directory) else {
|
||||
continue;
|
||||
};
|
||||
if path_is_untrusted(&canonical_directory, authority)
|
||||
|| !std::fs::metadata(&canonical_directory).is_ok_and(|metadata| metadata.is_dir())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
push_unique_path(&mut safe_directories, canonical_directory);
|
||||
|
||||
if selected.is_some() {
|
||||
continue;
|
||||
}
|
||||
let candidate = directory.join(git_executable_name());
|
||||
if path_is_untrusted(&candidate, authority) {
|
||||
continue;
|
||||
}
|
||||
let Ok(canonical_candidate) = std::fs::canonicalize(&candidate) else {
|
||||
continue;
|
||||
};
|
||||
if path_is_untrusted(&canonical_candidate, authority)
|
||||
|| !is_native_executable_file(&canonical_candidate)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = canonical_candidate.parent() {
|
||||
push_unique_path(&mut safe_directories, parent.to_path_buf());
|
||||
}
|
||||
selected = Some((canonical_candidate, candidate));
|
||||
}
|
||||
let (executable, argv0) = selected.ok_or(GitReadError::NoTrustedGit)?;
|
||||
let safe_path =
|
||||
std::env::join_paths(safe_directories).map_err(|_| GitReadError::NoTrustedGit)?;
|
||||
Ok(SelectedGitExecutable {
|
||||
executable,
|
||||
argv0,
|
||||
safe_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn harden_git_launch_environment(
|
||||
command: &mut std::process::Command,
|
||||
safe_path: &OsStr,
|
||||
) {
|
||||
let mut names = std::env::vars_os()
|
||||
.map(|(name, _)| name)
|
||||
.filter(|name| startup_injection_variable(name))
|
||||
.collect::<Vec<_>>();
|
||||
names.extend(
|
||||
command
|
||||
.get_envs()
|
||||
.filter(|&(name, _)| startup_injection_variable(name))
|
||||
.map(|(name, _)| name.to_os_string()),
|
||||
);
|
||||
for name in names {
|
||||
command.env_remove(name);
|
||||
}
|
||||
command.env("PATH", safe_path);
|
||||
#[cfg(windows)]
|
||||
command.env("NoDefaultCurrentDirectoryInExePath", "1");
|
||||
}
|
||||
|
||||
fn startup_injection_variable(name: &OsStr) -> bool {
|
||||
let name = name.to_string_lossy().to_ascii_uppercase();
|
||||
name.starts_with("DYLD_")
|
||||
|| name.starts_with("LD_")
|
||||
|| name == "LIBPATH"
|
||||
|| name == "SHLIB_PATH"
|
||||
|| name.starts_with("CORECLR_")
|
||||
|| name.starts_with("COR_")
|
||||
|| name.starts_with("DOTNET_")
|
||||
|| name == "GCONV_PATH"
|
||||
|| name == "NIX_LD"
|
||||
|| name == "NIX_LD_LIBRARY_PATH"
|
||||
}
|
||||
|
||||
pub(crate) fn path_is_untrusted(path: &Path, authority: &RepositoryAuthority) -> bool {
|
||||
authority.path_is_untrusted_for_executable(path)
|
||||
}
|
||||
|
||||
pub(crate) fn search_directory_is_untrusted(
|
||||
directory: &Path,
|
||||
authority: &RepositoryAuthority,
|
||||
) -> bool {
|
||||
#[cfg(windows)]
|
||||
if windows_path_requires_fail_closed(directory)
|
||||
|| windows_path_has_untrusted_canonical_ancestor(directory, authority)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
path_is_untrusted(directory, authority)
|
||||
}
|
||||
|
||||
fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
||||
if !paths.iter().any(|existing| existing == &path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn windows_path_requires_fail_closed(path: &Path) -> bool {
|
||||
let mut components = path.components();
|
||||
let supported_namespace = match components.next() {
|
||||
Some(Component::Prefix(prefix)) => match prefix.kind() {
|
||||
Prefix::Disk(_)
|
||||
| Prefix::VerbatimDisk(_)
|
||||
| Prefix::UNC(_, _)
|
||||
| Prefix::VerbatimUNC(_, _) => true,
|
||||
Prefix::DeviceNS(device) => windows_device_namespace_is_filesystem(device),
|
||||
Prefix::Verbatim(namespace) => namespace
|
||||
.to_str()
|
||||
.is_some_and(|namespace| namespace.eq_ignore_ascii_case("UNC")),
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
!supported_namespace || components.any(|component| matches!(component, Component::ParentDir))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_device_namespace_is_filesystem(device: &OsStr) -> bool {
|
||||
let bytes = device.as_encoded_bytes();
|
||||
bytes.eq_ignore_ascii_case(b"UNC")
|
||||
|| matches!(bytes, [drive, b':'] if drive.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_path_has_untrusted_canonical_ancestor(
|
||||
path: &Path,
|
||||
authority: &RepositoryAuthority,
|
||||
) -> bool {
|
||||
let Ok(canonical_path) = std::fs::canonicalize(path) else {
|
||||
return true;
|
||||
};
|
||||
if path_is_untrusted(&canonical_path, authority) {
|
||||
return true;
|
||||
}
|
||||
for ancestor in path.ancestors().skip(1) {
|
||||
let canonical_ancestor = match std::fs::canonicalize(ancestor) {
|
||||
Ok(canonical_ancestor) => canonical_ancestor,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::InvalidInput
|
||||
) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Err(_) => return true,
|
||||
};
|
||||
if path_is_untrusted(&canonical_ancestor, authority) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn git_executable_name() -> &'static str {
|
||||
"git.exe"
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) fn git_executable_name() -> &'static str {
|
||||
"git"
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn is_native_executable_file(path: &Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let Ok(metadata) = std::fs::metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 {
|
||||
return false;
|
||||
}
|
||||
let Ok(bytes) = read_prefix(path, 4) else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
bytes.as_slice(),
|
||||
[0xfe, 0xed, 0xfa, 0xce]
|
||||
| [0xfe, 0xed, 0xfa, 0xcf]
|
||||
| [0xce, 0xfa, 0xed, 0xfe]
|
||||
| [0xcf, 0xfa, 0xed, 0xfe]
|
||||
| [0xca, 0xfe, 0xba, 0xbe]
|
||||
| [0xbe, 0xba, 0xfe, 0xca]
|
||||
| [0xca, 0xfe, 0xba, 0xbf]
|
||||
| [0xbf, 0xba, 0xfe, 0xca]
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub(crate) fn is_native_executable_file(path: &Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let Ok(metadata) = std::fs::metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
metadata.is_file()
|
||||
&& metadata.permissions().mode() & 0o111 != 0
|
||||
&& read_prefix(path, 4).is_ok_and(|bytes| bytes == b"\x7fELF")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn is_native_executable_file(path: &Path) -> bool {
|
||||
use std::io::Read;
|
||||
use std::io::Seek;
|
||||
use std::io::SeekFrom;
|
||||
|
||||
if !path
|
||||
.extension()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(metadata) = std::fs::metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
if !metadata.is_file() || metadata.len() < 68 {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut file) = std::fs::File::open(path) else {
|
||||
return false;
|
||||
};
|
||||
let mut dos = [0_u8; 64];
|
||||
if file.read_exact(&mut dos).is_err() || &dos[..2] != b"MZ" {
|
||||
return false;
|
||||
}
|
||||
let offset = u32::from_le_bytes(dos[60..64].try_into().expect("PE offset bytes")) as u64;
|
||||
if offset > 1024 * 1024 || offset + 4 > metadata.len() {
|
||||
return false;
|
||||
}
|
||||
let mut signature = [0_u8; 4];
|
||||
file.seek(SeekFrom::Start(offset)).is_ok()
|
||||
&& file.read_exact(&mut signature).is_ok()
|
||||
&& signature == *b"PE\0\0"
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(crate) fn is_native_executable_file(_path: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_prefix(path: &Path, length: usize) -> io::Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut bytes = vec![0; length];
|
||||
file.read_exact(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -6,11 +6,14 @@ mod errors;
|
||||
mod fsmonitor;
|
||||
mod git_command;
|
||||
mod git_config;
|
||||
mod git_executable;
|
||||
mod info;
|
||||
mod local_only;
|
||||
mod operations;
|
||||
mod patch_paths;
|
||||
mod path_authority;
|
||||
mod platform;
|
||||
mod repository_authority;
|
||||
mod safe_git;
|
||||
|
||||
pub use apply::ApplyGitRequest;
|
||||
|
||||
@@ -110,8 +110,7 @@ where
|
||||
}
|
||||
let command_string = build_command_string(&args_vec);
|
||||
let git = GitRunner::for_cwd_io(dir)?;
|
||||
let mut command = git.command();
|
||||
command.current_dir(dir);
|
||||
let mut command = git.command_for_cwd(dir)?;
|
||||
if let Some(envs) = env {
|
||||
for (key, value) in envs {
|
||||
command.env(key, value);
|
||||
|
||||
@@ -63,14 +63,13 @@ fn git_apply_numstat_paths(
|
||||
patch_path: &Path,
|
||||
revert: bool,
|
||||
) -> io::Result<Vec<String>> {
|
||||
let mut cmd = git.command();
|
||||
let command_cwd = patch_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let mut cmd = git.command_for_cwd(command_cwd)?;
|
||||
cmd.args(["apply", "--numstat", "-z"]);
|
||||
if revert {
|
||||
cmd.arg("-R");
|
||||
}
|
||||
cmd.arg("--")
|
||||
.arg(patch_path)
|
||||
.current_dir(patch_path.parent().unwrap_or_else(|| Path::new(".")));
|
||||
cmd.arg("--").arg(patch_path);
|
||||
let out = git.output(cmd)?;
|
||||
if !out.status.success() {
|
||||
return Err(io::Error::new(
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use same_file::Handle;
|
||||
|
||||
use crate::git_config::path_is_within;
|
||||
|
||||
mod cancellation;
|
||||
mod route_walker;
|
||||
#[cfg(any(windows, test))]
|
||||
mod windows_path;
|
||||
|
||||
use route_walker::RawRouteObservation;
|
||||
use route_walker::RouteObservationSnapshot;
|
||||
use route_walker::observe_route;
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) use windows_path::windows_authority_path_is_ambiguous;
|
||||
#[cfg(windows)]
|
||||
pub(crate) use windows_path::windows_path_is_ambiguous;
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum PathBoundary {
|
||||
Worktree,
|
||||
Metadata,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum CandidateBoundary {
|
||||
Outside,
|
||||
Metadata,
|
||||
ExactWorktree,
|
||||
Worktree,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct RepositoryRouteBoundaries {
|
||||
worktree_roots: Vec<PathBuf>,
|
||||
metadata_dirs: Vec<PathBuf>,
|
||||
worktree_identities: Vec<Handle>,
|
||||
metadata_identities: Vec<Handle>,
|
||||
}
|
||||
|
||||
pub(crate) struct RouteInspection {
|
||||
pub(crate) crosses_worktree: bool,
|
||||
pub(crate) touches_worktree: bool,
|
||||
pub(crate) crosses_metadata: bool,
|
||||
pub(crate) observed_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl RepositoryRouteBoundaries {
|
||||
pub(crate) fn inspect_route(&self, route: &Path) -> io::Result<RouteInspection> {
|
||||
inspect_route(route, self)
|
||||
}
|
||||
|
||||
pub(crate) fn contains_known_boundary(&self, path: &Path) -> io::Result<bool> {
|
||||
Ok(classify_candidate(
|
||||
path,
|
||||
&self.worktree_roots,
|
||||
&self.metadata_dirs,
|
||||
&self.worktree_identities,
|
||||
&self.metadata_identities,
|
||||
)? != CandidateBoundary::Outside)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn repository_route_boundaries(
|
||||
roots: &[PathBuf],
|
||||
common_dirs: &[PathBuf],
|
||||
) -> io::Result<RepositoryRouteBoundaries> {
|
||||
let mut worktree_roots = Vec::new();
|
||||
for root in roots {
|
||||
push_unique_location(&mut worktree_roots, root.clone())?;
|
||||
}
|
||||
let worktree_identities = directory_identities(&worktree_roots)?;
|
||||
|
||||
let mut metadata_dirs = Vec::new();
|
||||
for root in &worktree_roots {
|
||||
let marker = root.join(".git");
|
||||
let Ok(metadata) = std::fs::symlink_metadata(&marker) else {
|
||||
continue;
|
||||
};
|
||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let canonical_root = std::fs::canonicalize(root)?;
|
||||
let canonical_marker = std::fs::canonicalize(&marker)?;
|
||||
if canonical_marker == canonical_root.join(".git") {
|
||||
push_unique_location(&mut metadata_dirs, canonical_marker)?;
|
||||
}
|
||||
}
|
||||
for common in common_dirs {
|
||||
let within_standard_metadata = metadata_dirs
|
||||
.iter()
|
||||
.any(|metadata| path_is_within(common, metadata));
|
||||
let within_worktree = worktree_roots
|
||||
.iter()
|
||||
.any(|root| path_is_within(common, root))
|
||||
|| nearest_identity_boundary(common, &worktree_identities, &[])?
|
||||
== Some(PathBoundary::Worktree);
|
||||
if within_standard_metadata || !within_worktree {
|
||||
push_unique_location(&mut metadata_dirs, common.clone())?;
|
||||
}
|
||||
}
|
||||
|
||||
let metadata_identities = directory_identities(&metadata_dirs)?;
|
||||
Ok(RepositoryRouteBoundaries {
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
})
|
||||
}
|
||||
|
||||
fn push_unique_location(paths: &mut Vec<PathBuf>, path: PathBuf) -> io::Result<()> {
|
||||
for existing in paths.iter() {
|
||||
if paths_refer_to_same_location(existing, &path)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
paths.push(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn paths_refer_to_same_location(left: &Path, right: &Path) -> io::Result<bool> {
|
||||
if path_is_within(left, right) && path_is_within(right, left) {
|
||||
return Ok(true);
|
||||
}
|
||||
if let (Ok(left), Ok(right)) = (Handle::from_path(left), Handle::from_path(right)) {
|
||||
return Ok(left == right);
|
||||
}
|
||||
match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
|
||||
(Ok(left), Ok(right)) => Ok(left == right),
|
||||
(Err(left), _) if is_missing(&left) => Ok(false),
|
||||
(_, Err(right)) if is_missing(&right) => Ok(false),
|
||||
(Err(error), _) | (_, Err(error)) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_missing(error: &io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
)
|
||||
}
|
||||
|
||||
fn inspect_route(
|
||||
route: &Path,
|
||||
boundaries: &RepositoryRouteBoundaries,
|
||||
) -> io::Result<RouteInspection> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let route = route
|
||||
.to_str()
|
||||
.ok_or_else(|| invalid_data("non-UTF-8 Windows authority path"))?;
|
||||
if windows_path_is_ambiguous(route) {
|
||||
return Ok(RouteInspection {
|
||||
crosses_worktree: true,
|
||||
touches_worktree: true,
|
||||
crosses_metadata: true,
|
||||
observed_paths: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let observation = observe_route(route)?;
|
||||
let crosses_worktree = observation_crosses_worktree(
|
||||
&observation,
|
||||
&boundaries.worktree_roots,
|
||||
&boundaries.metadata_dirs,
|
||||
&boundaries.worktree_identities,
|
||||
&boundaries.metadata_identities,
|
||||
)?;
|
||||
let observed_paths = observation.observed_paths();
|
||||
let mut touches_worktree = false;
|
||||
let mut crosses_metadata = false;
|
||||
for path in &observed_paths {
|
||||
match classify_candidate(
|
||||
path,
|
||||
&boundaries.worktree_roots,
|
||||
&boundaries.metadata_dirs,
|
||||
&boundaries.worktree_identities,
|
||||
&boundaries.metadata_identities,
|
||||
)? {
|
||||
CandidateBoundary::Worktree | CandidateBoundary::ExactWorktree => {
|
||||
touches_worktree = true;
|
||||
}
|
||||
CandidateBoundary::Metadata => crosses_metadata = true,
|
||||
CandidateBoundary::Outside => {}
|
||||
}
|
||||
}
|
||||
Ok(RouteInspection {
|
||||
crosses_worktree,
|
||||
touches_worktree,
|
||||
crosses_metadata,
|
||||
observed_paths,
|
||||
})
|
||||
}
|
||||
|
||||
fn observation_crosses_worktree(
|
||||
observation: &RouteObservationSnapshot,
|
||||
worktree_roots: &[PathBuf],
|
||||
metadata_dirs: &[PathBuf],
|
||||
worktree_identities: &[Handle],
|
||||
metadata_identities: &[Handle],
|
||||
) -> io::Result<bool> {
|
||||
let raw_pivot = raw_observation_has_worktree_descendant_pivot(
|
||||
&observation.raw,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)?;
|
||||
let normalized_crosses = strict_candidate_crosses(
|
||||
&observation.raw.normalized,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)?;
|
||||
let projected_crosses = strict_candidate_crosses(
|
||||
&observation.raw.projected,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)?;
|
||||
if raw_pivot || normalized_crosses || projected_crosses {
|
||||
return Ok(true);
|
||||
}
|
||||
for hop in &observation.symlink_hops {
|
||||
// A symlink entry located directly under an exact worktree root is
|
||||
// already a mutable descendant; classify its parent strictly.
|
||||
if strict_candidate_crosses(
|
||||
&hop.parent,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)? || intermediate_candidate_crosses(
|
||||
&hop.entry,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)? || raw_observation_has_worktree_descendant_pivot(
|
||||
&hop.target,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)? || intermediate_observation_crosses(
|
||||
&hop.target,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)? || raw_observation_has_worktree_descendant_pivot(
|
||||
&hop.projected,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)? || terminal_observation_crosses(
|
||||
&hop.projected,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn intermediate_observation_crosses(
|
||||
observation: &RawRouteObservation,
|
||||
worktree_roots: &[PathBuf],
|
||||
metadata_dirs: &[PathBuf],
|
||||
worktree_identities: &[Handle],
|
||||
metadata_identities: &[Handle],
|
||||
) -> io::Result<bool> {
|
||||
Ok(intermediate_candidate_crosses(
|
||||
&observation.normalized,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)? || intermediate_candidate_crosses(
|
||||
&observation.projected,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)?)
|
||||
}
|
||||
|
||||
fn terminal_observation_crosses(
|
||||
observation: &RawRouteObservation,
|
||||
worktree_roots: &[PathBuf],
|
||||
metadata_dirs: &[PathBuf],
|
||||
worktree_identities: &[Handle],
|
||||
metadata_identities: &[Handle],
|
||||
) -> io::Result<bool> {
|
||||
Ok(strict_candidate_crosses(
|
||||
&observation.normalized,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)? || strict_candidate_crosses(
|
||||
&observation.projected,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)?)
|
||||
}
|
||||
|
||||
fn strict_candidate_crosses(
|
||||
path: &Path,
|
||||
worktree_roots: &[PathBuf],
|
||||
metadata_dirs: &[PathBuf],
|
||||
worktree_identities: &[Handle],
|
||||
metadata_identities: &[Handle],
|
||||
) -> io::Result<bool> {
|
||||
Ok(matches!(
|
||||
classify_candidate(
|
||||
path,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)?,
|
||||
CandidateBoundary::ExactWorktree | CandidateBoundary::Worktree
|
||||
))
|
||||
}
|
||||
|
||||
fn intermediate_candidate_crosses(
|
||||
path: &Path,
|
||||
worktree_roots: &[PathBuf],
|
||||
metadata_dirs: &[PathBuf],
|
||||
worktree_identities: &[Handle],
|
||||
metadata_identities: &[Handle],
|
||||
) -> io::Result<bool> {
|
||||
// Exact roots remain valid intermediates for Git-generated active metadata
|
||||
// routes such as a nested submodule's `nested/../.git/modules/...`.
|
||||
Ok(classify_candidate(
|
||||
path,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)? == CandidateBoundary::Worktree)
|
||||
}
|
||||
|
||||
fn raw_observation_has_worktree_descendant_pivot(
|
||||
observation: &RawRouteObservation,
|
||||
worktree_roots: &[PathBuf],
|
||||
metadata_dirs: &[PathBuf],
|
||||
worktree_identities: &[Handle],
|
||||
metadata_identities: &[Handle],
|
||||
) -> io::Result<bool> {
|
||||
for prefix in &observation.normalized_prefixes {
|
||||
// Active metadata routes are revalidated before each child, so an
|
||||
// exact nested root is allowed here. Retained registry and standard
|
||||
// directory routes use the stricter role-aware cancellation policy.
|
||||
let boundary = classify_candidate(
|
||||
prefix,
|
||||
worktree_roots,
|
||||
metadata_dirs,
|
||||
worktree_identities,
|
||||
metadata_identities,
|
||||
)?;
|
||||
if boundary == CandidateBoundary::Worktree {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn classify_candidate(
|
||||
path: &Path,
|
||||
worktree_roots: &[PathBuf],
|
||||
metadata_dirs: &[PathBuf],
|
||||
worktree_identities: &[Handle],
|
||||
metadata_identities: &[Handle],
|
||||
) -> io::Result<CandidateBoundary> {
|
||||
if metadata_dirs
|
||||
.iter()
|
||||
.any(|metadata| path_is_within(path, metadata))
|
||||
{
|
||||
return Ok(CandidateBoundary::Metadata);
|
||||
}
|
||||
if worktree_roots
|
||||
.iter()
|
||||
.any(|root| path_is_within(path, root) && path_is_within(root, path))
|
||||
{
|
||||
return Ok(CandidateBoundary::ExactWorktree);
|
||||
}
|
||||
if worktree_roots.iter().any(|root| path_is_within(path, root)) {
|
||||
return Ok(CandidateBoundary::Worktree);
|
||||
}
|
||||
for (depth, ancestor) in path.ancestors().enumerate() {
|
||||
let metadata = match std::fs::metadata(ancestor) {
|
||||
Ok(metadata) if metadata.is_dir() => metadata,
|
||||
Ok(_) => continue,
|
||||
Err(error) if is_missing(&error) => continue,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let identity = Handle::from_path(ancestor)?;
|
||||
if metadata_identities.contains(&identity) {
|
||||
return Ok(CandidateBoundary::Metadata);
|
||||
}
|
||||
if worktree_identities.contains(&identity) {
|
||||
return Ok(if depth == 0 && metadata.is_dir() {
|
||||
CandidateBoundary::ExactWorktree
|
||||
} else {
|
||||
CandidateBoundary::Worktree
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(CandidateBoundary::Outside)
|
||||
}
|
||||
|
||||
fn directory_identities(paths: &[PathBuf]) -> io::Result<Vec<Handle>> {
|
||||
paths
|
||||
.iter()
|
||||
.filter_map(|path| match std::fs::metadata(path) {
|
||||
Ok(metadata) if metadata.is_dir() => Some(Handle::from_path(path)),
|
||||
Ok(_) => None,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
None
|
||||
}
|
||||
Err(error) => Some(Err(error)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn nearest_identity_boundary(
|
||||
path: &Path,
|
||||
worktree_identities: &[Handle],
|
||||
metadata_identities: &[Handle],
|
||||
) -> io::Result<Option<PathBoundary>> {
|
||||
for ancestor in path.ancestors() {
|
||||
let metadata = match std::fs::metadata(ancestor) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if !metadata.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let identity = Handle::from_path(ancestor)?;
|
||||
if metadata_identities.contains(&identity) {
|
||||
return Ok(Some(PathBoundary::Metadata));
|
||||
}
|
||||
if worktree_identities.contains(&identity) {
|
||||
return Ok(Some(PathBoundary::Worktree));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use std::io;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use same_file::Handle;
|
||||
|
||||
use crate::git_config::path_is_within;
|
||||
|
||||
use super::CandidateBoundary;
|
||||
use super::RepositoryRouteBoundaries;
|
||||
use super::classify_candidate;
|
||||
use super::is_missing;
|
||||
use super::route_walker::RouteObservationSnapshot;
|
||||
use super::route_walker::observe_route;
|
||||
#[cfg(windows)]
|
||||
use super::windows_path_is_ambiguous;
|
||||
|
||||
impl RepositoryRouteBoundaries {
|
||||
pub(crate) fn route_cancels_worktree_descendant(&self, route: &Path) -> io::Result<bool> {
|
||||
if route_requires_fail_closed(route)? {
|
||||
return Ok(true);
|
||||
}
|
||||
observation_cancels_worktree_descendant(&observe_route(route)?, self)
|
||||
}
|
||||
|
||||
pub(crate) fn retained_route_is_untrusted(&self, route: &Path) -> io::Result<bool> {
|
||||
if route_requires_fail_closed(route)? {
|
||||
return Ok(true);
|
||||
}
|
||||
let observation = observe_route(route)?;
|
||||
if observation_cancels_worktree_descendant(&observation, self)? {
|
||||
return Ok(true);
|
||||
}
|
||||
// Unlike active metadata, retained registry routes are not rebound
|
||||
// before every child. A symlink or junction entry below a worktree is
|
||||
// therefore mutable authority even when it currently aliases metadata.
|
||||
// Parent identity classification covers Windows Unicode case aliases.
|
||||
for hop in &observation.symlink_hops {
|
||||
if matches!(
|
||||
classify_candidate(
|
||||
&hop.parent,
|
||||
&self.worktree_roots,
|
||||
&self.metadata_dirs,
|
||||
&self.worktree_identities,
|
||||
&self.metadata_identities,
|
||||
)?,
|
||||
CandidateBoundary::ExactWorktree | CandidateBoundary::Worktree
|
||||
) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn observation_cancels_worktree_descendant(
|
||||
observation: &RouteObservationSnapshot,
|
||||
boundaries: &RepositoryRouteBoundaries,
|
||||
) -> io::Result<bool> {
|
||||
if raw_spelling_cancels_worktree_descendant(&observation.raw.spelling, boundaries)? {
|
||||
return Ok(true);
|
||||
}
|
||||
for hop in &observation.symlink_hops {
|
||||
if raw_spelling_cancels_worktree_descendant(&hop.target.spelling, boundaries)?
|
||||
|| raw_spelling_cancels_worktree_descendant(&hop.projected.spelling, boundaries)?
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn route_requires_fail_closed(route: &Path) -> io::Result<bool> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let route = route.to_str().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"non-UTF-8 Windows authority path",
|
||||
)
|
||||
})?;
|
||||
Ok(windows_path_is_ambiguous(route))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = route;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_spelling_cancels_worktree_descendant(
|
||||
spelling: &Path,
|
||||
boundaries: &RepositoryRouteBoundaries,
|
||||
) -> io::Result<bool> {
|
||||
let mut prefix = PathBuf::new();
|
||||
for component in spelling.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
if candidate_is_proper_worktree_descendant(
|
||||
&prefix,
|
||||
&boundaries.worktree_roots,
|
||||
&boundaries.metadata_dirs,
|
||||
&boundaries.worktree_identities,
|
||||
&boundaries.metadata_identities,
|
||||
)? {
|
||||
return Ok(true);
|
||||
}
|
||||
prefix.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
_ => prefix.push(component.as_os_str()),
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(super) fn candidate_is_proper_worktree_descendant(
|
||||
path: &Path,
|
||||
worktree_roots: &[PathBuf],
|
||||
metadata_dirs: &[PathBuf],
|
||||
worktree_identities: &[Handle],
|
||||
metadata_identities: &[Handle],
|
||||
) -> io::Result<bool> {
|
||||
if metadata_dirs
|
||||
.iter()
|
||||
.any(|metadata| path_is_within(path, metadata))
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// A worktree-controlled spelling cannot become trusted merely because a
|
||||
// mutable symlink or junction currently aliases protected metadata. Keep
|
||||
// genuinely metadata-spelled paths exempt above, but reject lexical
|
||||
// worktree descendants before following identities.
|
||||
if worktree_roots
|
||||
.iter()
|
||||
.any(|root| path_is_within(path, root) && !path_is_within(root, path))
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let mut ancestor_identities = Vec::new();
|
||||
for (depth, ancestor) in path.ancestors().enumerate() {
|
||||
match std::fs::metadata(ancestor) {
|
||||
Ok(metadata) if metadata.is_dir() => {}
|
||||
Ok(_) => continue,
|
||||
Err(error) if is_missing(&error) => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
let identity = Handle::from_path(ancestor)?;
|
||||
if metadata_identities.contains(&identity) {
|
||||
return Ok(false);
|
||||
}
|
||||
ancestor_identities.push((depth, identity));
|
||||
}
|
||||
|
||||
Ok(ancestor_identities
|
||||
.into_iter()
|
||||
.any(|(depth, identity)| depth > 0 && worktree_identities.contains(&identity)))
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
#[cfg(windows)]
|
||||
use super::windows_path::windows_path_is_ambiguous;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct RawRouteObservation {
|
||||
pub(super) spelling: PathBuf,
|
||||
pub(super) normalized_prefixes: Vec<PathBuf>,
|
||||
pub(super) normalized: PathBuf,
|
||||
pub(super) projected: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct SymlinkRouteObservation {
|
||||
pub(super) entry: PathBuf,
|
||||
pub(super) parent: PathBuf,
|
||||
pub(super) target: RawRouteObservation,
|
||||
pub(super) projected: RawRouteObservation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct RouteObservationSnapshot {
|
||||
pub(super) raw: RawRouteObservation,
|
||||
pub(super) symlink_hops: Vec<SymlinkRouteObservation>,
|
||||
}
|
||||
|
||||
impl RouteObservationSnapshot {
|
||||
pub(super) fn observed_paths(&self) -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
append_raw_observation_paths(&mut paths, &self.raw);
|
||||
for hop in &self.symlink_hops {
|
||||
push_unique_spelling(&mut paths, hop.parent.clone());
|
||||
push_unique_spelling(&mut paths, hop.entry.clone());
|
||||
append_raw_observation_paths(&mut paths, &hop.target);
|
||||
append_raw_observation_paths(&mut paths, &hop.projected);
|
||||
}
|
||||
paths
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SymlinkRouteHop {
|
||||
entry: PathBuf,
|
||||
parent: PathBuf,
|
||||
target: PathBuf,
|
||||
projected: PathBuf,
|
||||
}
|
||||
|
||||
pub(super) fn observe_route(path: &Path) -> io::Result<RouteObservationSnapshot> {
|
||||
let raw = observe_raw_route(path)?;
|
||||
let symlink_hops = symlink_route_hops(path)?
|
||||
.into_iter()
|
||||
.map(|hop| {
|
||||
Ok(SymlinkRouteObservation {
|
||||
entry: hop.entry,
|
||||
parent: hop.parent,
|
||||
target: observe_raw_route(&hop.target)?,
|
||||
projected: observe_raw_route(&hop.projected)?,
|
||||
})
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
Ok(RouteObservationSnapshot { raw, symlink_hops })
|
||||
}
|
||||
|
||||
fn observe_raw_route(route: &Path) -> io::Result<RawRouteObservation> {
|
||||
let base = route
|
||||
.ancestors()
|
||||
.last()
|
||||
.ok_or_else(|| invalid_data("authority path has no root"))?;
|
||||
let normalized = AbsolutePathBuf::resolve_path_against_base(route, base).into_path_buf();
|
||||
let normalized_prefixes = route
|
||||
.ancestors()
|
||||
.map(|prefix| AbsolutePathBuf::resolve_path_against_base(prefix, base).into_path_buf())
|
||||
.collect();
|
||||
let projected = project_through_longest_existing_ancestor(route)?;
|
||||
Ok(RawRouteObservation {
|
||||
spelling: route.to_path_buf(),
|
||||
normalized_prefixes,
|
||||
normalized,
|
||||
projected,
|
||||
})
|
||||
}
|
||||
|
||||
fn append_raw_observation_paths(paths: &mut Vec<PathBuf>, observation: &RawRouteObservation) {
|
||||
for prefix in &observation.normalized_prefixes {
|
||||
push_unique_spelling(paths, prefix.clone());
|
||||
}
|
||||
push_unique_spelling(paths, observation.projected.clone());
|
||||
}
|
||||
|
||||
fn push_unique_spelling(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
||||
if !paths.iter().any(|existing| existing == &path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn symlink_route_hops(path: &Path) -> io::Result<Vec<SymlinkRouteHop>> {
|
||||
let mut hops = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
collect_symlink_route_hops(path, 0, &mut seen, &mut hops)?;
|
||||
Ok(hops)
|
||||
}
|
||||
|
||||
fn collect_symlink_route_hops(
|
||||
path: &Path,
|
||||
depth: usize,
|
||||
seen: &mut BTreeSet<PathBuf>,
|
||||
hops: &mut Vec<SymlinkRouteHop>,
|
||||
) -> io::Result<()> {
|
||||
if depth > 40 {
|
||||
return Err(invalid_data("authority path symlink cycle"));
|
||||
}
|
||||
if !seen.insert(path.to_path_buf()) {
|
||||
return Ok(());
|
||||
}
|
||||
for ancestor in path.ancestors() {
|
||||
match std::fs::symlink_metadata(ancestor) {
|
||||
Ok(_) => match std::fs::read_link(ancestor) {
|
||||
Ok(target) => {
|
||||
let parent = ancestor
|
||||
.parent()
|
||||
.ok_or_else(|| invalid_data("authority symlink has no parent"))?;
|
||||
let target = resolve_literal_path(target, parent);
|
||||
#[cfg(windows)]
|
||||
if target.to_str().is_none_or(windows_path_is_ambiguous) {
|
||||
return Err(invalid_data("ambiguous Windows authority symlink"));
|
||||
}
|
||||
let suffix = path
|
||||
.strip_prefix(ancestor)
|
||||
.map_err(|_| invalid_data("failed to project authority symlink path"))?;
|
||||
let projected = resolve_literal_path(suffix, &target);
|
||||
hops.push(SymlinkRouteHop {
|
||||
entry: ancestor.to_path_buf(),
|
||||
parent: parent.to_path_buf(),
|
||||
target,
|
||||
projected: projected.clone(),
|
||||
});
|
||||
collect_symlink_route_hops(&projected, depth + 1, seen, hops)?;
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput => {}
|
||||
Err(error) => return Err(error),
|
||||
},
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn project_through_longest_existing_ancestor(path: &Path) -> io::Result<PathBuf> {
|
||||
project_path(path, 0)
|
||||
}
|
||||
|
||||
fn project_path(path: &Path, symlink_depth: usize) -> io::Result<PathBuf> {
|
||||
if symlink_depth > 40 {
|
||||
return Err(invalid_data("too many authority path symlinks"));
|
||||
}
|
||||
if let Ok(canonical) = std::fs::canonicalize(path) {
|
||||
return Ok(canonical);
|
||||
}
|
||||
for ancestor in path.ancestors() {
|
||||
if std::fs::symlink_metadata(ancestor).is_ok()
|
||||
&& let Ok(target) = std::fs::read_link(ancestor)
|
||||
{
|
||||
let parent = ancestor
|
||||
.parent()
|
||||
.ok_or_else(|| invalid_data("authority symlink has no parent"))?;
|
||||
let target = resolve_literal_path(target, parent);
|
||||
#[cfg(windows)]
|
||||
if target.to_str().is_none_or(windows_path_is_ambiguous) {
|
||||
return Err(invalid_data("ambiguous Windows authority symlink"));
|
||||
}
|
||||
let suffix = path
|
||||
.strip_prefix(ancestor)
|
||||
.map_err(|_| invalid_data("failed to project authority path"))?;
|
||||
return project_path(&resolve_literal_path(suffix, &target), symlink_depth + 1);
|
||||
}
|
||||
}
|
||||
for ancestor in path.ancestors() {
|
||||
match std::fs::canonicalize(ancestor) {
|
||||
Ok(canonical) => {
|
||||
let suffix = path
|
||||
.strip_prefix(ancestor)
|
||||
.map_err(|_| invalid_data("failed to project authority path"))?;
|
||||
return Ok(
|
||||
AbsolutePathBuf::resolve_path_against_base(suffix, canonical).into_path_buf(),
|
||||
);
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(invalid_data("authority path has no existing ancestor"))
|
||||
}
|
||||
|
||||
fn resolve_literal_path(path: impl AsRef<Path>, base: &Path) -> PathBuf {
|
||||
let path = path.as_ref();
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
base.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn invalid_data(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
pub(crate) fn windows_path_is_ambiguous(path: &str) -> bool {
|
||||
let path = path.replace('/', "\\");
|
||||
if path.starts_with(r"\??\") {
|
||||
return true;
|
||||
}
|
||||
let path = if path
|
||||
.get(..8)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(r"\\?\UNC\"))
|
||||
|| path
|
||||
.get(..8)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(r"\\.\UNC\"))
|
||||
{
|
||||
format!(r"\\{}", &path[8..])
|
||||
} else if path.starts_with(r"\\?\") || path.starts_with(r"\\.\") {
|
||||
let path = &path[4..];
|
||||
if !matches!(path.as_bytes(), [drive, b':', b'\\', ..] if drive.is_ascii_alphabetic()) {
|
||||
return true;
|
||||
}
|
||||
path.to_string()
|
||||
} else {
|
||||
path
|
||||
};
|
||||
let bytes = path.as_bytes();
|
||||
let remainder = if matches!(bytes, [drive, b':', separator, ..]
|
||||
if drive.is_ascii_alphabetic() && *separator == b'\\')
|
||||
{
|
||||
&path[3..]
|
||||
} else {
|
||||
path.as_str()
|
||||
};
|
||||
if remainder.contains(':') {
|
||||
return true;
|
||||
}
|
||||
remainder.split('\\').any(|component| {
|
||||
!matches!(component, "." | "..")
|
||||
&& (component.ends_with(['.', ' ']) || windows_reserved_component(component))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn windows_authority_path_is_ambiguous(path: &str) -> bool {
|
||||
windows_path_is_ambiguous(path)
|
||||
|| path
|
||||
.replace('/', "\\")
|
||||
.split('\\')
|
||||
.any(|component| component == "..")
|
||||
}
|
||||
|
||||
fn windows_reserved_component(component: &str) -> bool {
|
||||
let stem = component
|
||||
.trim_end_matches(['.', ' '])
|
||||
.split_once('.')
|
||||
.map_or(component, |(stem, _)| stem);
|
||||
stem.eq_ignore_ascii_case("AUX")
|
||||
|| stem.eq_ignore_ascii_case("CON")
|
||||
|| stem.eq_ignore_ascii_case("CONIN$")
|
||||
|| stem.eq_ignore_ascii_case("CONOUT$")
|
||||
|| stem.eq_ignore_ascii_case("NUL")
|
||||
|| stem.eq_ignore_ascii_case("PRN")
|
||||
|| ["COM", "LPT"].iter().any(|prefix| {
|
||||
let Some(rest) = stem.get(3..) else {
|
||||
return false;
|
||||
};
|
||||
let mut chars = rest.chars();
|
||||
stem.get(..3)
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
|
||||
&& matches!(chars.next(), Some('1'..='9' | '¹' | '²' | '³'))
|
||||
&& chars.as_str().is_empty()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use same_file::Handle;
|
||||
|
||||
use helpers::RegisteredWorktree;
|
||||
use helpers::RegisteredWorktreeReadError;
|
||||
use helpers::registry_error;
|
||||
|
||||
mod authority;
|
||||
mod helpers;
|
||||
mod plain_config;
|
||||
pub(crate) use authority::RepositoryAuthority;
|
||||
pub(crate) use plain_config::CommonConfigAuthority;
|
||||
pub(crate) use plain_config::inspect_plain_common_config_authority;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum RepositoryMetadataKind {
|
||||
StandardPrimary,
|
||||
SeparatePrimary,
|
||||
Linked,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct ResolvedRepositoryMetadata {
|
||||
pub(crate) marker: PathBuf,
|
||||
pub(crate) git_dir: PathBuf,
|
||||
pub(crate) common_dir: PathBuf,
|
||||
pub(crate) kind: RepositoryMetadataKind,
|
||||
pub(crate) routes: Vec<RepositoryMetadataRoute>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct RepositoryMetadataRoute {
|
||||
pub(crate) spelling: PathBuf,
|
||||
pub(crate) target: PathBuf,
|
||||
pub(crate) kind: RepositoryMetadataRouteKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum RepositoryMetadataRouteKind {
|
||||
Traversal,
|
||||
StandardDirectory,
|
||||
}
|
||||
|
||||
pub(crate) fn read_bounded_marker(path: &Path) -> io::Result<Vec<u8>> {
|
||||
#[cfg(test)]
|
||||
BOUNDED_MARKER_READ_COUNT.with(|count| count.set(count.get() + 1));
|
||||
read_bounded_file(path, 64 * 1024, "Git metadata marker is too large")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static BOUNDED_MARKER_READ_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_bounded_marker_read_count() {
|
||||
BOUNDED_MARKER_READ_COUNT.with(|count| count.set(0));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn bounded_marker_read_count() -> usize {
|
||||
BOUNDED_MARKER_READ_COUNT.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_marker_path(contents: &[u8], prefix: &[u8]) -> io::Result<PathBuf> {
|
||||
let contents = contents
|
||||
.strip_prefix(prefix)
|
||||
.ok_or_else(|| invalid_data("malformed Git metadata marker"))?;
|
||||
let contents = trim_trailing_ascii_whitespace(contents);
|
||||
if contents.is_empty() || contents.contains(&0) {
|
||||
return Err(invalid_data("empty Git metadata marker path"));
|
||||
}
|
||||
bytes_to_path(contents)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_repository_metadata(
|
||||
dot_git: &Path,
|
||||
) -> io::Result<ResolvedRepositoryMetadata> {
|
||||
let metadata = std::fs::symlink_metadata(dot_git)?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(invalid_data("symlinked Git metadata marker"));
|
||||
}
|
||||
let marker_is_directory = metadata.is_dir();
|
||||
let (git_dir, git_dir_route, default_kind) = if marker_is_directory {
|
||||
(
|
||||
std::fs::canonicalize(dot_git)?,
|
||||
Some((
|
||||
dot_git.to_path_buf(),
|
||||
RepositoryMetadataRouteKind::StandardDirectory,
|
||||
)),
|
||||
RepositoryMetadataKind::StandardPrimary,
|
||||
)
|
||||
} else if metadata.is_file() {
|
||||
let contents = read_bounded_marker(dot_git)?;
|
||||
let route = raw_path_against(
|
||||
parse_marker_path(&contents, b"gitdir: ")?,
|
||||
dot_git
|
||||
.parent()
|
||||
.ok_or_else(|| invalid_data("Git metadata marker has no parent"))?,
|
||||
);
|
||||
let git_dir = std::fs::canonicalize(&route)?;
|
||||
(
|
||||
git_dir,
|
||||
Some((route, RepositoryMetadataRouteKind::Traversal)),
|
||||
RepositoryMetadataKind::SeparatePrimary,
|
||||
)
|
||||
} else {
|
||||
return Err(invalid_data("unsupported Git metadata marker"));
|
||||
};
|
||||
let commondir = git_dir.join("commondir");
|
||||
match std::fs::symlink_metadata(&commondir) {
|
||||
Ok(metadata) => {
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(invalid_data("unsupported Git common-dir marker"));
|
||||
}
|
||||
let contents = read_bounded_marker(&commondir)?;
|
||||
let route = raw_path_against(parse_marker_path(&contents, b"")?, &git_dir);
|
||||
let common_dir = std::fs::canonicalize(&route)?;
|
||||
let mut routes = git_dir_route
|
||||
.map(|(spelling, kind)| RepositoryMetadataRoute {
|
||||
spelling,
|
||||
target: git_dir.clone(),
|
||||
kind,
|
||||
})
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
routes.push(RepositoryMetadataRoute {
|
||||
spelling: route,
|
||||
target: common_dir.clone(),
|
||||
kind: RepositoryMetadataRouteKind::Traversal,
|
||||
});
|
||||
return Ok(ResolvedRepositoryMetadata {
|
||||
marker: dot_git.to_path_buf(),
|
||||
git_dir,
|
||||
common_dir,
|
||||
kind: RepositoryMetadataKind::Linked,
|
||||
routes,
|
||||
});
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
if git_dir
|
||||
.parent()
|
||||
.is_some_and(|parent| parent.file_name() == Some(std::ffi::OsStr::new("worktrees")))
|
||||
{
|
||||
let common_dir = git_dir
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.ok_or_else(|| invalid_data("linked Git admin dir has no common parent"))?;
|
||||
let common_dir = std::fs::canonicalize(common_dir)?;
|
||||
let routes = git_dir_route
|
||||
.map(|(spelling, kind)| RepositoryMetadataRoute {
|
||||
spelling,
|
||||
target: git_dir.clone(),
|
||||
kind,
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
return Ok(ResolvedRepositoryMetadata {
|
||||
marker: dot_git.to_path_buf(),
|
||||
git_dir,
|
||||
common_dir,
|
||||
kind: RepositoryMetadataKind::Linked,
|
||||
routes,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ResolvedRepositoryMetadata {
|
||||
marker: dot_git.to_path_buf(),
|
||||
common_dir: git_dir.clone(),
|
||||
git_dir: git_dir.clone(),
|
||||
kind: default_kind,
|
||||
routes: git_dir_route
|
||||
.map(|(spelling, kind)| RepositoryMetadataRoute {
|
||||
spelling,
|
||||
target: git_dir,
|
||||
kind,
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn repository_common_dir_for_candidate_root(root: &Path) -> io::Result<Option<PathBuf>> {
|
||||
let marker = root.join(".git");
|
||||
match std::fs::symlink_metadata(&marker) {
|
||||
Ok(_) => resolve_repository_metadata(&marker).map(|metadata| Some(metadata.common_dir)),
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn linked_common_dir_for_root(root: &Path) -> io::Result<Option<PathBuf>> {
|
||||
let marker = root.join(".git");
|
||||
let metadata = match std::fs::symlink_metadata(&marker) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(invalid_data("symlinked Git metadata marker"));
|
||||
}
|
||||
if !metadata.is_file() && !metadata.is_dir() {
|
||||
return Ok(None);
|
||||
}
|
||||
let resolved = resolve_repository_metadata(&marker)?;
|
||||
Ok((resolved.kind == RepositoryMetadataKind::Linked).then_some(resolved.common_dir))
|
||||
}
|
||||
|
||||
fn registered_worktrees(
|
||||
common_dir: &Path,
|
||||
) -> Result<Vec<RegisteredWorktree>, RegisteredWorktreeReadError> {
|
||||
let worktrees = common_dir.join("worktrees");
|
||||
match std::fs::symlink_metadata(&worktrees) {
|
||||
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
|
||||
Ok(_) => {
|
||||
return Err(registry_error(
|
||||
&worktrees,
|
||||
invalid_data("unsupported Git worktree registry"),
|
||||
));
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Err(error) => return Err(registry_error(&worktrees, error)),
|
||||
}
|
||||
|
||||
let mut registrations = Vec::new();
|
||||
let entries =
|
||||
std::fs::read_dir(&worktrees).map_err(|error| registry_error(&worktrees, error))?;
|
||||
for entry in entries {
|
||||
let admin_dir = entry
|
||||
.map_err(|error| registry_error(&worktrees, error))?
|
||||
.path();
|
||||
let metadata = std::fs::symlink_metadata(&admin_dir)
|
||||
.map_err(|error| registry_error(&admin_dir, error))?;
|
||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||
return Err(registry_error(
|
||||
&admin_dir,
|
||||
invalid_data("unsupported Git worktree registry entry"),
|
||||
));
|
||||
}
|
||||
let gitdir_marker = admin_dir.join("gitdir");
|
||||
let metadata = std::fs::symlink_metadata(&gitdir_marker)
|
||||
.map_err(|error| registry_error(&gitdir_marker, error))?;
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(registry_error(
|
||||
&gitdir_marker,
|
||||
invalid_data("unsupported Git worktree gitdir marker"),
|
||||
));
|
||||
}
|
||||
let contents = read_bounded_marker(&gitdir_marker)
|
||||
.map_err(|error| registry_error(&gitdir_marker, error))?;
|
||||
let marker_route = raw_path_against(
|
||||
parse_marker_path(&contents, b"")
|
||||
.map_err(|error| registry_error(&gitdir_marker, error))?,
|
||||
&admin_dir,
|
||||
);
|
||||
#[cfg(windows)]
|
||||
if marker_route
|
||||
.to_str()
|
||||
.is_none_or(crate::path_authority::windows_path_is_ambiguous)
|
||||
{
|
||||
return Err(registry_error(
|
||||
&gitdir_marker,
|
||||
invalid_data("ambiguous Windows Git worktree path"),
|
||||
));
|
||||
}
|
||||
// Git writes this protected registry marker from real paths (either
|
||||
// absolute or relative). Normalize it only after retaining the raw
|
||||
// route long enough to apply platform path grammar.
|
||||
let marker = resolve_path_against(marker_route.clone(), &admin_dir);
|
||||
if marker.file_name() != Some(std::ffi::OsStr::new(".git")) {
|
||||
return Err(registry_error(
|
||||
&gitdir_marker,
|
||||
invalid_data("malformed Git worktree gitdir marker"),
|
||||
));
|
||||
}
|
||||
let root = marker
|
||||
.parent()
|
||||
.ok_or_else(|| {
|
||||
registry_error(
|
||||
&gitdir_marker,
|
||||
invalid_data("Git worktree marker has no parent"),
|
||||
)
|
||||
})?
|
||||
.to_path_buf();
|
||||
registrations.push(RegisteredWorktree {
|
||||
root,
|
||||
admin_dir,
|
||||
marker_route,
|
||||
});
|
||||
}
|
||||
Ok(registrations)
|
||||
}
|
||||
|
||||
pub(crate) fn primary_authority_is_proven(
|
||||
current_root: &Path,
|
||||
common_dir: &Path,
|
||||
roots: &[PathBuf],
|
||||
) -> io::Result<bool> {
|
||||
for root in roots {
|
||||
if directories_refer_to_same_location(root, current_root).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let marker = root.join(".git");
|
||||
match std::fs::symlink_metadata(&marker) {
|
||||
Ok(_) => {
|
||||
let resolved = resolve_repository_metadata(&marker)?;
|
||||
if resolved.kind != RepositoryMetadataKind::Linked
|
||||
&& directories_refer_to_same_location(&resolved.common_dir, common_dir)?
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(crate) fn directories_refer_to_same_location(left: &Path, right: &Path) -> io::Result<bool> {
|
||||
let left_metadata = match std::fs::metadata(left) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let right_metadata = match std::fs::metadata(right) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if !left_metadata.is_dir() || !right_metadata.is_dir() {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(Handle::from_path(left)? == Handle::from_path(right)?)
|
||||
}
|
||||
|
||||
pub(crate) fn path_has_untrusted_root_identity_ancestor(path: &Path, roots: &[PathBuf]) -> bool {
|
||||
let mut root_identities = Vec::new();
|
||||
for root in roots {
|
||||
match std::fs::metadata(root) {
|
||||
Ok(metadata) if metadata.is_dir() => match Handle::from_path(root) {
|
||||
Ok(identity) => root_identities.push(identity),
|
||||
Err(_) => return true,
|
||||
},
|
||||
Ok(_) => return true,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) => {}
|
||||
Err(_) => return true,
|
||||
}
|
||||
}
|
||||
if root_identities.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let start = match std::fs::metadata(path) {
|
||||
Ok(metadata) if metadata.is_dir() => path,
|
||||
Ok(_) => path.parent().unwrap_or(path),
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
path.parent().unwrap_or(path)
|
||||
}
|
||||
Err(_) => return true,
|
||||
};
|
||||
for ancestor in start.ancestors() {
|
||||
match std::fs::metadata(ancestor) {
|
||||
Ok(metadata) if metadata.is_dir() => match Handle::from_path(ancestor) {
|
||||
Ok(identity) if root_identities.contains(&identity) => return true,
|
||||
Ok(_) => {}
|
||||
Err(_) => return true,
|
||||
},
|
||||
Ok(_) => continue,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) => {}
|
||||
Err(_) => return true,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn read_bounded_file(path: &Path, max_bytes: u64, too_large: &str) -> io::Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
|
||||
let file = std::fs::File::open(path)?;
|
||||
let mut contents = Vec::new();
|
||||
file.take(max_bytes + 1).read_to_end(&mut contents)?;
|
||||
if contents.len() as u64 > max_bytes {
|
||||
return Err(invalid_data(too_large));
|
||||
}
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
fn resolve_path_against(path: PathBuf, base: &Path) -> PathBuf {
|
||||
AbsolutePathBuf::resolve_path_against_base(path, base).into_path_buf()
|
||||
}
|
||||
|
||||
fn raw_path_against(path: PathBuf, base: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
base.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_to_path(bytes: &[u8]) -> io::Result<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
Ok(PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec())))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Ok(PathBuf::from(std::str::from_utf8(bytes).map_err(|_| {
|
||||
invalid_data("non-UTF-8 Git filesystem path")
|
||||
})?))
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_trailing_ascii_whitespace(mut value: &[u8]) -> &[u8] {
|
||||
while value.last().is_some_and(u8::is_ascii_whitespace) {
|
||||
value = &value[..value.len() - 1];
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn invalid_data(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "repository_authority_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,400 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::errors::GitReadError;
|
||||
use crate::git_config::path_is_within;
|
||||
use crate::path_authority::RepositoryRouteBoundaries;
|
||||
use crate::path_authority::repository_route_boundaries;
|
||||
|
||||
use super::CommonConfigAuthority;
|
||||
use super::RegisteredWorktree;
|
||||
use super::ResolvedRepositoryMetadata;
|
||||
use super::directories_refer_to_same_location;
|
||||
use super::helpers::common_dir_is_within_untrusted_root;
|
||||
use super::helpers::invalid_metadata;
|
||||
use super::helpers::paths_equal;
|
||||
use super::helpers::push_unique;
|
||||
use super::helpers::validated_logical_process_cwd;
|
||||
use super::inspect_plain_common_config_authority;
|
||||
use super::linked_common_dir_for_root;
|
||||
use super::primary_authority_is_proven;
|
||||
use super::registered_worktrees;
|
||||
use super::resolve_repository_metadata;
|
||||
|
||||
mod policy;
|
||||
|
||||
/// Repository and filesystem authority retained for the lifetime of one
|
||||
/// trusted Git runner.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RepositoryAuthority {
|
||||
active_worktree_root: PathBuf,
|
||||
roots: Vec<PathBuf>,
|
||||
worktree_roots: Vec<PathBuf>,
|
||||
common_dirs: Vec<PathBuf>,
|
||||
unproven_primary_common_dir: Option<PathBuf>,
|
||||
metadata_snapshots: Vec<ResolvedRepositoryMetadata>,
|
||||
registered_worktrees: Vec<RegisteredWorktree>,
|
||||
active_metadata: Option<ResolvedRepositoryMetadata>,
|
||||
route_boundaries: RepositoryRouteBoundaries,
|
||||
}
|
||||
|
||||
impl RepositoryAuthority {
|
||||
pub(crate) fn discover(cwd: &Path) -> Result<Self, GitReadError> {
|
||||
let lexical_cwd = if cwd.is_absolute() {
|
||||
cwd.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|_| GitReadError::NotRepository {
|
||||
path: cwd.to_path_buf(),
|
||||
})?
|
||||
.join(cwd)
|
||||
};
|
||||
let canonical_cwd =
|
||||
std::fs::canonicalize(cwd).map_err(|_| GitReadError::NotRepository {
|
||||
path: cwd.to_path_buf(),
|
||||
})?;
|
||||
let worktree_root = crate::get_git_repo_root(&canonical_cwd)
|
||||
.and_then(|root| std::fs::canonicalize(root).ok())
|
||||
.unwrap_or_else(|| canonical_cwd.clone());
|
||||
let mut authority = Self {
|
||||
active_worktree_root: worktree_root.clone(),
|
||||
roots: Vec::new(),
|
||||
worktree_roots: Vec::new(),
|
||||
common_dirs: Vec::new(),
|
||||
unproven_primary_common_dir: None,
|
||||
metadata_snapshots: Vec::new(),
|
||||
registered_worktrees: Vec::new(),
|
||||
active_metadata: None,
|
||||
route_boundaries: RepositoryRouteBoundaries::default(),
|
||||
};
|
||||
authority.record_repository_ancestry(&worktree_root)?;
|
||||
|
||||
// Canonicalization can erase a repository-controlled symlink prefix.
|
||||
// Retain the requested spelling so lexical enclosing repositories stay
|
||||
// in the authority boundary.
|
||||
let lexical_base = if lexical_cwd.is_dir() {
|
||||
lexical_cwd
|
||||
} else {
|
||||
lexical_cwd
|
||||
.parent()
|
||||
.ok_or_else(|| GitReadError::NotRepository {
|
||||
path: cwd.to_path_buf(),
|
||||
})?
|
||||
.to_path_buf()
|
||||
};
|
||||
authority.record_repository_ancestry(&lexical_base)?;
|
||||
if let Some(logical_cwd) = validated_logical_process_cwd(&canonical_cwd) {
|
||||
authority.record_repository_ancestry(&logical_cwd)?;
|
||||
}
|
||||
authority.record_preselection_primary_authorities()?;
|
||||
authority.active_metadata = authority
|
||||
.metadata_snapshots
|
||||
.iter()
|
||||
.find(|snapshot| snapshot.marker == worktree_root.join(".git"))
|
||||
.cloned();
|
||||
authority.route_boundaries =
|
||||
repository_route_boundaries(&authority.worktree_roots, &authority.common_dirs)
|
||||
.map_err(|error| invalid_metadata(&worktree_root, error))?;
|
||||
authority.validate_registered_worktree_routes()?;
|
||||
authority.validate_metadata_routes()?;
|
||||
Ok(authority)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_primary_authority(&self) -> Result<(), GitReadError> {
|
||||
if let Some(common_dir) = &self.unproven_primary_common_dir {
|
||||
return Err(GitReadError::UnprovenPrimaryAuthority {
|
||||
common_dir: common_dir.display().to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn path_is_untrusted_for_executable(&self, path: &Path) -> bool {
|
||||
self.path_is_untrusted_for_executable_result(path)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub(crate) fn revalidate_active_repository_metadata(&self) -> io::Result<()> {
|
||||
let Some(expected) = &self.active_metadata else {
|
||||
return Ok(());
|
||||
};
|
||||
let actual = resolve_repository_metadata(&expected.marker).map_err(|error| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"repository metadata changed during Git operation at {}: {error}",
|
||||
expected.marker.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
if &actual != expected {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"repository metadata changed during Git operation at {}",
|
||||
expected.marker.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
self.validate_snapshot_routes(&actual).map_err(|error| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"repository metadata changed during Git operation at {}: {error}",
|
||||
expected.marker.display()
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_test_locations(
|
||||
roots: Vec<PathBuf>,
|
||||
worktree_roots: Vec<PathBuf>,
|
||||
common_dirs: Vec<PathBuf>,
|
||||
) -> Result<Self, GitReadError> {
|
||||
let route_boundaries = repository_route_boundaries(&worktree_roots, &common_dirs)
|
||||
.map_err(|error| invalid_metadata(Path::new("<test>"), error))?;
|
||||
Ok(Self {
|
||||
active_worktree_root: worktree_roots
|
||||
.first()
|
||||
.or_else(|| roots.first())
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
roots,
|
||||
worktree_roots,
|
||||
common_dirs,
|
||||
unproven_primary_common_dir: None,
|
||||
metadata_snapshots: Vec::new(),
|
||||
registered_worktrees: Vec::new(),
|
||||
active_metadata: None,
|
||||
route_boundaries,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn active_git_dir(&self) -> Option<&Path> {
|
||||
self.active_metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.git_dir.as_path())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn contains_root(&self, root: &Path) -> bool {
|
||||
self.roots
|
||||
.iter()
|
||||
.any(|candidate| paths_equal(candidate, root))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn roots(&self) -> &[PathBuf] {
|
||||
&self.roots
|
||||
}
|
||||
|
||||
fn record_preselection_primary_authorities(&mut self) -> Result<(), GitReadError> {
|
||||
let mut inspected_common_dirs: Vec<PathBuf> = Vec::new();
|
||||
loop {
|
||||
let roots = self.roots.clone();
|
||||
let mut next = None;
|
||||
for root in roots {
|
||||
let Some(common_dir) = linked_common_dir_for_root(&root)
|
||||
.map_err(|error| invalid_metadata(&root.join(".git"), error))?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let already_inspected = inspected_common_dirs.iter().try_fold(
|
||||
false,
|
||||
|found, inspected| -> Result<bool, GitReadError> {
|
||||
Ok(found
|
||||
|| directories_refer_to_same_location(inspected, &common_dir)
|
||||
.map_err(|error| invalid_metadata(&common_dir, error))?)
|
||||
},
|
||||
)?;
|
||||
if !already_inspected {
|
||||
next = Some((root, common_dir));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((linked_root, common_dir)) = next else {
|
||||
return Ok(());
|
||||
};
|
||||
inspected_common_dirs.push(common_dir.clone());
|
||||
if primary_authority_is_proven(&linked_root, &common_dir, &self.roots)
|
||||
.map_err(|error| invalid_metadata(&common_dir, error))?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if common_dir_is_within_untrusted_root(&common_dir, &self.roots) {
|
||||
self.unproven_primary_common_dir.get_or_insert(common_dir);
|
||||
continue;
|
||||
}
|
||||
match inspect_plain_common_config_authority(&common_dir)
|
||||
.map_err(|error| invalid_metadata(&common_dir, error))?
|
||||
{
|
||||
CommonConfigAuthority::Bare => {}
|
||||
CommonConfigAuthority::Worktree(root) => {
|
||||
self.record_repository_ancestry(&root)?;
|
||||
}
|
||||
CommonConfigAuthority::Unproven => {
|
||||
self.unproven_primary_common_dir.get_or_insert(common_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_repository_ancestry(&mut self, start: &Path) -> Result<(), GitReadError> {
|
||||
push_unique(&mut self.roots, start.to_path_buf());
|
||||
push_unique(&mut self.worktree_roots, start.to_path_buf());
|
||||
self.record_repository_marker(start)?;
|
||||
for ancestor in start.parent().into_iter().flat_map(Path::ancestors) {
|
||||
let marker = ancestor.join(".git");
|
||||
match std::fs::symlink_metadata(&marker) {
|
||||
Ok(_) => {
|
||||
push_unique(&mut self.roots, ancestor.to_path_buf());
|
||||
push_unique(&mut self.worktree_roots, ancestor.to_path_buf());
|
||||
let canonical_root = std::fs::canonicalize(ancestor)
|
||||
.map_err(|error| invalid_metadata(&marker, error))?;
|
||||
push_unique(&mut self.roots, canonical_root.clone());
|
||||
push_unique(&mut self.worktree_roots, canonical_root);
|
||||
self.record_repository_marker(ancestor)?;
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(invalid_metadata(&marker, error)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_repository_marker(&mut self, worktree_root: &Path) -> Result<(), GitReadError> {
|
||||
let marker = worktree_root.join(".git");
|
||||
let snapshot = match std::fs::symlink_metadata(&marker) {
|
||||
Ok(_) => resolve_repository_metadata(&marker)
|
||||
.map_err(|error| invalid_metadata(&marker, error))?,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(invalid_metadata(&marker, error)),
|
||||
};
|
||||
let common_dir = snapshot.common_dir.clone();
|
||||
if !self
|
||||
.metadata_snapshots
|
||||
.iter()
|
||||
.any(|known| known.marker == snapshot.marker)
|
||||
{
|
||||
self.metadata_snapshots.push(snapshot);
|
||||
}
|
||||
if !path_is_within(&common_dir, worktree_root) {
|
||||
push_unique(&mut self.roots, common_dir.clone());
|
||||
}
|
||||
push_unique(&mut self.common_dirs, common_dir.clone());
|
||||
self.record_registered_worktrees(&common_dir)?;
|
||||
self.record_common_dir_ancestry(common_dir)
|
||||
}
|
||||
|
||||
fn record_registered_worktrees(&mut self, common_dir: &Path) -> Result<(), GitReadError> {
|
||||
let registrations = registered_worktrees(common_dir)
|
||||
.map_err(|error| invalid_metadata(&error.path, error.source))?;
|
||||
for registration in registrations {
|
||||
if !self
|
||||
.registered_worktrees
|
||||
.iter()
|
||||
.any(|known| known.admin_dir == registration.admin_dir)
|
||||
{
|
||||
self.registered_worktrees.push(registration.clone());
|
||||
}
|
||||
let already_known = self
|
||||
.roots
|
||||
.iter()
|
||||
.any(|known| paths_equal(known, ®istration.root));
|
||||
push_unique(&mut self.roots, registration.root.clone());
|
||||
push_unique(&mut self.worktree_roots, registration.root.clone());
|
||||
if let Ok(canonical_root) = std::fs::canonicalize(®istration.root) {
|
||||
push_unique(&mut self.roots, canonical_root.clone());
|
||||
push_unique(&mut self.worktree_roots, canonical_root);
|
||||
}
|
||||
self.validate_registered_worktree_backlink(®istration)?;
|
||||
if !already_known {
|
||||
self.record_repository_ancestry(®istration.root)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_registered_worktree_backlink(
|
||||
&self,
|
||||
registration: &RegisteredWorktree,
|
||||
) -> Result<(), GitReadError> {
|
||||
match std::fs::metadata(®istration.root) {
|
||||
Ok(metadata) if metadata.is_dir() => {}
|
||||
Ok(_) => {
|
||||
return Err(GitReadError::InvalidRepositoryMetadata {
|
||||
path: registration.root.clone(),
|
||||
reason: "registered Git worktree is not a directory".to_string(),
|
||||
});
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => return Err(invalid_metadata(®istration.root, error)),
|
||||
}
|
||||
let marker = registration.root.join(".git");
|
||||
match std::fs::symlink_metadata(&marker) {
|
||||
Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {}
|
||||
Ok(_) => {
|
||||
return Err(GitReadError::InvalidRepositoryMetadata {
|
||||
path: marker,
|
||||
reason: "unsupported registered Git worktree marker".to_string(),
|
||||
});
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => return Err(invalid_metadata(&marker, error)),
|
||||
}
|
||||
let resolved = resolve_repository_metadata(&marker)
|
||||
.map_err(|error| invalid_metadata(&marker, error))?;
|
||||
if !directories_refer_to_same_location(&resolved.git_dir, ®istration.admin_dir)
|
||||
.map_err(|error| invalid_metadata(&marker, error))?
|
||||
{
|
||||
return Err(GitReadError::UnsafeRepositoryMetadata {
|
||||
path: marker,
|
||||
reason: "Git worktree registry backlink mismatch".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_common_dir_ancestry(&mut self, common_dir: PathBuf) -> Result<(), GitReadError> {
|
||||
for ancestor in common_dir.parent().into_iter().flat_map(Path::ancestors) {
|
||||
let marker = ancestor.join(".git");
|
||||
match std::fs::symlink_metadata(&marker) {
|
||||
Ok(_) => {
|
||||
let canonical_root = std::fs::canonicalize(ancestor)
|
||||
.map_err(|error| invalid_metadata(&marker, error))?;
|
||||
let already_known = self.roots.iter().any(|root| {
|
||||
paths_equal(root, ancestor) || paths_equal(root, &canonical_root)
|
||||
});
|
||||
push_unique(&mut self.roots, ancestor.to_path_buf());
|
||||
push_unique(&mut self.worktree_roots, ancestor.to_path_buf());
|
||||
push_unique(&mut self.roots, canonical_root.clone());
|
||||
push_unique(&mut self.worktree_roots, canonical_root);
|
||||
if !already_known {
|
||||
self.record_repository_marker(ancestor)?;
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(invalid_metadata(&marker, error)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::errors::GitReadError;
|
||||
use crate::git_config::path_is_within;
|
||||
|
||||
use super::super::RepositoryMetadataRouteKind;
|
||||
use super::super::ResolvedRepositoryMetadata;
|
||||
use super::super::directories_refer_to_same_location;
|
||||
use super::super::helpers::canonical_existing_ancestors;
|
||||
use super::super::helpers::invalid_metadata;
|
||||
use super::super::helpers::paths_equal;
|
||||
use super::super::repository_common_dir_for_candidate_root;
|
||||
use super::RepositoryAuthority;
|
||||
|
||||
impl RepositoryAuthority {
|
||||
pub(crate) fn canonical_command_cwd(&self, cwd: &Path) -> io::Result<PathBuf> {
|
||||
let canonical = std::fs::canonicalize(cwd)?;
|
||||
let inspection = self.route_boundaries.inspect_route(cwd)?;
|
||||
if (inspection.touches_worktree || inspection.crosses_worktree)
|
||||
&& !path_is_within(&canonical, &self.active_worktree_root)
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"refusing Git command cwd that no longer resolves within the selected worktree: {}",
|
||||
cwd.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
pub(super) fn path_is_untrusted_for_executable_result(&self, path: &Path) -> io::Result<bool> {
|
||||
let inspection = self.route_boundaries.inspect_route(path)?;
|
||||
if inspection.touches_worktree || inspection.crosses_metadata {
|
||||
return Ok(true);
|
||||
}
|
||||
for candidate in inspection.observed_paths {
|
||||
if self.route_boundaries.contains_known_boundary(&candidate)? {
|
||||
continue;
|
||||
}
|
||||
if self.has_related_repository_ancestor(&candidate)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn has_related_repository_ancestor(&self, path: &Path) -> io::Result<bool> {
|
||||
let mut ancestors = path.ancestors().map(Path::to_path_buf).collect::<Vec<_>>();
|
||||
ancestors.extend(canonical_existing_ancestors(path)?);
|
||||
let mut seen = BTreeSet::new();
|
||||
for ancestor in ancestors {
|
||||
if !seen.insert(ancestor.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Some(common_dir) = repository_common_dir_for_candidate_root(&ancestor)? else {
|
||||
continue;
|
||||
};
|
||||
for protected in &self.common_dirs {
|
||||
if directories_refer_to_same_location(protected, &common_dir)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(super) fn validate_metadata_routes(&self) -> Result<(), GitReadError> {
|
||||
for snapshot in &self.metadata_snapshots {
|
||||
self.validate_snapshot_routes(snapshot)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_registered_worktree_routes(&self) -> Result<(), GitReadError> {
|
||||
for registration in &self.registered_worktrees {
|
||||
if self
|
||||
.route_boundaries
|
||||
.retained_route_is_untrusted(®istration.marker_route)
|
||||
.map_err(|error| invalid_metadata(®istration.admin_dir.join("gitdir"), error))?
|
||||
{
|
||||
return Err(GitReadError::UnsafeRepositoryMetadata {
|
||||
path: registration.admin_dir.join("gitdir"),
|
||||
reason: "Git worktree registry route crosses a repository worktree".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_snapshot_routes(
|
||||
&self,
|
||||
snapshot: &ResolvedRepositoryMetadata,
|
||||
) -> Result<(), GitReadError> {
|
||||
for route in &snapshot.routes {
|
||||
if route.kind == RepositoryMetadataRouteKind::StandardDirectory {
|
||||
let root = route.spelling.parent().ok_or_else(|| {
|
||||
GitReadError::InvalidRepositoryMetadata {
|
||||
path: route.spelling.clone(),
|
||||
reason: "Git metadata directory has no worktree parent".to_string(),
|
||||
}
|
||||
})?;
|
||||
let expected = std::fs::canonicalize(root)
|
||||
.map_err(|error| invalid_metadata(root, error))?
|
||||
.join(".git");
|
||||
if !paths_equal(&route.target, &expected) {
|
||||
return Err(GitReadError::UnsafeRepositoryMetadata {
|
||||
path: snapshot.marker.clone(),
|
||||
reason: "nonstandard Git metadata directory".to_string(),
|
||||
});
|
||||
}
|
||||
if self
|
||||
.route_boundaries
|
||||
.route_cancels_worktree_descendant(&route.spelling)
|
||||
.map_err(|error| invalid_metadata(&route.spelling, error))?
|
||||
{
|
||||
return Err(GitReadError::UnsafeRepositoryMetadata {
|
||||
path: snapshot.marker.clone(),
|
||||
reason: "Git metadata route crosses a repository worktree".to_string(),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let spelling_crosses = self
|
||||
.route_boundaries
|
||||
.inspect_route(&route.spelling)
|
||||
.map_err(|error| invalid_metadata(&route.spelling, error))?
|
||||
.crosses_worktree;
|
||||
let target_crosses = self
|
||||
.route_boundaries
|
||||
.inspect_route(&route.target)
|
||||
.map_err(|error| invalid_metadata(&route.target, error))?
|
||||
.crosses_worktree;
|
||||
if spelling_crosses || target_crosses {
|
||||
return Err(GitReadError::UnsafeRepositoryMetadata {
|
||||
path: snapshot.marker.clone(),
|
||||
reason: "Git metadata route crosses a repository worktree".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::errors::GitReadError;
|
||||
use crate::git_config::path_is_within;
|
||||
|
||||
use super::directories_refer_to_same_location;
|
||||
use super::path_has_untrusted_root_identity_ancestor;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct RegisteredWorktree {
|
||||
pub(super) root: PathBuf,
|
||||
pub(super) admin_dir: PathBuf,
|
||||
pub(super) marker_route: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct RegisteredWorktreeReadError {
|
||||
pub(super) path: PathBuf,
|
||||
pub(super) source: io::Error,
|
||||
}
|
||||
|
||||
pub(super) fn registry_error(path: &Path, source: io::Error) -> RegisteredWorktreeReadError {
|
||||
RegisteredWorktreeReadError {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn common_dir_is_within_untrusted_root(common_dir: &Path, roots: &[PathBuf]) -> bool {
|
||||
for root in roots {
|
||||
if directories_refer_to_same_location(common_dir, root).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if path_is_within(common_dir, root)
|
||||
|| path_has_untrusted_root_identity_ancestor(common_dir, std::slice::from_ref(root))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn canonical_existing_ancestors(path: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
let mut canonical = Vec::new();
|
||||
for ancestor in path.ancestors() {
|
||||
match std::fs::canonicalize(ancestor) {
|
||||
Ok(path) => canonical.push(path),
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
pub(super) fn validated_logical_process_cwd(canonical_cwd: &Path) -> Option<PathBuf> {
|
||||
let process_cwd = std::fs::canonicalize(std::env::current_dir().ok()?).ok()?;
|
||||
if !paths_equal(&process_cwd, canonical_cwd) {
|
||||
return None;
|
||||
}
|
||||
let logical_cwd = PathBuf::from(std::env::var_os("PWD")?);
|
||||
if !logical_cwd.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let canonical_logical_cwd = std::fs::canonicalize(&logical_cwd).ok()?;
|
||||
paths_equal(&canonical_logical_cwd, canonical_cwd).then_some(logical_cwd)
|
||||
}
|
||||
|
||||
pub(super) fn push_unique(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
||||
if !paths.iter().any(|existing| paths_equal(existing, &path)) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn paths_equal(left: &Path, right: &Path) -> bool {
|
||||
path_is_within(left, right) && path_is_within(right, left)
|
||||
}
|
||||
|
||||
pub(super) fn invalid_metadata(path: &Path, error: io::Error) -> GitReadError {
|
||||
GitReadError::InvalidRepositoryMetadata {
|
||||
path: path.to_path_buf(),
|
||||
reason: error.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::bytes_to_path;
|
||||
use super::read_bounded_file;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(crate) enum CommonConfigAuthority {
|
||||
Bare,
|
||||
Worktree(PathBuf),
|
||||
Unproven,
|
||||
}
|
||||
|
||||
pub(crate) fn inspect_plain_common_config_authority(
|
||||
common_dir: &Path,
|
||||
) -> io::Result<CommonConfigAuthority> {
|
||||
let config_path = common_dir.join("config");
|
||||
let metadata = match std::fs::symlink_metadata(&config_path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
return Ok(CommonConfigAuthority::Unproven);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Ok(CommonConfigAuthority::Unproven);
|
||||
}
|
||||
let bytes = read_bounded_file(
|
||||
&config_path,
|
||||
1024 * 1024,
|
||||
"Git common config is too large for authority proof",
|
||||
)?;
|
||||
let config = match gix::config::File::from_bytes_no_includes(
|
||||
&bytes,
|
||||
gix::config::file::Metadata::default(),
|
||||
gix::config::file::init::Options::default(),
|
||||
) {
|
||||
Ok(config) => config,
|
||||
Err(_) => return Ok(CommonConfigAuthority::Unproven),
|
||||
};
|
||||
if config.sections_by_name("include").is_some()
|
||||
|| config.sections_by_name("includeIf").is_some()
|
||||
{
|
||||
return Ok(CommonConfigAuthority::Unproven);
|
||||
}
|
||||
let bare = match unique_explicit_value(&config, "core", "bare") {
|
||||
Ok(Some(value)) => match explicit_boolean(value.as_ref()) {
|
||||
Some(value) => Some(value),
|
||||
None => return Ok(CommonConfigAuthority::Unproven),
|
||||
},
|
||||
Ok(None) => None,
|
||||
Err(()) => return Ok(CommonConfigAuthority::Unproven),
|
||||
};
|
||||
match unique_explicit_value(&config, "extensions", "worktreeConfig") {
|
||||
Ok(Some(value)) if explicit_boolean(value.as_ref()) == Some(false) => {}
|
||||
Ok(None) => {}
|
||||
Ok(Some(_)) | Err(()) => return Ok(CommonConfigAuthority::Unproven),
|
||||
}
|
||||
let worktree = match unique_explicit_value(&config, "core", "worktree") {
|
||||
Ok(Some(value)) => Some(value),
|
||||
Ok(None) => None,
|
||||
Err(()) => return Ok(CommonConfigAuthority::Unproven),
|
||||
};
|
||||
if let Some(worktree) = worktree {
|
||||
if bare == Some(true) {
|
||||
return Ok(CommonConfigAuthority::Unproven);
|
||||
}
|
||||
let worktree = bytes_to_path(&worktree)?;
|
||||
if worktree.as_os_str().is_empty() || !worktree.is_absolute() {
|
||||
return Ok(CommonConfigAuthority::Unproven);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if worktree
|
||||
.to_str()
|
||||
.is_none_or(crate::path_authority::windows_authority_path_is_ambiguous)
|
||||
{
|
||||
return Ok(CommonConfigAuthority::Unproven);
|
||||
}
|
||||
return Ok(CommonConfigAuthority::Worktree(worktree));
|
||||
}
|
||||
Ok(if bare == Some(true) {
|
||||
CommonConfigAuthority::Bare
|
||||
} else {
|
||||
CommonConfigAuthority::Unproven
|
||||
})
|
||||
}
|
||||
|
||||
fn unique_explicit_value(
|
||||
config: &gix::config::File<'_>,
|
||||
section_name: &str,
|
||||
value_name: &str,
|
||||
) -> Result<Option<Vec<u8>>, ()> {
|
||||
let Some(sections) = config.sections_by_name(section_name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut occurrences = 0usize;
|
||||
let mut explicit_values = Vec::new();
|
||||
for section in sections {
|
||||
if section.header().subsection_name().is_some() {
|
||||
return Err(());
|
||||
}
|
||||
occurrences += section
|
||||
.value_names()
|
||||
.filter(|name| {
|
||||
let name: &str = name.as_ref();
|
||||
name.eq_ignore_ascii_case(value_name)
|
||||
})
|
||||
.count();
|
||||
explicit_values.extend(
|
||||
section
|
||||
.values(value_name)
|
||||
.into_iter()
|
||||
.map(|value| value.as_ref().to_vec()),
|
||||
);
|
||||
}
|
||||
match (occurrences, explicit_values.len()) {
|
||||
(0, 0) => Ok(None),
|
||||
(1, 1) => Ok(explicit_values.pop()),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_boolean(value: &[u8]) -> Option<bool> {
|
||||
if value.eq_ignore_ascii_case(b"true") {
|
||||
Some(true)
|
||||
} else if value.eq_ignore_ascii_case(b"false") {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unreadable_metadata_is_invalid_not_a_proven_policy_crossing() {
|
||||
let marker = PathBuf::from("repository/.git/commondir");
|
||||
let error = super::helpers::invalid_metadata(
|
||||
&marker,
|
||||
io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"metadata marker is unreadable",
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
error,
|
||||
crate::errors::GitReadError::InvalidRepositoryMetadata {
|
||||
path: marker,
|
||||
reason: "metadata marker is unreadable".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(error.io_kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
fn write_common_config(body: &[u8]) -> tempfile::TempDir {
|
||||
let common = tempfile::tempdir().expect("common dir");
|
||||
std::fs::write(common.path().join("config"), body).expect("write common config");
|
||||
common
|
||||
}
|
||||
|
||||
fn native_bare_value(config: &Path, includes: bool) -> io::Result<Option<bool>> {
|
||||
let mut command = std::process::Command::new("git");
|
||||
crate::safe_git::isolate_git_command_environment(&mut command);
|
||||
command
|
||||
.args(["config", "--file"])
|
||||
.arg(config)
|
||||
.arg(if includes {
|
||||
"--includes"
|
||||
} else {
|
||||
"--no-includes"
|
||||
})
|
||||
.args(["--type=bool", "--get", "core.bare"]);
|
||||
let output = command.output()?;
|
||||
match output.status.code() {
|
||||
Some(0) => Ok(Some(
|
||||
String::from_utf8_lossy(&output.stdout).trim() == "true",
|
||||
)),
|
||||
Some(1) if output.stdout.is_empty() => Ok(None),
|
||||
_ => Err(io::Error::other(format!(
|
||||
"native core.bare query failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_common_bare_parser_matches_native_boolean_syntax_and_is_stricter_on_duplicates() {
|
||||
for (name, body, expected, native) in [
|
||||
(
|
||||
"explicit true",
|
||||
b"[core]\n\tbare = true\n".as_slice(),
|
||||
CommonConfigAuthority::Bare,
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"implicit true",
|
||||
b"[core]\n\tbare\n".as_slice(),
|
||||
CommonConfigAuthority::Unproven,
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"quoted true",
|
||||
b"[core]\n\tbare = \"true\"\n".as_slice(),
|
||||
CommonConfigAuthority::Bare,
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"false",
|
||||
b"[core]\n\tbare = false\n".as_slice(),
|
||||
CommonConfigAuthority::Unproven,
|
||||
Some(false),
|
||||
),
|
||||
(
|
||||
"duplicate last true",
|
||||
b"[core]\n\tbare = false\n\tbare = true\n".as_slice(),
|
||||
CommonConfigAuthority::Unproven,
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"duplicate last false",
|
||||
b"[core]\n\tbare = true\n\tbare = false\n".as_slice(),
|
||||
CommonConfigAuthority::Unproven,
|
||||
Some(false),
|
||||
),
|
||||
(
|
||||
"mixed implicit then explicit",
|
||||
b"[core]\n\tbare\n\tbare = true\n".as_slice(),
|
||||
CommonConfigAuthority::Unproven,
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"mixed explicit then implicit",
|
||||
b"[core]\n\tbare = false\n\tbare\n".as_slice(),
|
||||
CommonConfigAuthority::Unproven,
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"mixed case duplicate",
|
||||
b"[core]\n\tbare = false\n\tBaRe = true\n".as_slice(),
|
||||
CommonConfigAuthority::Unproven,
|
||||
Some(true),
|
||||
),
|
||||
] {
|
||||
let common = write_common_config(body);
|
||||
assert_eq!(
|
||||
inspect_plain_common_config_authority(common.path()).expect(name),
|
||||
expected,
|
||||
"authority result for {name}"
|
||||
);
|
||||
assert_eq!(
|
||||
native_bare_value(&common.path().join("config"), false).expect(name),
|
||||
native,
|
||||
"native result for {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_bare_proof_accepts_only_explicit_boolean_literals() {
|
||||
for (name, body) in [
|
||||
("empty", b"[core]\n\tbare =\n".as_slice()),
|
||||
("yes", b"[core]\n\tbare = yes\n".as_slice()),
|
||||
("on", b"[core]\n\tbare = on\n".as_slice()),
|
||||
("one", b"[core]\n\tbare = 1\n".as_slice()),
|
||||
("leading zero", b"[core]\n\tbare = 08\n".as_slice()),
|
||||
(
|
||||
"positive overflow",
|
||||
b"[core]\n\tbare = 2147483648\n".as_slice(),
|
||||
),
|
||||
(
|
||||
"negative overflow",
|
||||
b"[core]\n\tbare = -2147483649\n".as_slice(),
|
||||
),
|
||||
(
|
||||
"i64 max",
|
||||
b"[core]\n\tbare = 9223372036854775807\n".as_slice(),
|
||||
),
|
||||
] {
|
||||
let common = write_common_config(body);
|
||||
assert_eq!(
|
||||
inspect_plain_common_config_authority(common.path()).expect(name),
|
||||
CommonConfigAuthority::Unproven,
|
||||
"authority result for {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_bare_proof_rejects_includes_worktree_config_and_relative_worktree() {
|
||||
let include_target = tempfile::NamedTempFile::new().expect("included config");
|
||||
std::fs::write(include_target.path(), "[core]\n\tbare = false\n")
|
||||
.expect("write included config");
|
||||
let common = write_common_config(
|
||||
format!(
|
||||
"[core]\n\tbare = true\n[include]\n\tpath = {}\n",
|
||||
include_target.path().display()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
native_bare_value(&common.path().join("config"), false).expect("direct native bare"),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
native_bare_value(&common.path().join("config"), true).expect("included native bare"),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
inspect_plain_common_config_authority(common.path()).expect("include authority"),
|
||||
CommonConfigAuthority::Unproven
|
||||
);
|
||||
|
||||
for body in [
|
||||
b"[core]\n\tbare = true\n[includeIf \"gitdir:**\"]\n\tpath = /tmp/ignored\n".as_slice(),
|
||||
b"[core]\n\tbare = true\n[extensions]\n\tworktreeConfig = true\n".as_slice(),
|
||||
b"[core]\n\tbare = true\n\tworktree = relative/path\n".as_slice(),
|
||||
b"[core]\n\tbare = true\n[extensions]\n\tworktreeConfig\n".as_slice(),
|
||||
b"[core]\n\tbare = true\n[extensions]\n\tworktreeConfig = false\n\tworktreeConfig\n"
|
||||
.as_slice(),
|
||||
b"[core]\n\tbare = true\n\tworktree\n".as_slice(),
|
||||
b"[core]\n\tbare = true\n\tworktree = /tmp/one\n\tworktree\n".as_slice(),
|
||||
b"[core \"unexpected\"]\n\tbare = true\n".as_slice(),
|
||||
b"[extensions \"unexpected\"]\n\tworktreeConfig = false\n[core]\n\tbare = true\n"
|
||||
.as_slice(),
|
||||
] {
|
||||
let common = write_common_config(body);
|
||||
assert_eq!(
|
||||
inspect_plain_common_config_authority(common.path()).expect("ambiguous authority"),
|
||||
CommonConfigAuthority::Unproven
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_config_absolute_worktree_is_returned_as_authority() {
|
||||
let worktree = tempfile::tempdir().expect("worktree");
|
||||
let common = write_common_config(
|
||||
format!("[core]\n\tworktree = {}\n", worktree.path().display()).as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
inspect_plain_common_config_authority(common.path()).expect("worktree authority"),
|
||||
CommonConfigAuthority::Worktree(worktree.path().to_path_buf())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_config_rejects_contradictory_or_malformed_bare_with_absolute_worktree() {
|
||||
let worktree = tempfile::tempdir().expect("worktree");
|
||||
for (name, bare) in [("contradictory bare", "true"), ("malformed bare", "08")] {
|
||||
let common = write_common_config(
|
||||
format!(
|
||||
"[core]\n\tbare = {bare}\n\tworktree = {}\n",
|
||||
worktree.path().display()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
inspect_plain_common_config_authority(common.path()).expect(name),
|
||||
CommonConfigAuthority::Unproven,
|
||||
"authority result for {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_authority_path_grammar_rejects_ambiguous_primary_spellings() {
|
||||
for path in [
|
||||
r"C:\primary.\repo",
|
||||
r"C:\primary \repo",
|
||||
r"C:\primary:stream\repo",
|
||||
r"C:\NUL\repo",
|
||||
r"\\?\GLOBALROOT\Device\HarddiskVolume1\repo",
|
||||
r"\??\C:\primary\repo",
|
||||
r"C:\outside\..\primary",
|
||||
] {
|
||||
assert!(
|
||||
crate::path_authority::windows_authority_path_is_ambiguous(path),
|
||||
"ambiguous Windows authority path accepted: {path:?}"
|
||||
);
|
||||
}
|
||||
for path in [r"C:\primary\repo", r"\\server\share\primary\repo"] {
|
||||
assert!(
|
||||
!crate::path_authority::windows_authority_path_is_ambiguous(path),
|
||||
"ordinary Windows authority path rejected: {path:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user