mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
fix(git): authorize included config sources
This commit is contained in:
@@ -13,7 +13,7 @@ use std::path::PathBuf;
|
||||
use crate::FsmonitorOverride;
|
||||
use crate::apply_output::parse_git_apply_output;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_config_sources::ensure_no_worktree_primary_config_sources;
|
||||
use crate::git_config_sources::ensure_no_worktree_config_sources;
|
||||
use crate::patch_paths::extract_effective_paths_from_patch;
|
||||
use crate::patch_paths::stage_effective_paths;
|
||||
use crate::safe_git::DISABLED_HOOKS_PATH;
|
||||
@@ -48,11 +48,11 @@ pub struct ApplyGitResult {
|
||||
pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||||
let git = GitRunner::for_cwd_io(&req.cwd)?;
|
||||
let mut cfg_parts = configured_git_config_parts();
|
||||
ensure_no_worktree_primary_config_sources(&git, &req.cwd)?;
|
||||
ensure_no_worktree_config_sources(&git, &req.cwd, &cfg_parts)?;
|
||||
let requested_cwd = std::fs::canonicalize(&req.cwd)?;
|
||||
let git_root = resolve_git_root(&git, &req.cwd, &cfg_parts)?;
|
||||
if git_root != requested_cwd {
|
||||
ensure_no_worktree_primary_config_sources(&git, &git_root)?;
|
||||
ensure_no_worktree_config_sources(&git, &git_root, &cfg_parts)?;
|
||||
}
|
||||
|
||||
// Write unified diff into a temporary file
|
||||
|
||||
@@ -1514,6 +1514,46 @@ fn active_commondir_route_is_revalidated_before_every_child() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn active_worktree_identity_is_revalidated_before_every_child() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let root = fixture.path().join("repo");
|
||||
let moved_root = fixture.path().join("repo-moved");
|
||||
std::fs::create_dir_all(&root).expect("create repository");
|
||||
run_git(&root, &["init", "-q"]);
|
||||
let runner = GitRunner::for_cwd(&root).expect("runner for original repository");
|
||||
let sentinel = fixture.path().join("git-child-ran");
|
||||
let mut command = runner
|
||||
.command_for_cwd(&root)
|
||||
.expect("command for original repository");
|
||||
command
|
||||
.args(["config", "--file"])
|
||||
.arg(&sentinel)
|
||||
.args(["probe.value", "ran"]);
|
||||
|
||||
std::fs::rename(&root, &moved_root).expect("move original repository");
|
||||
std::fs::create_dir_all(&root).expect("create replacement repository");
|
||||
run_git(&root, &["init", "-q"]);
|
||||
assert!(moved_root.join(".git").is_dir());
|
||||
assert!(root.join(".git").is_dir());
|
||||
|
||||
let error = runner
|
||||
.output(command)
|
||||
.expect_err("same-path repository replacement must block Git child");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("guarded Git repository identity changed before Git launch"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(
|
||||
!sentinel.exists(),
|
||||
"Git child executed after same-path repository replacement"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn direct_external_separate_git_dir_absolute_and_relative_routes_execute_git() {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::io;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::git_command::GitRunner;
|
||||
|
||||
pub(crate) fn parse_git_boolean(value: &[u8]) -> Option<bool> {
|
||||
parse_git_boolean_with_minimum(value, i128::from(i32::MIN))
|
||||
}
|
||||
@@ -79,6 +82,193 @@ fn parse_git_boolean_with_minimum(value: &[u8], minimum: i128) -> Option<bool> {
|
||||
.then_some(value != 0)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum GitConfigScope {
|
||||
Unknown,
|
||||
System,
|
||||
Global,
|
||||
Local,
|
||||
Worktree,
|
||||
Command,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct GitConfigEntry {
|
||||
pub(crate) scope: GitConfigScope,
|
||||
pub(crate) origin: GitConfigOrigin,
|
||||
pub(crate) key: String,
|
||||
pub(crate) value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum GitConfigOrigin {
|
||||
CommandLine,
|
||||
File(std::path::PathBuf),
|
||||
}
|
||||
|
||||
pub(crate) fn parse_config_entries(output: &[u8]) -> io::Result<Vec<GitConfigEntry>> {
|
||||
if output.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(body) = output.strip_suffix(&[0]) else {
|
||||
return Err(invalid_config_output("unterminated Git config output"));
|
||||
};
|
||||
let fields = body.split(|byte| *byte == 0).collect::<Vec<_>>();
|
||||
if fields.len() % 3 != 0 {
|
||||
return Err(invalid_config_output("incomplete Git config record"));
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for record in fields.chunks_exact(3) {
|
||||
let scope = parse_scope(record[0])?;
|
||||
let origin = parse_config_origin(record[1])?;
|
||||
let entry = parse_utf8_field(record[2], "config key/value")?;
|
||||
let Some((key, value)) = entry.split_once('\n') else {
|
||||
return Err(invalid_config_output(
|
||||
"Git config record has no key/value separator",
|
||||
));
|
||||
};
|
||||
if key.is_empty() {
|
||||
return Err(invalid_config_output("empty Git config key"));
|
||||
}
|
||||
let entry = GitConfigEntry {
|
||||
scope,
|
||||
origin,
|
||||
key: key.to_string(),
|
||||
value: value.to_string(),
|
||||
};
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Parse the `--show-origin` form used by Git versions that predate
|
||||
/// `config --show-scope`. The record order still reflects effective config
|
||||
/// precedence, so duplicate include directives remain ordered. Scope is
|
||||
/// retained as best-effort metadata only; command-line entries remain
|
||||
/// distinguishable.
|
||||
pub(crate) fn parse_config_entries_with_origins(output: &[u8]) -> io::Result<Vec<GitConfigEntry>> {
|
||||
if output.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(body) = output.strip_suffix(&[0]) else {
|
||||
return Err(invalid_config_output("unterminated Git config output"));
|
||||
};
|
||||
let fields = body.split(|byte| *byte == 0).collect::<Vec<_>>();
|
||||
if fields.len() % 2 != 0 {
|
||||
return Err(invalid_config_output("incomplete Git config record"));
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for record in fields.chunks_exact(2) {
|
||||
let origin = parse_config_origin(record[0])?;
|
||||
let entry = parse_utf8_field(record[1], "config key/value")?;
|
||||
let Some((key, value)) = entry.split_once('\n') else {
|
||||
return Err(invalid_config_output(
|
||||
"Git config record has no key/value separator",
|
||||
));
|
||||
};
|
||||
if key.is_empty() {
|
||||
return Err(invalid_config_output("empty Git config key"));
|
||||
}
|
||||
let scope = if origin == GitConfigOrigin::CommandLine {
|
||||
GitConfigScope::Command
|
||||
} else {
|
||||
// Older Git does not expose the scope. Selection depends on the
|
||||
// emitted precedence order, not on this informational label.
|
||||
GitConfigScope::Local
|
||||
};
|
||||
entries.push(GitConfigEntry {
|
||||
scope,
|
||||
origin,
|
||||
key: key.to_string(),
|
||||
value: value.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub(crate) fn read_config_entries_without_includes(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
git_config_args: &[String],
|
||||
pattern: &str,
|
||||
probe: &str,
|
||||
config_file: Option<&Path>,
|
||||
) -> io::Result<Vec<GitConfigEntry>> {
|
||||
read_config_entries_with_fallback(git, cwd, git_config_args, pattern, probe, config_file)
|
||||
}
|
||||
|
||||
fn read_config_entries_with_fallback(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
git_config_args: &[String],
|
||||
pattern: &str,
|
||||
probe: &str,
|
||||
config_file: Option<&Path>,
|
||||
) -> io::Result<Vec<GitConfigEntry>> {
|
||||
let scoped = run_effective_config_query(
|
||||
git,
|
||||
cwd,
|
||||
git_config_args,
|
||||
pattern,
|
||||
/*show_scope*/ true,
|
||||
config_file,
|
||||
)?;
|
||||
if scoped
|
||||
.status
|
||||
.code()
|
||||
.is_some_and(|code| code == 0 || code == 1)
|
||||
{
|
||||
return parse_config_entries(&scoped.stdout);
|
||||
}
|
||||
|
||||
let legacy = run_effective_config_query(
|
||||
git,
|
||||
cwd,
|
||||
git_config_args,
|
||||
pattern,
|
||||
/*show_scope*/ false,
|
||||
config_file,
|
||||
)?;
|
||||
if !legacy
|
||||
.status
|
||||
.code()
|
||||
.is_some_and(|code| code == 0 || code == 1)
|
||||
{
|
||||
return Err(io::Error::other(format!(
|
||||
"git {probe} config probe failed with status {}: {}",
|
||||
legacy.status,
|
||||
String::from_utf8_lossy(&legacy.stderr).trim()
|
||||
)));
|
||||
}
|
||||
parse_config_entries_with_origins(&legacy.stdout)
|
||||
}
|
||||
|
||||
fn run_effective_config_query(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
git_config_args: &[String],
|
||||
pattern: &str,
|
||||
show_scope: bool,
|
||||
config_file: Option<&Path>,
|
||||
) -> io::Result<std::process::Output> {
|
||||
let mut command = git.command_for_cwd(cwd)?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.arg("config");
|
||||
if let Some(config_file) = config_file {
|
||||
command.arg("--file").arg(config_file);
|
||||
}
|
||||
command.arg("--null");
|
||||
if show_scope {
|
||||
command.arg("--show-scope");
|
||||
}
|
||||
command.args(["--show-origin", "--no-includes", "--get-regexp", pattern]);
|
||||
git.output(command)
|
||||
}
|
||||
|
||||
pub(crate) fn path_is_within(path: &Path, root: &Path) -> bool {
|
||||
let mut path_components = path.components();
|
||||
for root_component in root.components() {
|
||||
@@ -104,6 +294,52 @@ fn components_equal(left: Component<'_>, right: Component<'_>) -> bool {
|
||||
left == right
|
||||
}
|
||||
|
||||
fn parse_scope(scope: &[u8]) -> io::Result<GitConfigScope> {
|
||||
match scope {
|
||||
b"unknown" => Ok(GitConfigScope::Unknown),
|
||||
b"system" => Ok(GitConfigScope::System),
|
||||
b"global" => Ok(GitConfigScope::Global),
|
||||
b"local" => Ok(GitConfigScope::Local),
|
||||
b"worktree" => Ok(GitConfigScope::Worktree),
|
||||
b"command" => Ok(GitConfigScope::Command),
|
||||
_ => Err(invalid_config_output("unknown Git config scope")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_config_origin(origin: &[u8]) -> io::Result<GitConfigOrigin> {
|
||||
if origin == b"command line:" {
|
||||
return Ok(GitConfigOrigin::CommandLine);
|
||||
}
|
||||
let path = origin
|
||||
.strip_prefix(b"file:")
|
||||
.ok_or_else(|| invalid_config_output("unsupported Git config origin"))?;
|
||||
if path.is_empty() || path.contains(&0) {
|
||||
return Err(invalid_config_output("empty Git config origin"));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
Ok(GitConfigOrigin::File(std::path::PathBuf::from(
|
||||
std::ffi::OsString::from_vec(path.to_vec()),
|
||||
)))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Ok(GitConfigOrigin::File(std::path::PathBuf::from(
|
||||
parse_utf8_field(path, "Git config origin path")?,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_utf8_field<'a>(field: &'a [u8], name: &str) -> io::Result<&'a str> {
|
||||
std::str::from_utf8(field).map_err(|_| invalid_config_output(&format!("non-UTF-8 {name}")))
|
||||
}
|
||||
|
||||
fn invalid_config_output(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "git_config_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
#[cfg(test)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_config::read_config_entries_without_includes;
|
||||
|
||||
mod include_graph;
|
||||
mod path_safety;
|
||||
mod primary_sources;
|
||||
|
||||
use include_graph::validate_include_entries;
|
||||
use path_safety::normalize_absolute_path;
|
||||
use path_safety::resolve_literal_path;
|
||||
use primary_sources::default_system_config_source_candidates;
|
||||
use primary_sources::is_disabled_primary_config_path;
|
||||
use primary_sources::legacy_primary_config_source_candidates;
|
||||
use primary_sources::selected_git_home_config_candidates;
|
||||
use primary_sources::selected_git_prefix_system_candidate;
|
||||
|
||||
/// Reject primary Git configuration paths selected from worktree-controlled
|
||||
/// routes before any ordinary Git command can open them.
|
||||
///
|
||||
/// This layer intentionally does not authorize includes. The complete include
|
||||
/// graph is added by the next stack layer before this is promoted to a full
|
||||
/// config-source capability.
|
||||
pub(crate) fn ensure_no_worktree_primary_config_sources(
|
||||
const INCLUDE_CONFIG_PATTERN: &str = r"^include(\.path|if\..*\.path)$";
|
||||
const MAX_CONFIG_INCLUDE_DEPTH: usize = 10;
|
||||
const MAX_CONFIG_INCLUDE_FILES: usize = 1024;
|
||||
|
||||
/// Reject configuration that an untrusted worktree writer can change between
|
||||
/// a policy probe and the Git command it guards.
|
||||
pub(crate) fn ensure_no_worktree_config_sources(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<()> {
|
||||
let git_root = normalize_absolute_path(std::fs::canonicalize(git_root)?)?;
|
||||
|
||||
// Environment, HOME, and XDG paths are attacker-controlled byte strings.
|
||||
// Classify their exact OsString spelling before `git var` can open or
|
||||
// newline-delimit one of them.
|
||||
for (description, candidate) in legacy_primary_config_source_candidates(git)? {
|
||||
if !is_disabled_primary_config_path(&candidate) {
|
||||
reject_source(git, &git_root, &candidate, description)?;
|
||||
@@ -41,6 +52,65 @@ pub(crate) fn ensure_no_worktree_primary_config_sources(
|
||||
"selected Git prefix system config",
|
||||
)?;
|
||||
}
|
||||
let entries = read_config_entries_without_includes(
|
||||
git,
|
||||
&git_root,
|
||||
git_config_args,
|
||||
INCLUDE_CONFIG_PATTERN,
|
||||
"include",
|
||||
/*config_file*/ None,
|
||||
)?;
|
||||
let mut pending = Vec::new();
|
||||
validate_include_entries(git, &git_root, entries, /*depth*/ 1, &mut pending)?;
|
||||
let mut visited = BTreeSet::new();
|
||||
while let Some((config_path, depth)) = pending.pop() {
|
||||
if depth > MAX_CONFIG_INCLUDE_DEPTH {
|
||||
return Err(path_safety::invalid_config_source(
|
||||
"Git config include depth exceeded",
|
||||
));
|
||||
}
|
||||
match std::fs::canonicalize(&config_path) {
|
||||
Ok(_) => {}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
// The same file reached through two spellings can resolve a relative
|
||||
// child include differently. Deduplicate only the exact source
|
||||
// spelling, not its canonical target.
|
||||
if !visited.insert(config_path.clone()) {
|
||||
continue;
|
||||
}
|
||||
if visited.len() > MAX_CONFIG_INCLUDE_FILES {
|
||||
return Err(path_safety::invalid_config_source(
|
||||
"too many Git config include files",
|
||||
));
|
||||
}
|
||||
let entries = read_config_entries_without_includes(
|
||||
git,
|
||||
&git_root,
|
||||
&[],
|
||||
INCLUDE_CONFIG_PATTERN,
|
||||
"include",
|
||||
Some(&config_path),
|
||||
)?;
|
||||
validate_include_entries(git, &git_root, entries, depth + 1, &mut pending)?;
|
||||
}
|
||||
// `git var` loads repository config before reporting modern default-system
|
||||
// paths. Delay that supplemental probe until the complete no-includes
|
||||
// source graph above has been classified, so it cannot be tricked into
|
||||
// opening an untrusted include or FIFO first.
|
||||
for (description, candidate) in default_system_config_source_candidates(git, &git_root)? {
|
||||
if !is_disabled_primary_config_path(&candidate) {
|
||||
reject_source(git, &git_root, &candidate, description)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -54,6 +124,17 @@ fn reject_source(
|
||||
git.ensure_config_source_is_not_worktree_controlled(&absolute, description)
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(windows)))]
|
||||
use crate::git_config::GitConfigEntry;
|
||||
#[cfg(all(test, not(windows)))]
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
#[cfg(test)]
|
||||
use include_graph::expand_git_config_path;
|
||||
#[cfg(all(test, not(windows)))]
|
||||
use include_graph::resolve_include_path;
|
||||
#[cfg(test)]
|
||||
use path_safety::windows_config_path_is_ambiguous;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "git_config_sources_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::path_safety::CONFIG_PATH_KEY;
|
||||
use super::path_safety::invalid_config_source;
|
||||
use super::path_safety::reject_raw_ambiguous_windows_config_path;
|
||||
use super::path_safety::resolve_literal_path;
|
||||
use super::reject_source;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_config::GitConfigEntry;
|
||||
use crate::git_config::GitConfigOrigin;
|
||||
|
||||
pub(super) fn validate_include_entries(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
entries: Vec<GitConfigEntry>,
|
||||
depth: usize,
|
||||
pending: &mut Vec<(PathBuf, usize)>,
|
||||
) -> io::Result<()> {
|
||||
for entry in entries {
|
||||
if !is_include_path(&entry.key) {
|
||||
return Err(invalid_config_source("unexpected Git config include key"));
|
||||
}
|
||||
if let Some(origin) = config_file_origin(&entry, git_root)? {
|
||||
reject_source(git, git_root, &origin, "Git config origin")?;
|
||||
}
|
||||
let include = resolve_include_path(git, git_root, &entry)?;
|
||||
reject_source(git, git_root, &include, "Git config include")?;
|
||||
pending.push((include, depth));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_file_origin(entry: &GitConfigEntry, cwd: &Path) -> io::Result<Option<PathBuf>> {
|
||||
match &entry.origin {
|
||||
GitConfigOrigin::CommandLine => Ok(None),
|
||||
GitConfigOrigin::File(path) => Ok(Some(resolve_literal_path(path, cwd))),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_include_path(key: &str) -> bool {
|
||||
key == "include.path" || key.starts_with("includeif.") && key.ends_with(".path")
|
||||
}
|
||||
|
||||
pub(super) fn resolve_include_path(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
entry: &GitConfigEntry,
|
||||
) -> io::Result<PathBuf> {
|
||||
let raw = entry.value.as_str();
|
||||
if raw.is_empty() {
|
||||
return Err(invalid_config_source("empty Git config include path"));
|
||||
}
|
||||
// Unlike generic `git config --path`, include.path treats `:(...)` as
|
||||
// literal path text. Bypass the generic path expander for those spellings
|
||||
// so the validated path exactly matches Git's include loader.
|
||||
let expanded = if raw.starts_with(":(") {
|
||||
PathBuf::from(raw)
|
||||
} else {
|
||||
expand_git_config_path(git, cwd, raw)?
|
||||
};
|
||||
reject_raw_ambiguous_windows_config_path(
|
||||
expanded
|
||||
.to_str()
|
||||
.ok_or_else(|| invalid_config_source("non-UTF-8 Git include path"))?,
|
||||
)?;
|
||||
let base = match config_file_origin(entry, cwd)? {
|
||||
Some(origin) => origin
|
||||
.parent()
|
||||
.ok_or_else(|| invalid_config_source("Git config origin has no parent"))?
|
||||
.to_path_buf(),
|
||||
None if expanded.is_absolute() => cwd.to_path_buf(),
|
||||
None => {
|
||||
return Err(invalid_config_source(
|
||||
"relative Git config include has no file origin",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(resolve_literal_path(expanded, &base))
|
||||
}
|
||||
|
||||
pub(super) fn expand_git_config_path(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
raw: &str,
|
||||
) -> io::Result<PathBuf> {
|
||||
let mut command = git.command_for_cwd(cwd)?;
|
||||
command
|
||||
.arg("-c")
|
||||
.arg(format!("{CONFIG_PATH_KEY}={raw}"))
|
||||
.args([
|
||||
"config",
|
||||
"--null",
|
||||
"--no-includes",
|
||||
"--path",
|
||||
"--get",
|
||||
CONFIG_PATH_KEY,
|
||||
]);
|
||||
let output = git.output(command)?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"git include path expansion failed with status {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let value = output
|
||||
.stdout
|
||||
.strip_suffix(&[0])
|
||||
.ok_or_else(|| invalid_config_source("unterminated Git include path expansion"))?;
|
||||
if value.is_empty() || value.contains(&0) {
|
||||
return Err(invalid_config_source(
|
||||
"ambiguous Git include path expansion",
|
||||
));
|
||||
}
|
||||
let value = std::str::from_utf8(value)
|
||||
.map_err(|_| invalid_config_source("non-UTF-8 Git include path expansion"))?;
|
||||
Ok(PathBuf::from(value))
|
||||
}
|
||||
@@ -33,6 +33,24 @@ pub(super) fn normalize_absolute_path(path: impl AsRef<Path>) -> io::Result<Path
|
||||
Ok(AbsolutePathBuf::from_absolute_path(path)?.into_path_buf())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(super) fn reject_raw_ambiguous_windows_config_path(path: &str) -> io::Result<()> {
|
||||
if windows_config_path_is_ambiguous(path) {
|
||||
return Err(invalid_config_source("ambiguous Windows Git config path"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(super) fn reject_raw_ambiguous_windows_config_path(_path: &str) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
pub(super) fn windows_config_path_is_ambiguous(path: &str) -> bool {
|
||||
crate::path_authority::windows_path_is_ambiguous(path)
|
||||
}
|
||||
|
||||
pub(super) fn invalid_config_source(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,30 @@ use super::path_safety::invalid_config_source;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_config::parse_git_boolean_symmetric_i32;
|
||||
|
||||
pub(super) fn default_system_config_source_candidates(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
) -> io::Result<Vec<(&'static str, PathBuf)>> {
|
||||
if git_env_bool(git, "GIT_CONFIG_NOSYSTEM")?
|
||||
|| git.config_environment_value("GIT_CONFIG_SYSTEM").is_some()
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// `GIT_CONFIG_SYSTEM` was added to `git var` in Git 2.42. The PSEC-4394
|
||||
// boundary treats the selected Git installation and its non-environment
|
||||
// compile-time system config as host-owned trusted inputs. For older Git,
|
||||
// the exact custom ETC_GITCONFIG path is therefore a documented residual;
|
||||
// the derivable prefix/ProgramData paths are still checked separately and
|
||||
// the no-includes graph validates every directive the system file exposes.
|
||||
let Some(paths) = git_var_config_paths(git, cwd, "GIT_CONFIG_SYSTEM")? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
Ok(paths
|
||||
.into_iter()
|
||||
.map(|path| ("GIT_CONFIG_SYSTEM", path))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) fn selected_git_home_config_candidates(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
@@ -86,6 +110,54 @@ fn selected_git_path_candidate(git: &GitRunner, cwd: &Path, raw: &str) -> io::Re
|
||||
git_var_path_from_bytes(value)
|
||||
}
|
||||
|
||||
fn git_var_config_paths(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
variable: &str,
|
||||
) -> io::Result<Option<Vec<PathBuf>>> {
|
||||
let mut command = git.command_for_cwd(cwd)?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(["var", variable]);
|
||||
let output = git.output(command)?;
|
||||
parse_git_var_config_paths_result(
|
||||
output.status.code(),
|
||||
&output.stdout,
|
||||
&output.stderr,
|
||||
variable,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn parse_git_var_config_paths_result(
|
||||
status_code: Option<i32>,
|
||||
stdout: &[u8],
|
||||
stderr: &[u8],
|
||||
variable: &str,
|
||||
) -> io::Result<Option<Vec<PathBuf>>> {
|
||||
match status_code {
|
||||
Some(0) => parse_git_var_paths(stdout).map(Some),
|
||||
Some(1) if stdout.is_empty() && stderr.is_empty() => Ok(Some(Vec::new())),
|
||||
// These variables were added in Git 2.42. Older Git reports usage
|
||||
// status 129; fall back to its documented environment/home sources.
|
||||
Some(129) => Ok(None),
|
||||
_ => Err(io::Error::other(format!(
|
||||
"git {variable} source probe failed with status {status_code:?}: {}",
|
||||
String::from_utf8_lossy(stderr).trim()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_git_var_paths(output: &[u8]) -> io::Result<Vec<PathBuf>> {
|
||||
output
|
||||
.split(|byte| *byte == b'\n')
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(|path| {
|
||||
let path = path.strip_suffix(b"\r").unwrap_or(path);
|
||||
git_var_path_from_bytes(path)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn legacy_primary_config_source_candidates(
|
||||
git: &GitRunner,
|
||||
) -> io::Result<Vec<(&'static str, PathBuf)>> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,62 @@ fn symmetric_git_boolean_parser_excludes_only_int_min_spellings() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_parsers_preserve_duplicate_include_directives_in_order() {
|
||||
let scoped = b"local\0file:.git/config\0include.path\n../unsafe.gitconfig\0\
|
||||
local\0file:.git/config\0include.path\n/absolute/external.gitconfig\0";
|
||||
let legacy = b"file:.git/config\0include.path\n../unsafe.gitconfig\0\
|
||||
file:.git/config\0include.path\n/absolute/external.gitconfig\0";
|
||||
let expected_scoped = vec![
|
||||
GitConfigEntry {
|
||||
scope: GitConfigScope::Local,
|
||||
origin: GitConfigOrigin::File(".git/config".into()),
|
||||
key: "include.path".to_string(),
|
||||
value: "../unsafe.gitconfig".to_string(),
|
||||
},
|
||||
GitConfigEntry {
|
||||
scope: GitConfigScope::Local,
|
||||
origin: GitConfigOrigin::File(".git/config".into()),
|
||||
key: "include.path".to_string(),
|
||||
value: "/absolute/external.gitconfig".to_string(),
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
parse_config_entries(scoped).expect("scoped entries"),
|
||||
expected_scoped
|
||||
);
|
||||
assert_eq!(
|
||||
parse_config_entries_with_origins(legacy).expect("legacy entries"),
|
||||
expected_scoped
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn config_origin_parser_preserves_non_utf8_unix_path_bytes() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let entries = parse_config_entries(b"local\0file:/tmp/config-\xff\0include.path\n/tmp/safe\0")
|
||||
.expect("non-UTF-8 config origin");
|
||||
let GitConfigOrigin::File(path) = &entries[0].origin else {
|
||||
panic!("expected file origin");
|
||||
};
|
||||
assert_eq!(path.as_os_str().as_bytes(), b"/tmp/config-\xff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_config_records() {
|
||||
for output in [
|
||||
b"global\0file:/tmp/config\0key\nvalue".as_slice(),
|
||||
b"global\0file:/tmp/config\0key\0".as_slice(),
|
||||
b"mystery\0file:/tmp/config\0key\nvalue\0".as_slice(),
|
||||
b"global\0\0key\nvalue\0".as_slice(),
|
||||
b"global\0file:/tmp/config\0\nvalue\0".as_slice(),
|
||||
] {
|
||||
assert!(parse_config_entries(output).is_err(), "{output:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_containment_uses_component_boundaries() {
|
||||
let root = Path::new("/repo/root");
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::apply::safe_git_config_parts;
|
||||
use crate::apply::write_temp_patch;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_config::path_is_within;
|
||||
use crate::git_config_sources::ensure_no_worktree_primary_config_sources;
|
||||
use crate::git_config_sources::ensure_no_worktree_config_sources;
|
||||
|
||||
/// Extract paths with Git from a cwd whose config sources have already been
|
||||
/// authorized for `git_config_args`.
|
||||
@@ -66,7 +66,7 @@ fn extract_paths_from_patch_from_cwd(diff_text: &str, cwd: &Path) -> Vec<String>
|
||||
let git_root = crate::get_git_repo_root(cwd)
|
||||
.ok_or_else(|| io::Error::other("not a Git repository"))?;
|
||||
let git_root = std::fs::canonicalize(git_root)?;
|
||||
ensure_no_worktree_primary_config_sources(&git, &git_root)?;
|
||||
ensure_no_worktree_config_sources(&git, &git_root, &[])?;
|
||||
extract_effective_paths_from_patch(&git, &git_root, &patch_path, /*revert*/ false, &[])
|
||||
})()
|
||||
.unwrap_or_default();
|
||||
@@ -245,7 +245,7 @@ fn invalid_windows_patch_component(component: &str) -> bool {
|
||||
pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
|
||||
let git = GitRunner::for_cwd_io(git_root)?;
|
||||
let git_config_args = safe_git_config_parts();
|
||||
ensure_no_worktree_primary_config_sources(&git, git_root)?;
|
||||
ensure_no_worktree_config_sources(&git, git_root, &git_config_args)?;
|
||||
let (tmpdir, patch_path) = write_temp_patch(diff)?;
|
||||
let paths = extract_effective_paths_from_patch(
|
||||
&git,
|
||||
|
||||
@@ -68,7 +68,7 @@ fn effective_paths(diff: &str, revert: bool) -> io::Result<Vec<String>> {
|
||||
let git_root =
|
||||
crate::get_git_repo_root(cwd).ok_or_else(|| io::Error::other("not a Git repository"))?;
|
||||
let git_root = std::fs::canonicalize(git_root)?;
|
||||
ensure_no_worktree_primary_config_sources(&git, &git_root)?;
|
||||
ensure_no_worktree_config_sources(&git, &git_root, &[])?;
|
||||
let paths = extract_effective_paths_from_patch(&git, &git_root, &patch_path, revert, &[])?;
|
||||
drop(tmpdir);
|
||||
Ok(paths)
|
||||
|
||||
@@ -18,7 +18,7 @@ use route_walker::invalid_data;
|
||||
use route_walker::observe_route;
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) use windows_path::windows_authority_path_is_ambiguous;
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) use windows_path::windows_path_is_ambiguous;
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
|
||||
@@ -2,6 +2,8 @@ use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use same_file::Handle;
|
||||
|
||||
use crate::errors::GitReadError;
|
||||
use crate::git_config::path_is_within;
|
||||
use crate::path_authority::RepositoryRouteBoundaries;
|
||||
@@ -29,6 +31,7 @@ mod policy;
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RepositoryAuthority {
|
||||
active_worktree_root: PathBuf,
|
||||
active_worktree_identity: Option<Handle>,
|
||||
roots: Vec<PathBuf>,
|
||||
worktree_roots: Vec<PathBuf>,
|
||||
common_dirs: Vec<PathBuf>,
|
||||
@@ -57,8 +60,11 @@ impl RepositoryAuthority {
|
||||
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 active_worktree_identity = Handle::from_path(&worktree_root)
|
||||
.map_err(|error| invalid_metadata(&worktree_root, error))?;
|
||||
let mut authority = Self {
|
||||
active_worktree_root: worktree_root.clone(),
|
||||
active_worktree_identity: Some(active_worktree_identity),
|
||||
roots: Vec::new(),
|
||||
worktree_roots: Vec::new(),
|
||||
common_dirs: Vec::new(),
|
||||
@@ -116,6 +122,7 @@ impl RepositoryAuthority {
|
||||
}
|
||||
|
||||
pub(crate) fn revalidate_active_repository_metadata(&self) -> io::Result<()> {
|
||||
self.revalidate_active_worktree_identity()?;
|
||||
let Some(expected) = &self.active_metadata else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -148,6 +155,25 @@ impl RepositoryAuthority {
|
||||
})
|
||||
}
|
||||
|
||||
fn revalidate_active_worktree_identity(&self) -> io::Result<()> {
|
||||
let Some(expected) = &self.active_worktree_identity else {
|
||||
return Ok(());
|
||||
};
|
||||
let actual = Handle::from_path(&self.active_worktree_root).map_err(|error| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!("guarded Git repository identity could not be revalidated: {error}"),
|
||||
)
|
||||
})?;
|
||||
if &actual != expected {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"guarded Git repository identity changed before Git launch",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_test_locations(
|
||||
roots: Vec<PathBuf>,
|
||||
@@ -156,12 +182,15 @@ impl RepositoryAuthority {
|
||||
) -> Result<Self, GitReadError> {
|
||||
let route_boundaries = repository_route_boundaries(&worktree_roots, &common_dirs)
|
||||
.map_err(|error| invalid_metadata(Path::new("<test>"), error))?;
|
||||
let active_worktree_root = worktree_roots
|
||||
.first()
|
||||
.or_else(|| roots.first())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let active_worktree_identity = Handle::from_path(&active_worktree_root).ok();
|
||||
Ok(Self {
|
||||
active_worktree_root: worktree_roots
|
||||
.first()
|
||||
.or_else(|| roots.first())
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
active_worktree_root,
|
||||
active_worktree_identity,
|
||||
roots,
|
||||
worktree_roots,
|
||||
common_dirs,
|
||||
|
||||
Reference in New Issue
Block a user