Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions gix-path/src/env/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,21 @@ pub fn core_dir() -> Option<&'static Path> {
GIT_CORE_DIR.as_deref()
}

/// Return the path at which the Git-provided program with `name` resides within the [`core_dir()`],
/// or `None` if it doesn't exist there or if Git could not be found.
///
/// This is the location `git` itself uses to find the programs implementing its subcommands, and is
/// useful to invoke programs like `git-upload-pack` that are shipped with Git but aren't necessarily
/// present in `PATH`.
///
/// Note that installations differ in which programs they provide as separate executables - builds
/// with `SKIP_DASHED_BUILT_INS`, like Git for Windows, omit programs for builtin subcommands, which
/// can then still be run through `git` itself.
pub fn core_dir_program(name: &str) -> Option<PathBuf> {
let path = core_dir()?.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject path components in core_dir_program names

Because this helper is public and documented as finding a Git-provided program within core_dir(), callers that pass a user/config-provided name containing .., separators, or an absolute path can get back any existing file outside Git's exec path (Path::join does not confine the result to the base directory). Please validate that name is only a bare program name before joining so the returned path cannot escape the core dir.

Useful? React with 👍 / 👎.

path.is_file().then_some(path)
}

fn system_prefix_from_core_dir<F>(core_dir_func: F) -> Option<PathBuf>
where
F: Fn() -> Option<&'static Path>,
Expand Down
30 changes: 30 additions & 0 deletions gix-path/tests/path/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,36 @@ fn core_dir() {
);
}

#[test]
fn core_dir_program() {
let core_dir = gix_path::env::core_dir().expect("Git is always in PATH when we run tests");
let program = std::fs::read_dir(core_dir)
.expect("the core directory can be listed")
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_ok_and(|t| t.is_file()))
.find_map(|entry| {
let name = entry.file_name();
let stem = name.to_str()?.strip_suffix(std::env::consts::EXE_SUFFIX)?;
stem.starts_with("git-").then(|| stem.to_owned())
});
// Which programs are present as separate executables depends on how Git was built -
// Git for Windows, for instance, may not provide programs for builtin subcommands.
if let Some(stem) = program {
let path =
gix_path::env::core_dir_program(&stem).expect("a program listed in the core directory is found there");
assert!(path.is_file(), "the returned path refers to an existing file");
assert!(
path.is_absolute(),
"the path is absolute as it is based on `git --exec-path`"
);
}
assert_eq!(
gix_path::env::core_dir_program("git-program-that-does-not-exist"),
None,
"programs that don't exist in the core directory are not found"
);
}

#[test]
fn system_prefix() {
assert_ne!(
Expand Down
1 change: 1 addition & 0 deletions gix-transport/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ required-features = ["async-client"]

[dependencies]
gix-command = { version = "^0.9.1", path = "../gix-command" }
gix-path = { version = "^0.12.1", path = "../gix-path" }
gix-features = { version = "^0.48.1", path = "../gix-features" }
gix-url = { version = "^0.36.1", path = "../gix-url" }
gix-sec = { version = "^0.14.1", path = "../gix-sec" }
Expand Down
121 changes: 109 additions & 12 deletions gix-transport/src/client/blocking_io/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,37 @@ impl SpawnProcessOnDemand {
cmd.args.push(repo_path);
Ok((cmd, ssh_kind, cmd_name))
}

/// Prepare an invocation for when the program of `service`, e.g. `git-upload-pack`, isn't present in `PATH`,
/// which can happen on Windows in particular, where `git` may be installed without its subcommands
/// being directly available (#2313).
///
/// Prefer the same program from git's own `--exec-path`, which is where `git` itself finds it, and
/// otherwise let the `git` we can always find run it as subcommand.
/// This is only used for local repositories - remote shells keep the standard invocation.
fn prepare_fallback_command(&self, service: Service) -> (gix_command::Prepare, OsString) {
let (mut cmd, cmd_name) = match gix_path::env::core_dir_program(service.as_str()) {
Some(program) => {
let cmd_name: OsString = program.clone().into();
(gix_command::prepare(program).stderr(Stdio::null()), cmd_name)
}
None => {
let subcommand = service
.as_str()
.strip_prefix("git-")
.expect("all services are 'git-*' subcommands");
let git = gix_path::env::exe_invocation();
let mut cmd_name: OsString = git.into();
cmd_name.push(" ");
cmd_name.push(subcommand);
let mut cmd = gix_command::prepare(git).stderr(Stdio::null());
cmd.args.push(subcommand.into());
(cmd, cmd_name)
}
};
cmd.args.push(self.path.to_os_str_lossy().into_owned());
(cmd, cmd_name)
}
}

impl client::TransportWithoutIO for SpawnProcessOnDemand {
Expand Down Expand Up @@ -241,21 +272,42 @@ impl client::blocking_io::Transport for SpawnProcessOnDemand {
service: Service,
extra_parameters: &'a [(&'a str, Option<&'a str>)],
) -> Result<SetServiceResponse<'_>, client::Error> {
let (mut cmd, ssh_kind, cmd_name) = self.prepare_command(service)?;
cmd.stdin = Stdio::piped();
cmd.stdout = Stdio::piped();
let (cmd, ssh_kind, cmd_name) = self.prepare_command(service)?;
let envs = std::mem::take(&mut self.envs);
let into_std_command = |mut cmd: gix_command::Prepare| {
cmd.stdin = Stdio::piped();
cmd.stdout = Stdio::piped();

let mut cmd = std::process::Command::from(cmd);
for env_to_remove in ENV_VARS_TO_REMOVE {
cmd.env_remove(env_to_remove);
}
cmd.envs(std::mem::take(&mut self.envs));
let mut cmd = std::process::Command::from(cmd);
for env_to_remove in ENV_VARS_TO_REMOVE {
cmd.env_remove(env_to_remove);
}
cmd.envs(envs.iter().map(|(k, v)| (k, v)));
cmd
};

let mut cmd = into_std_command(cmd);
gix_features::trace::debug!(command = ?cmd, "gix_transport::SpawnProcessOnDemand");
let mut child = cmd.spawn().map_err(|err| client::Error::InvokeProgram {
source: err,
command: cmd_name,
})?;
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(err) if ssh_kind.is_none() && err.kind() == std::io::ErrorKind::NotFound => {
// The service program wasn't found in `PATH`, but as `git` itself can be found,
// the service can still be run through it (#2313).
let (cmd, cmd_name) = self.prepare_fallback_command(service);
let mut cmd = into_std_command(cmd);
gix_features::trace::debug!(command = ?cmd, "gix_transport::SpawnProcessOnDemand (fallback)");
cmd.spawn().map_err(|err| client::Error::InvokeProgram {
source: err,
command: cmd_name,
})?
}
Err(err) => {
return Err(client::Error::InvokeProgram {
source: err,
command: cmd_name,
});
}
};
let stdout: Box<dyn std::io::Read + Send> = match ssh_kind {
Some(ssh_kind) => Box::new(supervise_stderr(
ssh_kind,
Expand Down Expand Up @@ -305,6 +357,51 @@ pub fn connect(

#[cfg(test)]
mod tests {
mod local {
use crate::{Protocol, Service, client::blocking_io::file::SpawnProcessOnDemand};

#[test]
fn fallback_command_prefers_the_core_dir_program_and_can_always_run_git_itself() {
let transport = SpawnProcessOnDemand::new_local("/repo/path".into(), Protocol::V2, false);
let (cmd, name) = transport.prepare_fallback_command(Service::UploadPack);

assert!(!cmd.use_shell, "a shell is never used to run the local service program");
assert_eq!(
cmd.args.last().map(std::path::Path::new),
Some(std::path::Path::new("/repo/path")),
"the repository path is the final argument"
);
match gix_path::env::core_dir_program(Service::UploadPack.as_str()) {
Some(program) => {
assert_eq!(
std::path::Path::new(&cmd.command),
program,
"the program shipped with Git in its `--exec-path` is used directly"
);
assert!(
std::path::Path::new(&cmd.command).is_absolute(),
"which also means it's an absolute path"
);
assert_eq!(cmd.args.len(), 1, "there is no sub-command, just the repo path");
assert_eq!(name, program.into_os_string());
}
None => {
assert_eq!(
std::path::Path::new(&cmd.command),
gix_path::env::exe_invocation(),
"without it, `git` itself runs the service as subcommand"
);
assert_eq!(
cmd.args.first().and_then(|arg| arg.to_str()),
Some("upload-pack"),
"the sub-command is passed as separate argument, without the 'git-' prefix"
);
assert_eq!(cmd.args.len(), 2, "sub-command and repo path");
}
}
}
}

mod ssh {
mod connect {
use crate::{Protocol, client::blocking_io::ssh};
Expand Down
Loading