Skip to main content

solid_pod_rs_git/
push_guard.rs

1//! Pre-receive path guard for `git push`.
2//!
3//! The pod repository uses `receive.denyCurrentBranch=updateInstead`, so every
4//! path a push adds, changes or deletes on the checked-out branch is written
5//! straight into the pod working tree, bypassing the REST/MCP path rules. This
6//! module inspects a `git-receive-pack` request **before** the CGI sees it and
7//! refuses the whole push (nothing is unpacked, no ref moves, nothing is
8//! checked out) if any path it changes is refused by the embedder's classifier.
9//!
10//! # What is checked
11//!
12//! * the pushed pack is indexed into a throw-away quarantine object directory
13//!   (backed by the real object store as an alternate), so nothing reaches the
14//!   repository unless the CGI later accepts the same bytes;
15//! * every commit the push introduces (`rev-list <new> --not --all`) is diffed
16//!   against each of its parents (renames are split into delete + add, so both
17//!   sides are seen), covering delete-then-recreate sequences and commits that
18//!   are not on the tip;
19//! * for the ref that `HEAD` points at (the only ref `updateInstead` checks
20//!   out) the new tip is additionally diffed against the current `HEAD`, which
21//!   covers objects pushed earlier to another ref and then promoted;
22//! * every changed path (added, modified, deleted, type-changed) goes through
23//!   the embedder-supplied [`PushPathGuard`]; paths that are not UTF-8, are
24//!   absolute, or contain `.`, `..`, empty, backslash or NUL components are
25//!   refused outright;
26//! * a new tree entry of mode `120000` (symlink) or `160000` (submodule) is
27//!   refused outright.
28//!
29//! # Limits
30//!
31//! The work is bounded: a request body above [`MAX_PUSH_BODY_BYTES`], more than
32//! [`MAX_PUSH_COMMITS`] new commits, more than [`MAX_PUSH_PATHS`] changed
33//! paths, or a check that exceeds [`PUSH_CHECK_TIMEOUT`] is refused rather than
34//! left to hang. Requests are serialised per repository so `HEAD` cannot move
35//! between the check and the CGI run.
36
37use 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
50/// Classifier for pushed paths. Receives a repository-relative path with a
51/// leading `/` (for example `/.well-known/token/x.json`) and returns `true`
52/// when a push must not change it.
53pub type PushPathGuard = Arc<dyn Fn(&str) -> bool + Send + Sync>;
54
55/// Largest `git-receive-pack` request body that is inspected (64 MiB). Larger
56/// pushes are refused.
57pub const MAX_PUSH_BODY_BYTES: usize = 64 * 1024 * 1024;
58/// Most new commits one push may introduce.
59pub const MAX_PUSH_COMMITS: usize = 5_000;
60/// Most changed paths one push may carry, summed over all inspected diffs.
61pub const MAX_PUSH_PATHS: usize = 20_000;
62/// Most ref updates one push may carry.
63pub const MAX_PUSH_COMMANDS: usize = 256;
64/// Wall-clock budget for the whole inspection.
65pub const PUSH_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
66const MAX_GIT_OUTPUT: usize = 16 * 1024 * 1024;
67
68/// A refused push: the client-facing reason.
69#[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
97/// Read one pkt-line at `pos`; `Ok(None)` is a flush packet.
98fn 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
165/// Reason text safe to put in a pkt-line: printable ASCII only, bounded.
166fn 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
179/// Builtin path rules, then the embedder's classifier. Returns the reason.
180fn 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    // Judge the lower-cased spelling too: the check must not depend on the
197    // case sensitivity of whatever filesystem later receives the file.
198    if guard(&rooted) || guard(&rooted.to_ascii_lowercase()) {
199        return Err(refuse(path));
200    }
201    Ok(())
202}
203
204/// Scan `git diff-tree -z --raw` output; `count` accumulates changed paths.
205fn 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; // commit id header
210        }
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    /// Run `git <args>` against the repository with the quarantine layered on
260    /// top. Returns `(success, stdout)`.
261    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
326/// Inspect a `git-receive-pack` request body. `Ok(())` means every path the
327/// push would change is acceptable to `guard`.
328pub(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(()); // pure deletions change no tree
355    }
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    // Every commit the push introduces, diffed against each parent.
379    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    // The checked-out branch: diff the new tip against the current HEAD.
423    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            // Unborn branch: everything in the new tip is new to the tree.
433            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
477/// The `git-receive-pack` result a client understands as "rejected": an
478/// `unpack ok` line and an `ng` line per ref update, side-band wrapped when the
479/// client asked for it. Falls back to a plain 403 when the client did not
480/// request `report-status`.
481pub(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
534/// One async lock per git directory, so `HEAD` cannot move between the check
535/// and the CGI run.
536pub(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}