//! Builder for `git` invocations. Never uses a shell. Cleared environment. use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::{Output, Stdio}; use crate::error::{Error, Result}; /// Where git lives and where forge keeps its managed config and hooks. /// Gitconfig at `data_dir/gitconfig`, hooks at `data_dir/hooks`. #[derive(Debug, Clone)] pub struct GitEnv { pub git_bin: PathBuf, pub data_dir: PathBuf, } /// Extra object directory that a read-only command may read from, besides its own repo. Used to /// compare a fork's commits without copying them into the base repo. Never built from user input. #[derive(Debug, Clone, Copy, Default)] pub struct ObjectSource<'a> { pub extra: Option<&'a Path>, } /// Builds a `git` command. Only trusted static flags go through [`GitCommand::arg`]; /// user input only through the `user_*` methods and [`GitCommand::paths`]. #[derive(Debug)] pub struct GitCommand { program: PathBuf, git_dir: Option, subcommand: &'static str, args: Vec, envs: Vec<(&'static str, OsString)>, end_of_options: bool, local_transport: bool, /// `key=value` pairs passed as `-c` before the subcommand. Static text only. configs: Vec, } /// Rejects user values that git could read as an option, or that cannot be passed as argv. fn check_user_value(v: &str) -> Result<()> { if v.is_empty() { return Err(Error::InvalidInput("empty value".into())); } if v.starts_with('-') { return Err(Error::InvalidInput(format!("value starts with '-': {v}"))); } if v.contains('\0') { return Err(Error::InvalidInput("value contains NUL".into())); } Ok(()) } impl GitCommand { /// Sets the child environment: `PATH`, `HOME=data_dir`, `GIT_CONFIG_NOSYSTEM=1`, /// `GIT_CONFIG_GLOBAL=data_dir/gitconfig`, `GIT_TERMINAL_PROMPT=0`, `LANG=C`. /// The environment is cleared before these are applied, in [`GitCommand::into_tokio`]. pub fn new(env: &GitEnv, subcommand: &'static str) -> Self { let envs = vec![ ("PATH", OsString::from("/usr/local/bin:/usr/bin:/bin")), ("HOME", env.data_dir.as_os_str().to_os_string()), ("GIT_CONFIG_NOSYSTEM", OsString::from("1")), ( "GIT_CONFIG_GLOBAL", env.data_dir.join("gitconfig").into_os_string(), ), ("GIT_TERMINAL_PROMPT", OsString::from("0")), ("LANG", OsString::from("C")), ]; Self { program: env.git_bin.clone(), git_dir: None, subcommand, args: Vec::new(), envs, end_of_options: false, local_transport: false, configs: Vec::new(), } } /// Adds a `-c key=value` before the subcommand. Only trusted static text goes here. pub fn config(mut self, kv: &'static str) -> Self { self.configs.push(kv.to_string()); self } /// Adds a `-c key=value` whose value was built at run time, such as a pinned address. Rejects a /// pair with no `=`, or with whitespace or control characters. pub fn config_checked(mut self, kv: String) -> Result { if !kv.contains('=') || kv.chars().any(|c| c.is_whitespace() || c.is_control()) { return Err(Error::InvalidInput("invalid git config pair".into())); } self.configs.push(kv); Ok(self) } /// Appends a trusted static flag. pub fn arg(mut self, a: &'static str) -> Self { self.args.push(OsString::from(a)); self } /// Adds `--git-dir=

`, placed before the subcommand. pub fn git_dir(mut self, p: &Path) -> Self { let mut s = OsString::from("--git-dir="); s.push(p.as_os_str()); self.git_dir = Some(s); self } /// Allows git's `file` transport (local paths) for this one invocation, by passing /// `-c protocol.file.allow=always` ahead of the subcommand. The managed gitconfig sets /// `protocol.file.allow = never` to keep user-controlled URLs out of local paths, so only the /// call sites that open repositories forge itself chose may use this. Only pass paths produced /// by `repo_path()` or the server's own data dir, never user input. pub fn allow_local_transport(mut self) -> Self { self.local_transport = true; self } /// Appends `--end-of-options` (once) and the revs. Rejects empty revs and revs starting with `-`. pub fn user_revs(mut self, revs: &[&str]) -> Result { for r in revs { check_user_value(r)?; } if !self.end_of_options { self.args.push(OsString::from("--end-of-options")); self.end_of_options = true; } self.args.extend(revs.iter().map(OsString::from)); Ok(self) } /// Appends one validated user value as a positional argument (no separator). pub fn user_arg(mut self, v: &str) -> Result { check_user_value(v)?; self.args.push(OsString::from(v)); Ok(self) } /// Appends `-e `. The value follows `-e`, so git reads it as a pattern even when it /// starts with `-`. Rejects empty patterns and NUL. pub fn user_pattern(mut self, pattern: &str) -> Result { if pattern.is_empty() { return Err(Error::InvalidInput("empty pattern".into())); } if pattern.contains('\0') { return Err(Error::InvalidInput("pattern contains NUL".into())); } self.args.push(OsString::from("-e")); self.args.push(OsString::from(pattern)); Ok(self) } /// Appends one validated `--flag=value` argument, e.g. `("--initial-branch", "main")`. pub fn user_opt(mut self, flag: &'static str, v: &str) -> Result { check_user_value(v)?; self.args.push(OsString::from(format!("{flag}={v}"))); Ok(self) } /// Appends `--` and the paths. pub fn paths(mut self, paths: &[&str]) -> Self { self.args.push(OsString::from("--")); self.args.extend(paths.iter().map(OsString::from)); self } pub fn env(mut self, k: &'static str, v: impl AsRef) -> Self { self.envs.push((k, v.as_ref().to_os_string())); self } /// Lets this command read objects from `dir` as well, through `GIT_ALTERNATE_OBJECT_DIRECTORIES`. /// The variable is set on this child process only. Git writes nothing there, so use it only for /// read-only subcommands. `dir` must be an absolute existing directory whose path has no `:` /// (git's list separator) and no newline. pub fn alternate_objects(mut self, dir: &Path) -> Result { let text = dir .to_str() .ok_or_else(|| Error::InvalidInput("object dir is not valid UTF-8".into()))?; if !dir.is_absolute() || text.contains([':', '\n']) { return Err(Error::InvalidInput(format!("bad object dir: {text:?}"))); } if !dir.is_dir() { return Err(Error::InvalidInput(format!( "object dir does not exist: {text:?}" ))); } self.envs .push(("GIT_ALTERNATE_OBJECT_DIRECTORIES", OsString::from(text))); Ok(self) } /// Applies an [`ObjectSource`]: adds its extra object directory, if it has one. pub fn objects(self, src: ObjectSource<'_>) -> Result { match src.extra { Some(dir) => self.alternate_objects(dir), None => Ok(self), } } pub fn into_tokio(self) -> tokio::process::Command { let mut cmd = tokio::process::Command::new(&self.program); cmd.env_clear(); for (k, v) in &self.envs { cmd.env(k, v); } if let Some(g) = &self.git_dir { cmd.arg(g); } if self.local_transport { cmd.args(["-c", "protocol.file.allow=always"]); } for kv in &self.configs { cmd.args(["-c", kv.as_str()]); } cmd.arg(self.subcommand); cmd.args(&self.args); cmd.stdin(Stdio::null()); cmd.kill_on_drop(true); cmd } /// Runs the command and returns stdout. A non-zero exit becomes `Error::Failed` with stderr. pub async fn output(self) -> Result> { let subcommand = self.subcommand; let out = self.into_tokio().output().await?; check_status(subcommand, &out)?; Ok(out.stdout) } /// Runs the command with `input` on stdin and returns stdout. Used by plumbing such as /// `hash-object --stdin` and `commit-tree -F -`, where the content is not an argument. pub async fn output_stdin(self, input: &[u8]) -> Result> { use tokio::io::AsyncWriteExt; let subcommand = self.subcommand; let mut cmd = self.into_tokio(); cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = cmd.spawn()?; if let Some(mut stdin) = child.stdin.take() { stdin.write_all(input).await?; stdin.shutdown().await?; } let out = child.wait_with_output().await?; check_status(subcommand, &out)?; Ok(out.stdout) } /// Runs the command and discards stdout. A non-zero exit becomes `Error::Failed` with stderr. pub async fn status(self) -> Result<()> { self.output().await.map(|_| ()) } } fn check_status(subcommand: &str, out: &Output) -> Result<()> { if out.status.success() { return Ok(()); } let stderr = String::from_utf8_lossy(&out.stderr); Err(Error::Failed(format!( "git {subcommand} exited with {}: {}", out.status, stderr.trim() ))) } #[cfg(test)] mod tests { use super::*; fn env() -> GitEnv { GitEnv { git_bin: PathBuf::from("/usr/bin/git"), data_dir: PathBuf::from("/"), } } fn alternates_of(cmd: GitCommand) -> Option { cmd.into_tokio() .as_std() .get_envs() .find(|(k, _)| *k == OsStr::new("GIT_ALTERNATE_OBJECT_DIRECTORIES")) .and_then(|(_, v)| v.map(OsStr::to_os_string)) } #[test] fn alternate_objects_is_validated_and_set_on_the_child_only() { let dir = tempfile::tempdir().expect("tempdir"); let objects = dir.path().join("objects"); std::fs::create_dir(&objects).expect("objects dir"); assert!( GitCommand::new(&env(), "log") .alternate_objects(Path::new("objects")) .is_err() ); assert!( GitCommand::new(&env(), "log") .alternate_objects(&dir.path().join("nope")) .is_err() ); assert!( GitCommand::new(&env(), "log") .alternate_objects(Path::new("/a:b")) .is_err() ); let set = GitCommand::new(&env(), "log") .alternate_objects(&objects) .expect("valid dir"); assert_eq!(alternates_of(set), Some(objects.clone().into_os_string())); assert_eq!(alternates_of(GitCommand::new(&env(), "log")), None); let none = GitCommand::new(&env(), "log") .objects(ObjectSource::default()) .expect("no source"); assert_eq!(alternates_of(none), None); } }