Skip to main content

solid_pod_rs_forge/
gitrun.rs

1//! Argv-only, hardened `git` runner for the read features (JSON API, search,
2//! releases, archive).
3//!
4//! Every invocation goes through [`solid_pod_rs_git::hardened_git_async`]
5//! (no hooks, no system/global config, no prompts), runs with the bare repo
6//! as the working directory, takes arguments as an argv slice (never a
7//! shell), has no stdin, no inherited `HOME`, and a hard stdout cap. Callers
8//! validate refs and paths with [`crate::guard`] before they get here.
9
10use std::path::Path;
11use std::process::Stdio;
12use std::sync::OnceLock;
13use std::time::Duration;
14
15use tokio::io::AsyncReadExt;
16use tokio::sync::Semaphore;
17
18use crate::error::ForgeError;
19
20/// Result of one git run.
21#[derive(Debug)]
22pub struct GitOut {
23    /// Exit code (`None` when killed by a signal).
24    pub code: Option<i32>,
25    /// Captured stdout, at most the requested cap.
26    pub stdout: Vec<u8>,
27    /// `true` when stdout hit the cap and the child was killed.
28    pub truncated: bool,
29    /// Captured stderr (lossy UTF-8, bounded to 64 KiB).
30    pub stderr: String,
31}
32
33impl GitOut {
34    /// Did git exit zero without truncation?
35    #[must_use]
36    pub fn ok(&self) -> bool {
37        self.code == Some(0) && !self.truncated
38    }
39}
40
41/// Run `git <args>` in `git_dir`, capturing at most `cap` bytes of stdout.
42///
43/// # Errors
44///
45/// [`ForgeError::Unsupported`] when no `git` binary exists, otherwise
46/// [`ForgeError::Io`] on spawn or read failures. A non-zero exit is **not**
47/// an error here: inspect [`GitOut::code`] (`git grep` exits 1 on no match).
48pub async fn run(git_dir: &Path, args: &[&str], cap: usize) -> Result<GitOut, ForgeError> {
49    run_within(git_dir, args, cap, GIT_TIMEOUT).await
50}
51
52/// Wall-clock budget for one read-side git invocation. Dropping the future
53/// on expiry kills the child (`kill_on_drop`), so a `git grep` over a huge
54/// branch cannot hold a CPU for longer than this.
55pub const GIT_TIMEOUT: Duration = Duration::from_secs(30);
56
57/// [`run`] with an explicit wall-clock `limit`.
58///
59/// # Errors
60///
61/// As [`run`], plus [`ForgeError::Backend`] (`timed out`) when git outlives
62/// `limit`; the child is killed.
63pub async fn run_within(
64    git_dir: &Path,
65    args: &[&str],
66    cap: usize,
67    limit: Duration,
68) -> Result<GitOut, ForgeError> {
69    let sub = args.first().copied().unwrap_or("");
70    match tokio::time::timeout(limit, run_unbounded(git_dir, args, cap)).await {
71        Ok(r) => r,
72        Err(_) => Err(ForgeError::Backend(format!("git {sub}: timed out"))),
73    }
74}
75
76/// Permits for the expensive read operations (`git grep`, `git archive`).
77const HEAVY_PERMITS: usize = 4;
78
79/// How long a heavy request waits for a permit before a 503.
80const HEAVY_WAIT: Duration = Duration::from_secs(5);
81
82fn heavy() -> &'static Semaphore {
83    static S: OnceLock<Semaphore> = OnceLock::new();
84    S.get_or_init(|| Semaphore::new(HEAVY_PERMITS))
85}
86
87/// [`run`] for operations that can burn real CPU or memory (search, archive):
88/// at most four run at once process-wide, and a request that
89/// cannot get a permit within a few seconds is [`ForgeError::Busy`] (503).
90///
91/// # Examples
92///
93/// ```
94/// use solid_pod_rs_forge::gitrun::run_heavy;
95///
96/// # async fn demo(repo: &std::path::Path) -> Result<(), solid_pod_rs_forge::error::ForgeError> {
97/// let out = run_heavy(repo, &["grep", "-F", "-i", "-e", "needle", "HEAD"], 1 << 20).await?;
98/// // `git grep` exits 1 when nothing matches; that is not an error.
99/// assert!(out.code == Some(0) || out.code == Some(1));
100/// # Ok(()) }
101/// ```
102///
103/// # Errors
104///
105/// As [`run`], plus [`ForgeError::Busy`].
106pub async fn run_heavy(git_dir: &Path, args: &[&str], cap: usize) -> Result<GitOut, ForgeError> {
107    run_gated(heavy(), HEAVY_WAIT, git_dir, args, cap).await
108}
109
110async fn run_gated(
111    gate: &Semaphore,
112    wait: Duration,
113    git_dir: &Path,
114    args: &[&str],
115    cap: usize,
116) -> Result<GitOut, ForgeError> {
117    let _permit = tokio::time::timeout(wait, gate.acquire())
118        .await
119        .map_err(|_| ForgeError::Busy("too many search or archive requests, retry shortly".into()))?
120        .map_err(|_| ForgeError::Busy("search is shutting down".into()))?;
121    run(git_dir, args, cap).await
122}
123
124async fn run_unbounded(git_dir: &Path, args: &[&str], cap: usize) -> Result<GitOut, ForgeError> {
125    let mut cmd = solid_pod_rs_git::hardened_git_async();
126    cmd.args(args)
127        .current_dir(git_dir)
128        .env_remove("HOME")
129        .env_remove("XDG_CONFIG_HOME")
130        .stdin(Stdio::null())
131        .stdout(Stdio::piped())
132        .stderr(Stdio::piped())
133        .kill_on_drop(true);
134    let mut child = cmd.spawn().map_err(|e| {
135        if e.kind() == std::io::ErrorKind::NotFound {
136            ForgeError::Unsupported("git binary not found in PATH".into())
137        } else {
138            ForgeError::Io(e)
139        }
140    })?;
141    let mut out = child.stdout.take();
142    let mut err = child.stderr.take();
143    let stderr_task = tokio::spawn(async move {
144        let mut buf = Vec::new();
145        if let Some(e) = err.as_mut() {
146            let _ = e.take(64 * 1024).read_to_end(&mut buf).await;
147        }
148        String::from_utf8_lossy(&buf).into_owned()
149    });
150    let mut stdout = Vec::new();
151    let mut truncated = false;
152    if let Some(o) = out.as_mut() {
153        let n = o.take(cap as u64 + 1).read_to_end(&mut stdout).await?;
154        if n > cap {
155            stdout.truncate(cap);
156            truncated = true;
157            let _ = child.start_kill();
158        }
159    }
160    let status = child.wait().await?;
161    let stderr = stderr_task.await.unwrap_or_default();
162    Ok(GitOut {
163        code: status.code(),
164        stdout,
165        truncated,
166        stderr,
167    })
168}
169
170/// Run and return stdout as lossy UTF-8; any failure (non-zero exit,
171/// truncation) is [`ForgeError::NotFound`] when git complains about an
172/// unknown object, else [`ForgeError::Backend`].
173pub async fn text(git_dir: &Path, args: &[&str]) -> Result<String, ForgeError> {
174    let o = run(git_dir, args, 64 * 1024 * 1024).await?;
175    if o.ok() {
176        Ok(String::from_utf8_lossy(&o.stdout).into_owned())
177    } else {
178        Err(map_failure(args, &o))
179    }
180}
181
182/// Run and return raw stdout bytes with the same failure mapping as [`text`].
183pub async fn bytes(git_dir: &Path, args: &[&str], cap: usize) -> Result<Vec<u8>, ForgeError> {
184    let o = run(git_dir, args, cap).await?;
185    if o.ok() {
186        Ok(o.stdout)
187    } else {
188        Err(map_failure(args, &o))
189    }
190}
191
192fn map_failure(args: &[&str], o: &GitOut) -> ForgeError {
193    let s = &o.stderr;
194    if s.contains("unknown revision")
195        || s.contains("bad revision")
196        || s.contains("Not a valid object")
197        || s.contains("does not exist")
198        || s.contains("exists on disk, but not in")
199        || s.contains("bad object")
200        || s.contains("not a tree object")
201        || s.contains("invalid object name")
202    {
203        ForgeError::NotFound(s.trim().to_string())
204    } else if o.truncated {
205        ForgeError::BadRequest("output too large".into())
206    } else {
207        ForgeError::Backend(format!(
208            "git {}: {}",
209            args.first().copied().unwrap_or(""),
210            s.trim()
211        ))
212    }
213}
214
215#[cfg(test)]
216pub(crate) mod testutil {
217    //! Fixture helpers shared by the unit tests of the git-backed modules.
218
219    use std::path::{Path, PathBuf};
220
221    /// Run a plain git command for fixture set-up (panics on failure).
222    pub fn git(dir: &Path, args: &[&str]) -> String {
223        let out = std::process::Command::new("git")
224            .args([
225                "-c",
226                "user.name=Casey Coder",
227                "-c",
228                "user.email=casey@example.org",
229                "-c",
230                "init.defaultBranch=main",
231                "-c",
232                "protocol.file.allow=always",
233            ])
234            .args(args)
235            .current_dir(dir)
236            .env("GIT_CONFIG_NOSYSTEM", "1")
237            .env("GIT_CONFIG_GLOBAL", "/dev/null")
238            .env("GIT_TERMINAL_PROMPT", "0")
239            .output()
240            .expect("git runs");
241        assert!(
242            out.status.success(),
243            "git {args:?}: {}",
244            String::from_utf8_lossy(&out.stderr)
245        );
246        String::from_utf8_lossy(&out.stdout).trim().to_string()
247    }
248
249    /// The JSS test fixture: README with a `<script>` line, `src/main.js`
250    /// (two commits), `docs/notes.txt`, a binary and an `.html` file, tag
251    /// `v1.0`. Returns the bare repo at `<root>/repos/casey/demo.git` and the
252    /// second commit's sha.
253    pub fn demo_repo(root: &Path) -> (PathBuf, String) {
254        let work = root.join("work");
255        std::fs::create_dir_all(work.join("src")).unwrap();
256        std::fs::create_dir_all(work.join("docs")).unwrap();
257        std::fs::write(
258            work.join("README.md"),
259            "# Demo Project\n\nA demo with **bold**, `inline code`, and a [doc](docs/notes.txt).\n\n```js\nconsole.log('hi');\n```\n\n<script>alert(1)</script>\n",
260        )
261        .unwrap();
262        std::fs::write(
263            work.join("src/main.js"),
264            "function main() {\n  return 1;\n}\n",
265        )
266        .unwrap();
267        std::fs::write(work.join("docs/notes.txt"), "notes live in a subdir\n").unwrap();
268        std::fs::write(
269            work.join("blob.bin"),
270            [0u8, 1, 2, 3, 0, 0xff, 0xfe, 0, 0x89, 0x50],
271        )
272        .unwrap();
273        std::fs::write(work.join("evil.html"), "<script>alert(\"xss\")</script>\n").unwrap();
274        git(&work, &["init", "--quiet"]);
275        git(&work, &["add", "-A"]);
276        git(&work, &["commit", "--quiet", "-m", "initial import"]);
277        std::fs::write(
278            work.join("src/main.js"),
279            "function main() {\n  return 2;\n}\n",
280        )
281        .unwrap();
282        git(&work, &["commit", "--quiet", "-am", "tweak main"]);
283        git(&work, &["tag", "v1.0"]);
284        let sha2 = git(&work, &["rev-parse", "HEAD"]);
285        let bare = root.join("repos/casey/demo.git");
286        std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
287        git(
288            root,
289            &[
290                "clone",
291                "--bare",
292                "--quiet",
293                work.to_str().unwrap(),
294                bare.to_str().unwrap(),
295            ],
296        );
297        (bare, sha2)
298    }
299}
300
301#[cfg(test)]
302mod timeout_tests {
303    use super::*;
304
305    /// A git process that outlives its budget is abandoned with a timeout
306    /// error instead of holding the request (and a CPU) indefinitely.
307    #[tokio::test]
308    async fn slow_git_is_cut_off_by_the_wall_clock() {
309        let td = tempfile::tempdir().unwrap();
310        let started = std::time::Instant::now();
311        let r = run_within(
312            td.path(),
313            &["-c", "alias.slow=!sleep 20", "slow"],
314            1024,
315            Duration::from_millis(200),
316        )
317        .await;
318        match r {
319            Err(ForgeError::Backend(m)) => assert!(m.contains("timed out"), "{m}"),
320            other => panic!("expected a timeout, got {other:?}"),
321        }
322        assert!(started.elapsed() < Duration::from_secs(10));
323    }
324
325    #[tokio::test]
326    async fn gated_runs_fail_busy_when_every_permit_is_taken() {
327        let gate = Semaphore::new(1);
328        let _held = gate.acquire().await.unwrap();
329        let td = tempfile::tempdir().unwrap();
330        let r = run_gated(
331            &gate,
332            Duration::from_millis(100),
333            td.path(),
334            &["--version"],
335            1024,
336        )
337        .await;
338        assert!(matches!(r, Err(ForgeError::Busy(_))), "{r:?}");
339    }
340}