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::time::Duration;
46
47use crate::service::{git_cors_header_pairs, GitResponse};
48
49/// Classifier for pushed paths. Receives a repository-relative path with a
50/// leading `/` (for example `/.well-known/token/x.json`) and returns `true`
51/// when a push must not change it.
52pub type PushPathGuard = Arc<dyn Fn(&str) -> bool + Send + Sync>;
53
54/// Largest `git-receive-pack` request body that is inspected (64 MiB). Larger
55/// pushes are refused.
56pub const MAX_PUSH_BODY_BYTES: usize = 64 * 1024 * 1024;
57/// Most new commits one push may introduce.
58pub const MAX_PUSH_COMMITS: usize = 5_000;
59/// Most changed paths one push may carry, summed over all inspected diffs.
60pub const MAX_PUSH_PATHS: usize = 20_000;
61/// Most ref updates one push may carry.
62pub const MAX_PUSH_COMMANDS: usize = 256;
63/// Wall-clock budget for the whole inspection.
64pub const PUSH_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
65const MAX_GIT_OUTPUT: usize = 16 * 1024 * 1024;
66
67/// A refused push: the client-facing reason.
68#[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
96/// Read one pkt-line at `pos`; `Ok(None)` is a flush packet.
97fn 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
164/// Reason text safe to put in a pkt-line: printable ASCII only, bounded.
165fn 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
178/// Builtin path rules, then the embedder's classifier. Returns the reason.
179fn 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    // Judge the lower-cased spelling too: the check must not depend on the
196    // case sensitivity of whatever filesystem later receives the file.
197    if guard(&rooted) || guard(&rooted.to_ascii_lowercase()) {
198        return Err(refuse(path));
199    }
200    Ok(())
201}
202
203/// Scan `git diff-tree -z --raw` output; `count` accumulates changed paths.
204fn 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; // commit id header
209        }
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    /// Run `git <args>` against the repository with the quarantine layered on
259    /// top. Returns `(success, stdout)`.
260    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
325/// Inspect a `git-receive-pack` request body. `Ok(())` means every path the
326/// push would change is acceptable to `guard`.
327pub(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(()); // pure deletions change no tree
354    }
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    // Every commit the push introduces, diffed against each parent.
378    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    // The checked-out branch: diff the new tip against the current HEAD.
422    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            // Unborn branch: everything in the new tip is new to the tree.
432            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
476/// The `git-receive-pack` result a client understands as "rejected": an
477/// `unpack ok` line and an `ng` line per ref update, side-band wrapped when the
478/// client asked for it. Falls back to a plain 403 when the client did not
479/// request `report-status`.
480pub(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
533/// One async lock per git directory, so `HEAD` cannot move between the check
534/// and the CGI run.
535pub(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}