forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
11.3 KiB
1//! Builder for `git` invocations. Never uses a shell. Cleared environment.
2
3use std::ffi::{OsStr, OsString};
4use std::path::{Path, PathBuf};
5use std::process::{Output, Stdio};
6
7use 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)]
12pub 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)]
20pub 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)]
27pub 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.
40fn 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
53impl 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
259fn 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)]
272mod 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}