Tahinli/forge
public0 watching
★ 0
| 1 | //! Builder for `git` invocations. Never uses a shell. Cleared environment. |
| 2 | |
| 3 | use std::ffi::{OsStr, OsString}; |
| 4 | use std::path::{Path, PathBuf}; |
| 5 | use std::process::{Output, Stdio}; |
| 6 | |
| 7 | use crate::error::{Error, Result}; |
| 8 | |
| 9 | /// Where git lives and where forge keeps its managed config and hooks. |
| 10 | /// Gitconfig at `data_dir/gitconfig`, hooks at `data_dir/hooks`. |
| 11 | #[derive(Debug, Clone)] |
| 12 | pub struct GitEnv { |
| 13 | pub git_bin: PathBuf, |
| 14 | pub data_dir: PathBuf, |
| 15 | } |
| 16 | |
| 17 | /// Extra object directory that a read-only command may read from, besides its own repo. Used to |
| 18 | /// compare a fork's commits without copying them into the base repo. Never built from user input. |
| 19 | #[derive(Debug, Clone, Copy, Default)] |
| 20 | pub struct ObjectSource<'a> { |
| 21 | pub extra: Option<&'a Path>, |
| 22 | } |
| 23 | |
| 24 | /// Builds a `git` command. Only trusted static flags go through [`GitCommand::arg`]; |
| 25 | /// user input only through the `user_*` methods and [`GitCommand::paths`]. |
| 26 | #[derive(Debug)] |
| 27 | pub struct GitCommand { |
| 28 | program: PathBuf, |
| 29 | git_dir: Option<OsString>, |
| 30 | subcommand: &'static str, |
| 31 | args: Vec<OsString>, |
| 32 | envs: Vec<(&'static str, OsString)>, |
| 33 | end_of_options: bool, |
| 34 | local_transport: bool, |
| 35 | /// `key=value` pairs passed as `-c` before the subcommand. Static text only. |
| 36 | configs: Vec<String>, |
| 37 | } |
| 38 | |
| 39 | /// Rejects user values that git could read as an option, or that cannot be passed as argv. |
| 40 | fn check_user_value(v: &str) -> Result<()> { |
| 41 | if v.is_empty() { |
| 42 | return Err(Error::InvalidInput("empty value".into())); |
| 43 | } |
| 44 | if v.starts_with('-') { |
| 45 | return Err(Error::InvalidInput(format!("value starts with '-': {v}"))); |
| 46 | } |
| 47 | if v.contains('\0') { |
| 48 | return Err(Error::InvalidInput("value contains NUL".into())); |
| 49 | } |
| 50 | Ok(()) |
| 51 | } |
| 52 | |
| 53 | impl GitCommand { |
| 54 | /// Sets the child environment: `PATH`, `HOME=data_dir`, `GIT_CONFIG_NOSYSTEM=1`, |
| 55 | /// `GIT_CONFIG_GLOBAL=data_dir/gitconfig`, `GIT_TERMINAL_PROMPT=0`, `LANG=C`. |
| 56 | /// The environment is cleared before these are applied, in [`GitCommand::into_tokio`]. |
| 57 | pub fn new(env: &GitEnv, subcommand: &'static str) -> Self { |
| 58 | let envs = vec![ |
| 59 | ("PATH", OsString::from("/usr/local/bin:/usr/bin:/bin")), |
| 60 | ("HOME", env.data_dir.as_os_str().to_os_string()), |
| 61 | ("GIT_CONFIG_NOSYSTEM", OsString::from("1")), |
| 62 | ( |
| 63 | "GIT_CONFIG_GLOBAL", |
| 64 | env.data_dir.join("gitconfig").into_os_string(), |
| 65 | ), |
| 66 | ("GIT_TERMINAL_PROMPT", OsString::from("0")), |
| 67 | ("LANG", OsString::from("C")), |
| 68 | ]; |
| 69 | Self { |
| 70 | program: env.git_bin.clone(), |
| 71 | git_dir: None, |
| 72 | subcommand, |
| 73 | args: Vec::new(), |
| 74 | envs, |
| 75 | end_of_options: false, |
| 76 | local_transport: false, |
| 77 | configs: Vec::new(), |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /// Adds a `-c key=value` before the subcommand. Only trusted static text goes here. |
| 82 | pub fn config(mut self, kv: &'static str) -> Self { |
| 83 | self.configs.push(kv.to_string()); |
| 84 | self |
| 85 | } |
| 86 | |
| 87 | /// Adds a `-c key=value` whose value was built at run time, such as a pinned address. Rejects a |
| 88 | /// pair with no `=`, or with whitespace or control characters. |
| 89 | pub fn config_checked(mut self, kv: String) -> Result<Self> { |
| 90 | if !kv.contains('=') || kv.chars().any(|c| c.is_whitespace() || c.is_control()) { |
| 91 | return Err(Error::InvalidInput("invalid git config pair".into())); |
| 92 | } |
| 93 | self.configs.push(kv); |
| 94 | Ok(self) |
| 95 | } |
| 96 | |
| 97 | /// Appends a trusted static flag. |
| 98 | pub fn arg(mut self, a: &'static str) -> Self { |
| 99 | self.args.push(OsString::from(a)); |
| 100 | self |
| 101 | } |
| 102 | |
| 103 | /// Adds `--git-dir=<p>`, placed before the subcommand. |
| 104 | pub fn git_dir(mut self, p: &Path) -> Self { |
| 105 | let mut s = OsString::from("--git-dir="); |
| 106 | s.push(p.as_os_str()); |
| 107 | self.git_dir = Some(s); |
| 108 | self |
| 109 | } |
| 110 | |
| 111 | /// Allows git's `file` transport (local paths) for this one invocation, by passing |
| 112 | /// `-c protocol.file.allow=always` ahead of the subcommand. The managed gitconfig sets |
| 113 | /// `protocol.file.allow = never` to keep user-controlled URLs out of local paths, so only the |
| 114 | /// call sites that open repositories forge itself chose may use this. Only pass paths produced |
| 115 | /// by `repo_path()` or the server's own data dir, never user input. |
| 116 | pub fn allow_local_transport(mut self) -> Self { |
| 117 | self.local_transport = true; |
| 118 | self |
| 119 | } |
| 120 | |
| 121 | /// Appends `--end-of-options` (once) and the revs. Rejects empty revs and revs starting with `-`. |
| 122 | pub fn user_revs(mut self, revs: &[&str]) -> Result<Self> { |
| 123 | for r in revs { |
| 124 | check_user_value(r)?; |
| 125 | } |
| 126 | if !self.end_of_options { |
| 127 | self.args.push(OsString::from("--end-of-options")); |
| 128 | self.end_of_options = true; |
| 129 | } |
| 130 | self.args.extend(revs.iter().map(OsString::from)); |
| 131 | Ok(self) |
| 132 | } |
| 133 | |
| 134 | /// Appends one validated user value as a positional argument (no separator). |
| 135 | pub fn user_arg(mut self, v: &str) -> Result<Self> { |
| 136 | check_user_value(v)?; |
| 137 | self.args.push(OsString::from(v)); |
| 138 | Ok(self) |
| 139 | } |
| 140 | |
| 141 | /// Appends `-e <pattern>`. The value follows `-e`, so git reads it as a pattern even when it |
| 142 | /// starts with `-`. Rejects empty patterns and NUL. |
| 143 | pub fn user_pattern(mut self, pattern: &str) -> Result<Self> { |
| 144 | if pattern.is_empty() { |
| 145 | return Err(Error::InvalidInput("empty pattern".into())); |
| 146 | } |
| 147 | if pattern.contains('\0') { |
| 148 | return Err(Error::InvalidInput("pattern contains NUL".into())); |
| 149 | } |
| 150 | self.args.push(OsString::from("-e")); |
| 151 | self.args.push(OsString::from(pattern)); |
| 152 | Ok(self) |
| 153 | } |
| 154 | |
| 155 | /// Appends one validated `--flag=value` argument, e.g. `("--initial-branch", "main")`. |
| 156 | pub fn user_opt(mut self, flag: &'static str, v: &str) -> Result<Self> { |
| 157 | check_user_value(v)?; |
| 158 | self.args.push(OsString::from(format!("{flag}={v}"))); |
| 159 | Ok(self) |
| 160 | } |
| 161 | |
| 162 | /// Appends `--` and the paths. |
| 163 | pub fn paths(mut self, paths: &[&str]) -> Self { |
| 164 | self.args.push(OsString::from("--")); |
| 165 | self.args.extend(paths.iter().map(OsString::from)); |
| 166 | self |
| 167 | } |
| 168 | |
| 169 | pub fn env(mut self, k: &'static str, v: impl AsRef<OsStr>) -> Self { |
| 170 | self.envs.push((k, v.as_ref().to_os_string())); |
| 171 | self |
| 172 | } |
| 173 | |
| 174 | /// Lets this command read objects from `dir` as well, through `GIT_ALTERNATE_OBJECT_DIRECTORIES`. |
| 175 | /// The variable is set on this child process only. Git writes nothing there, so use it only for |
| 176 | /// read-only subcommands. `dir` must be an absolute existing directory whose path has no `:` |
| 177 | /// (git's list separator) and no newline. |
| 178 | pub fn alternate_objects(mut self, dir: &Path) -> Result<Self> { |
| 179 | let text = dir |
| 180 | .to_str() |
| 181 | .ok_or_else(|| Error::InvalidInput("object dir is not valid UTF-8".into()))?; |
| 182 | if !dir.is_absolute() || text.contains([':', '\n']) { |
| 183 | return Err(Error::InvalidInput(format!("bad object dir: {text:?}"))); |
| 184 | } |
| 185 | if !dir.is_dir() { |
| 186 | return Err(Error::InvalidInput(format!( |
| 187 | "object dir does not exist: {text:?}" |
| 188 | ))); |
| 189 | } |
| 190 | self.envs |
| 191 | .push(("GIT_ALTERNATE_OBJECT_DIRECTORIES", OsString::from(text))); |
| 192 | Ok(self) |
| 193 | } |
| 194 | |
| 195 | /// Applies an [`ObjectSource`]: adds its extra object directory, if it has one. |
| 196 | pub fn objects(self, src: ObjectSource<'_>) -> Result<Self> { |
| 197 | match src.extra { |
| 198 | Some(dir) => self.alternate_objects(dir), |
| 199 | None => Ok(self), |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | pub fn into_tokio(self) -> tokio::process::Command { |
| 204 | let mut cmd = tokio::process::Command::new(&self.program); |
| 205 | cmd.env_clear(); |
| 206 | for (k, v) in &self.envs { |
| 207 | cmd.env(k, v); |
| 208 | } |
| 209 | if let Some(g) = &self.git_dir { |
| 210 | cmd.arg(g); |
| 211 | } |
| 212 | if self.local_transport { |
| 213 | cmd.args(["-c", "protocol.file.allow=always"]); |
| 214 | } |
| 215 | for kv in &self.configs { |
| 216 | cmd.args(["-c", kv.as_str()]); |
| 217 | } |
| 218 | cmd.arg(self.subcommand); |
| 219 | cmd.args(&self.args); |
| 220 | cmd.stdin(Stdio::null()); |
| 221 | cmd.kill_on_drop(true); |
| 222 | cmd |
| 223 | } |
| 224 | |
| 225 | /// Runs the command and returns stdout. A non-zero exit becomes `Error::Failed` with stderr. |
| 226 | pub async fn output(self) -> Result<Vec<u8>> { |
| 227 | let subcommand = self.subcommand; |
| 228 | let out = self.into_tokio().output().await?; |
| 229 | check_status(subcommand, &out)?; |
| 230 | Ok(out.stdout) |
| 231 | } |
| 232 | |
| 233 | /// Runs the command with `input` on stdin and returns stdout. Used by plumbing such as |
| 234 | /// `hash-object --stdin` and `commit-tree -F -`, where the content is not an argument. |
| 235 | pub async fn output_stdin(self, input: &[u8]) -> Result<Vec<u8>> { |
| 236 | use tokio::io::AsyncWriteExt; |
| 237 | |
| 238 | let subcommand = self.subcommand; |
| 239 | let mut cmd = self.into_tokio(); |
| 240 | cmd.stdin(Stdio::piped()) |
| 241 | .stdout(Stdio::piped()) |
| 242 | .stderr(Stdio::piped()); |
| 243 | let mut child = cmd.spawn()?; |
| 244 | if let Some(mut stdin) = child.stdin.take() { |
| 245 | stdin.write_all(input).await?; |
| 246 | stdin.shutdown().await?; |
| 247 | } |
| 248 | let out = child.wait_with_output().await?; |
| 249 | check_status(subcommand, &out)?; |
| 250 | Ok(out.stdout) |
| 251 | } |
| 252 | |
| 253 | /// Runs the command and discards stdout. A non-zero exit becomes `Error::Failed` with stderr. |
| 254 | pub async fn status(self) -> Result<()> { |
| 255 | self.output().await.map(|_| ()) |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | fn check_status(subcommand: &str, out: &Output) -> Result<()> { |
| 260 | if out.status.success() { |
| 261 | return Ok(()); |
| 262 | } |
| 263 | let stderr = String::from_utf8_lossy(&out.stderr); |
| 264 | Err(Error::Failed(format!( |
| 265 | "git {subcommand} exited with {}: {}", |
| 266 | out.status, |
| 267 | stderr.trim() |
| 268 | ))) |
| 269 | } |
| 270 | |
| 271 | #[cfg(test)] |
| 272 | mod tests { |
| 273 | use super::*; |
| 274 | |
| 275 | fn env() -> GitEnv { |
| 276 | GitEnv { |
| 277 | git_bin: PathBuf::from("/usr/bin/git"), |
| 278 | data_dir: PathBuf::from("/"), |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | fn alternates_of(cmd: GitCommand) -> Option<OsString> { |
| 283 | cmd.into_tokio() |
| 284 | .as_std() |
| 285 | .get_envs() |
| 286 | .find(|(k, _)| *k == OsStr::new("GIT_ALTERNATE_OBJECT_DIRECTORIES")) |
| 287 | .and_then(|(_, v)| v.map(OsStr::to_os_string)) |
| 288 | } |
| 289 | |
| 290 | #[test] |
| 291 | fn alternate_objects_is_validated_and_set_on_the_child_only() { |
| 292 | let dir = tempfile::tempdir().expect("tempdir"); |
| 293 | let objects = dir.path().join("objects"); |
| 294 | std::fs::create_dir(&objects).expect("objects dir"); |
| 295 | |
| 296 | assert!( |
| 297 | GitCommand::new(&env(), "log") |
| 298 | .alternate_objects(Path::new("objects")) |
| 299 | .is_err() |
| 300 | ); |
| 301 | assert!( |
| 302 | GitCommand::new(&env(), "log") |
| 303 | .alternate_objects(&dir.path().join("nope")) |
| 304 | .is_err() |
| 305 | ); |
| 306 | assert!( |
| 307 | GitCommand::new(&env(), "log") |
| 308 | .alternate_objects(Path::new("/a:b")) |
| 309 | .is_err() |
| 310 | ); |
| 311 | |
| 312 | let set = GitCommand::new(&env(), "log") |
| 313 | .alternate_objects(&objects) |
| 314 | .expect("valid dir"); |
| 315 | assert_eq!(alternates_of(set), Some(objects.clone().into_os_string())); |
| 316 | assert_eq!(alternates_of(GitCommand::new(&env(), "log")), None); |
| 317 | let none = GitCommand::new(&env(), "log") |
| 318 | .objects(ObjectSource::default()) |
| 319 | .expect("no source"); |
| 320 | assert_eq!(alternates_of(none), None); |
| 321 | } |
| 322 | } |