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