1use 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#[derive(Debug)]
22pub struct GitOut {
23 pub code: Option<i32>,
25 pub stdout: Vec<u8>,
27 pub truncated: bool,
29 pub stderr: String,
31}
32
33impl GitOut {
34 #[must_use]
36 pub fn ok(&self) -> bool {
37 self.code == Some(0) && !self.truncated
38 }
39}
40
41pub 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
52pub const GIT_TIMEOUT: Duration = Duration::from_secs(30);
56
57pub 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
76const HEAVY_PERMITS: usize = 4;
78
79const 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
87pub 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
170pub 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
182pub 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 use std::path::{Path, PathBuf};
220
221 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 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 #[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}