1use std::collections::HashMap;
38use std::path::{Path, PathBuf};
39use std::process::Stdio;
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::sync::{Arc, Mutex, OnceLock};
42
43use bytes::Bytes;
44use tokio::io::AsyncWriteExt;
45use tokio::process::Command;
46use tokio::time::Duration;
47
48use crate::service::{git_cors_header_pairs, GitResponse};
49
50pub type PushPathGuard = Arc<dyn Fn(&str) -> bool + Send + Sync>;
54
55pub const MAX_PUSH_BODY_BYTES: usize = 64 * 1024 * 1024;
58pub const MAX_PUSH_COMMITS: usize = 5_000;
60pub const MAX_PUSH_PATHS: usize = 20_000;
62pub const MAX_PUSH_COMMANDS: usize = 256;
64pub const PUSH_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
66const MAX_GIT_OUTPUT: usize = 16 * 1024 * 1024;
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct PushRefusal(pub String);
71
72impl PushRefusal {
73 fn new(msg: impl Into<String>) -> Self {
74 Self(msg.into())
75 }
76}
77
78struct RefUpdate {
79 new: String,
80 refname: String,
81}
82
83struct Parsed {
84 commands: Vec<RefUpdate>,
85 caps: Vec<String>,
86 pack_start: usize,
87}
88
89fn is_oid(s: &str) -> bool {
90 (s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit())
91}
92
93fn is_zero(s: &str) -> bool {
94 s.bytes().all(|b| b == b'0')
95}
96
97fn pkt_line(body: &[u8], pos: &mut usize) -> Result<Option<Vec<u8>>, PushRefusal> {
99 let bad = || PushRefusal::new("malformed receive-pack request");
100 let hdr = body.get(*pos..*pos + 4).ok_or_else(bad)?;
101 let len = usize::from_str_radix(std::str::from_utf8(hdr).map_err(|_| bad())?, 16)
102 .map_err(|_| bad())?;
103 if len == 0 {
104 *pos += 4;
105 return Ok(None);
106 }
107 if len < 4 {
108 return Err(bad());
109 }
110 let data = body.get(*pos + 4..*pos + len).ok_or_else(bad)?.to_vec();
111 *pos += len;
112 Ok(Some(data))
113}
114
115fn parse_request(body: &[u8]) -> Result<Parsed, PushRefusal> {
116 let bad = || PushRefusal::new("malformed receive-pack request");
117 let mut pos = 0;
118 let mut commands = Vec::new();
119 let mut caps: Vec<String> = Vec::new();
120 while let Some(line) = pkt_line(body, &mut pos)? {
121 let line = String::from_utf8(line).map_err(|_| bad())?;
122 let (line, line_caps) = match line.split_once('\0') {
123 Some((l, c)) => (l, Some(c)),
124 None => (line.as_str(), None),
125 };
126 if let Some(c) = line_caps {
127 if caps.is_empty() {
128 caps = c.split_whitespace().map(str::to_owned).collect();
129 }
130 }
131 let line = line.trim_end_matches('\n');
132 if line.starts_with("shallow ") {
133 continue;
134 }
135 if line.starts_with("push-cert") {
136 return Err(PushRefusal::new("signed pushes are not supported"));
137 }
138 let mut it = line.split(' ');
139 let (Some(old), Some(new), Some(refname), None) =
140 (it.next(), it.next(), it.next(), it.next())
141 else {
142 return Err(bad());
143 };
144 if !is_oid(old) || !is_oid(new) || old.len() != new.len() || !refname.starts_with("refs/") {
145 return Err(bad());
146 }
147 commands.push(RefUpdate {
148 new: new.to_owned(),
149 refname: refname.to_owned(),
150 });
151 if commands.len() > MAX_PUSH_COMMANDS {
152 return Err(PushRefusal::new("push carries too many ref updates"));
153 }
154 }
155 if caps.iter().any(|c| c == "push-options") {
156 while pkt_line(body, &mut pos)?.is_some() {}
157 }
158 Ok(Parsed {
159 commands,
160 caps,
161 pack_start: pos,
162 })
163}
164
165fn sanitise(s: &str) -> String {
167 s.chars()
168 .map(|c| {
169 if c.is_ascii_graphic() || c == ' ' {
170 c
171 } else {
172 '?'
173 }
174 })
175 .take(160)
176 .collect()
177}
178
179fn check_path(raw: &[u8], guard: &PushPathGuard) -> Result<(), PushRefusal> {
181 let refuse =
182 |p: &str| PushRefusal::new(format!("path is reserved by the pod: {}", sanitise(p)));
183 let Ok(path) = std::str::from_utf8(raw) else {
184 return Err(PushRefusal::new("path is not valid UTF-8"));
185 };
186 if path.is_empty()
187 || path.starts_with('/')
188 || path.contains(['\\', '\0'])
189 || path
190 .split('/')
191 .any(|s| s.is_empty() || s == "." || s == "..")
192 {
193 return Err(refuse(path));
194 }
195 let rooted = format!("/{path}");
196 if guard(&rooted) || guard(&rooted.to_ascii_lowercase()) {
199 return Err(refuse(path));
200 }
201 Ok(())
202}
203
204fn scan_raw(out: &[u8], guard: &PushPathGuard, count: &mut usize) -> Result<(), PushRefusal> {
206 let mut toks = out.split(|b| *b == 0);
207 while let Some(tok) = toks.next() {
208 if tok.first() != Some(&b':') {
209 continue; }
211 let meta = std::str::from_utf8(tok).map_err(|_| PushRefusal::new("unreadable diff"))?;
212 let mut f = meta[1..].split(' ');
213 let (_old_mode, new_mode) = (f.next(), f.next());
214 let path = toks
215 .next()
216 .ok_or_else(|| PushRefusal::new("unreadable diff"))?;
217 *count += 1;
218 if *count > MAX_PUSH_PATHS {
219 return Err(PushRefusal::new("push changes too many paths"));
220 }
221 if matches!(new_mode, Some("120000" | "160000")) {
222 return Err(PushRefusal::new(
223 "symbolic links and submodules cannot be pushed",
224 ));
225 }
226 check_path(path, guard)?;
227 }
228 Ok(())
229}
230
231async fn read_capped<R: tokio::io::AsyncRead + Unpin>(
232 r: &mut R,
233 cap: usize,
234) -> Result<Vec<u8>, PushRefusal> {
235 use tokio::io::AsyncReadExt;
236 let mut out = Vec::new();
237 let mut chunk = [0u8; 16 * 1024];
238 loop {
239 let n = r
240 .read(&mut chunk)
241 .await
242 .map_err(|_| PushRefusal::new("push inspection failed"))?;
243 if n == 0 {
244 return Ok(out);
245 }
246 if out.len() + n > cap {
247 return Err(PushRefusal::new("push is too large to inspect"));
248 }
249 out.extend_from_slice(&chunk[..n]);
250 }
251}
252
253struct Repo<'a> {
254 git_dir: &'a Path,
255 quarantine: &'a Path,
256}
257
258impl Repo<'_> {
259 async fn git(
262 &self,
263 args: &[&str],
264 stdin: Option<Bytes>,
265 ) -> Result<(bool, Vec<u8>), PushRefusal> {
266 let fail = |_| PushRefusal::new("push inspection failed");
267 let mut cmd = Command::new("git");
268 cmd.args(args)
269 .env_clear()
270 .env("GIT_DIR", self.git_dir)
271 .env("GIT_OBJECT_DIRECTORY", self.quarantine.join("objects"))
272 .env(
273 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
274 self.git_dir.join("objects"),
275 )
276 .env("GIT_CONFIG_NOSYSTEM", "1")
277 .env("GIT_CONFIG_GLOBAL", "/dev/null")
278 .env("GIT_TERMINAL_PROMPT", "0")
279 .stdin(if stdin.is_some() {
280 Stdio::piped()
281 } else {
282 Stdio::null()
283 })
284 .stdout(Stdio::piped())
285 .stderr(Stdio::null())
286 .kill_on_drop(true);
287 if let Ok(path) = std::env::var("PATH") {
288 cmd.env("PATH", path);
289 }
290 let mut child = cmd.spawn().map_err(fail)?;
291 if let Some(data) = stdin {
292 if let Some(mut si) = child.stdin.take() {
293 tokio::spawn(async move {
294 let _ = si.write_all(&data).await;
295 });
296 }
297 }
298 let mut stdout = child.stdout.take().expect("stdout piped");
299 let out = read_capped(&mut stdout, MAX_GIT_OUTPUT).await?;
300 let status = child.wait().await.map_err(fail)?;
301 Ok((status.success(), out))
302 }
303}
304
305struct Quarantine(PathBuf);
306
307impl Quarantine {
308 fn create(git_dir: &Path) -> std::io::Result<Self> {
309 static SEQ: AtomicU64 = AtomicU64::new(0);
310 let dir = git_dir.join(format!(
311 "solid-pod-quarantine-{}-{}",
312 std::process::id(),
313 SEQ.fetch_add(1, Ordering::Relaxed)
314 ));
315 std::fs::create_dir_all(dir.join("objects").join("pack"))?;
316 Ok(Self(dir))
317 }
318}
319
320impl Drop for Quarantine {
321 fn drop(&mut self) {
322 let _ = std::fs::remove_dir_all(&self.0);
323 }
324}
325
326pub(crate) async fn check_push(
329 git_dir: &Path,
330 body: &Bytes,
331 guard: &PushPathGuard,
332) -> Result<(), PushRefusal> {
333 match tokio::time::timeout(PUSH_CHECK_TIMEOUT, check_push_inner(git_dir, body, guard)).await {
334 Ok(r) => r,
335 Err(_) => Err(PushRefusal::new("push inspection timed out")),
336 }
337}
338
339async fn check_push_inner(
340 git_dir: &Path,
341 body: &Bytes,
342 guard: &PushPathGuard,
343) -> Result<(), PushRefusal> {
344 if body.len() > MAX_PUSH_BODY_BYTES {
345 return Err(PushRefusal::new("push is too large to inspect"));
346 }
347 let parsed = parse_request(body)?;
348 let news: Vec<&RefUpdate> = parsed
349 .commands
350 .iter()
351 .filter(|c| !is_zero(&c.new))
352 .collect();
353 if news.is_empty() {
354 return Ok(()); }
356 let quarantine =
357 Quarantine::create(git_dir).map_err(|_| PushRefusal::new("push inspection failed"))?;
358 let repo = Repo {
359 git_dir,
360 quarantine: &quarantine.0,
361 };
362
363 let pack = &body[parsed.pack_start..];
364 if !pack.is_empty() {
365 let (ok, _) = repo
366 .git(
367 &["index-pack", "--stdin", "--fix-thin"],
368 Some(Bytes::copy_from_slice(pack)),
369 )
370 .await?;
371 if !ok {
372 return Err(PushRefusal::new("malformed pack"));
373 }
374 }
375
376 let mut count = 0usize;
377
378 let mut args = vec![
380 "rev-list".to_owned(),
381 format!("--max-count={}", MAX_PUSH_COMMITS + 1),
382 ];
383 args.extend(news.iter().map(|c| c.new.clone()));
384 args.extend(["--not".to_owned(), "--all".to_owned()]);
385 let argv: Vec<&str> = args.iter().map(String::as_str).collect();
386 let (ok, commits) = repo.git(&argv, None).await?;
387 if !ok {
388 return Err(PushRefusal::new(
389 "push references objects that are not commits",
390 ));
391 }
392 let n_commits = commits
393 .split(|b| *b == b'\n')
394 .filter(|l| !l.is_empty())
395 .count();
396 if n_commits > MAX_PUSH_COMMITS {
397 return Err(PushRefusal::new("push carries too many commits"));
398 }
399 if n_commits > 0 {
400 let (ok, raw) = repo
401 .git(
402 &[
403 "diff-tree",
404 "--stdin",
405 "-r",
406 "-z",
407 "--raw",
408 "--no-renames",
409 "-m",
410 "--root",
411 "--no-abbrev",
412 ],
413 Some(Bytes::from(commits)),
414 )
415 .await?;
416 if !ok {
417 return Err(PushRefusal::new("push inspection failed"));
418 }
419 scan_raw(&raw, guard, &mut count)?;
420 }
421
422 let (ok, head_ref) = repo.git(&["symbolic-ref", "-q", "HEAD"], None).await?;
424 if ok {
425 let head_ref = String::from_utf8_lossy(&head_ref).trim().to_owned();
426 let (has_head, head) = repo
427 .git(&["rev-parse", "-q", "--verify", "HEAD^{commit}"], None)
428 .await?;
429 let base = if has_head {
430 String::from_utf8_lossy(&head).trim().to_owned()
431 } else {
432 let (ok, empty) = repo
434 .git(
435 &["hash-object", "-t", "tree", "--stdin"],
436 Some(Bytes::new()),
437 )
438 .await?;
439 if !ok {
440 return Err(PushRefusal::new("push inspection failed"));
441 }
442 String::from_utf8_lossy(&empty).trim().to_owned()
443 };
444 if !is_oid(&base) {
445 return Err(PushRefusal::new("push inspection failed"));
446 }
447 for c in news.iter().filter(|c| c.refname == head_ref) {
448 let (ok, raw) = repo
449 .git(
450 &[
451 "diff-tree",
452 "-r",
453 "-z",
454 "--raw",
455 "--no-renames",
456 "--no-abbrev",
457 &base,
458 &c.new,
459 ],
460 None,
461 )
462 .await?;
463 if !ok {
464 return Err(PushRefusal::new("push inspection failed"));
465 }
466 scan_raw(&raw, guard, &mut count)?;
467 }
468 }
469 Ok(())
470}
471
472fn pkt(out: &mut Vec<u8>, payload: &[u8]) {
473 out.extend_from_slice(format!("{:04x}", payload.len() + 4).as_bytes());
474 out.extend_from_slice(payload);
475}
476
477pub(crate) fn refusal_response(
482 body: &[u8],
483 reason: &PushRefusal,
484) -> Result<GitResponse, PushRefusal> {
485 let Ok(parsed) = parse_request(body) else {
486 return Err(reason.clone());
487 };
488 let caps = &parsed.caps;
489 if !caps
490 .iter()
491 .any(|c| c == "report-status" || c == "report-status-v2")
492 {
493 return Err(reason.clone());
494 }
495 let refs: Vec<&str> = parsed.commands.iter().map(|c| c.refname.as_str()).collect();
496 let why = sanitise(&format!("push refused: {}", reason.0));
497 let mut report = Vec::new();
498 pkt(&mut report, b"unpack ok\n");
499 for r in &refs {
500 pkt(
501 &mut report,
502 format!("ng {} {why}\n", sanitise(r)).as_bytes(),
503 );
504 }
505 report.extend_from_slice(b"0000");
506 let payload = if caps
507 .iter()
508 .any(|c| c == "side-band-64k" || c == "side-band")
509 {
510 let mut wrapped = Vec::new();
511 let mut band = vec![1u8];
512 band.extend_from_slice(&report);
513 pkt(&mut wrapped, &band);
514 wrapped.extend_from_slice(b"0000");
515 wrapped
516 } else {
517 report
518 };
519 let mut headers = vec![
520 (
521 "Content-Type".to_owned(),
522 "application/x-git-receive-pack-result".to_owned(),
523 ),
524 ("Cache-Control".to_owned(), "no-cache".to_owned()),
525 ];
526 headers.extend(git_cors_header_pairs());
527 Ok(GitResponse {
528 status: 200,
529 headers,
530 body: Bytes::from(payload),
531 })
532}
533
534pub(crate) fn repo_lock(git_dir: &Path) -> Arc<tokio::sync::Mutex<()>> {
537 static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>> = OnceLock::new();
538 let mut map = LOCKS
539 .get_or_init(|| Mutex::new(HashMap::new()))
540 .lock()
541 .unwrap_or_else(|e| e.into_inner());
542 map.entry(git_dir.to_path_buf()).or_default().clone()
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 #[test]
550 fn builtin_path_rules_hold_even_when_the_guard_allows_everything() {
551 let allow: PushPathGuard = Arc::new(|_| false);
552 for bad in [
553 &b"a/../b"[..],
554 b"..",
555 b"./a",
556 b"a//b",
557 b"/abs",
558 b"a\\b",
559 b"a\0b",
560 b"",
561 b"caf\xe9",
562 ] {
563 assert!(check_path(bad, &allow).is_err(), "{bad:?}");
564 }
565 assert!(check_path(b"docs/a.txt", &allow).is_ok());
566 }
567
568 #[test]
569 fn the_guard_sees_rooted_and_lower_cased_spellings() {
570 let seen = Arc::new(Mutex::new(Vec::new()));
571 let s = seen.clone();
572 let g: PushPathGuard = Arc::new(move |p| {
573 s.lock().unwrap().push(p.to_owned());
574 false
575 });
576 check_path(b"A/B", &g).unwrap();
577 assert_eq!(*seen.lock().unwrap(), ["/A/B", "/a/b"]);
578 }
579
580 #[test]
581 fn request_parser_reads_commands_caps_and_pack_offset() {
582 let z = "0".repeat(40);
583 let n = "1".repeat(40);
584 let line = format!("{z} {n} refs/heads/main\0 report-status side-band-64k\n");
585 let mut body = format!("{:04x}{line}0000", line.len() + 4).into_bytes();
586 body.extend(b"PACK");
587 let p = parse_request(&body).unwrap();
588 assert_eq!(p.commands.len(), 1);
589 assert_eq!(p.commands[0].refname, "refs/heads/main");
590 assert_eq!(p.caps, ["report-status", "side-band-64k"]);
591 assert_eq!(&body[p.pack_start..], b"PACK");
592 assert!(parse_request(b"0004").is_err());
593 assert!(parse_request(b"zzzz").is_err());
594 }
595}