From bcd01e3a8cea6d1467465c484ebb4c1c7b9ebddc Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Mon, 9 Nov 2026 13:32:32 +0200 Subject: [PATCH 1/2] test(forge): prove --git-memory-limit stops a push on Linux (#316) A server started with a 64 MiB RLIMIT_DATA refuses a push of a 128 MiB blob (git inflates it in memory to hash it) with git's out-of-memory error and no ref change; the same push lands under the default limit. Linux only, since macOS does not enforce RLIMIT_DATA. --- crates/forge/tests/e2e_git.rs | 35 +++++++++++++++++++++++++++++++++++ docs/THREAT-MODEL.md | 3 ++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/forge/tests/e2e_git.rs b/crates/forge/tests/e2e_git.rs index 85d1886..d56f0cb 100644 --- a/crates/forge/tests/e2e_git.rs +++ b/crates/forge/tests/e2e_git.rs @@ -947,6 +947,41 @@ fn a_push_over_the_size_cap_fails_and_changes_nothing() { wait_for("cleanup", || s.app.life.registry.live() == 0); } +/// `--git-memory-limit` is `RLIMIT_DATA`, which only Linux enforces usefully. +#[cfg(target_os = "linux")] +#[test] +fn the_git_memory_limit_stops_a_push_whose_object_needs_more() { + // 128 MiB of zeros: a tiny pack, but git inflates the blob in memory to hash it (it is + // under core.bigFileThreshold), so the receiving git needs well over 64 MiB. + let push_big = |s: &Srv, tag: &str| { + let c = Client::new(tag); + let url = repo(s, "smart"); + let auth = token(&s.admin); + let work = seeded(&c, &url, &auth); // a small push works under the limit + std::fs::write(work.join("zeros.bin"), vec![0u8; 128 << 20]).unwrap(); + c.ok(&work, None, &["add", "."]); + c.ok(&work, None, &["commit", "-q", "-m", "zeros"]); + let (ok, text) = c.git_in(&work, Some(&auth), &["push", "-q", &url, "main"]); + let tip = c.ok(&work, Some(&auth), &["ls-remote", &url, "refs/heads/main"]); + let parent = c.ok(&work, None, &["rev-parse", "HEAD~1"]); + wait_for("cleanup", || s.app.life.registry.live() == 0); + (ok, text, tip.starts_with(parent.trim())) + }; + let limited = Srv::start_transport(scl_forge::cgi::Cfg { + git_memory: 64 << 20, + ..Default::default() + }); + let (ok, text, unchanged) = push_big(&limited, "memlimit"); + assert!(!ok && unchanged, "the limit must stop the push: {text}"); + assert!( + text.contains("Out of memory"), + "stopped by the limit: {text}" + ); + // Control: the same push under the default limit lands. + let (ok, text, unchanged) = push_big(&Srv::start(), "memlimit-control"); + assert!(ok && !unchanged, "{text}"); +} + #[test] fn a_full_disk_reserve_refuses_pushes_with_507_but_not_reads() { let s = Srv::start_transport(scl_forge::cgi::Cfg { diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index b821600..a9be2cd 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -909,7 +909,8 @@ unless said otherwise). The child runs with `RLIMIT_CPU` (900 s), `RLIMIT_FSIZE`, `RLIMIT_CORE=0` and, on Linux, `RLIMIT_DATA` (`--git-memory-limit`, 8 GiB per child, so the host-wide bound is `--max-transports` times that; anonymous memory, which stops a pack declaring a huge compressed object from growing one child without limit; - not enforced on macOS and untested in CI: a cgroup limit on the service is the real bound), a wall-clock limit + not enforced on macOS, where a cgroup limit on the service is the real bound; on Linux a push whose object needs more + fails with git's out-of-memory error and moves nothing, `the_git_memory_limit_stops_a_push_whose_object_needs_more`), a wall-clock limit (`--git-timeout`) and directional stall deadlines (`--git-idle-timeout`: no request bytes while git waits for them, or no room in the response because the client does not read), all of which end the transport (`slots_are_bounded_then_released_when_the_client_disconnects`, `a_stalled_upload_is_cut_off_by_the_idle_deadline`, From 9fe12f342770919ff56eba7e1c5a422fafa165a4 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Mon, 9 Nov 2026 13:35:05 +0200 Subject: [PATCH 2/2] docs(forge): keep the cgroup advice on the Linux side --- docs/THREAT-MODEL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index a9be2cd..e8d5448 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -909,8 +909,8 @@ unless said otherwise). The child runs with `RLIMIT_CPU` (900 s), `RLIMIT_FSIZE`, `RLIMIT_CORE=0` and, on Linux, `RLIMIT_DATA` (`--git-memory-limit`, 8 GiB per child, so the host-wide bound is `--max-transports` times that; anonymous memory, which stops a pack declaring a huge compressed object from growing one child without limit; - not enforced on macOS, where a cgroup limit on the service is the real bound; on Linux a push whose object needs more - fails with git's out-of-memory error and moves nothing, `the_git_memory_limit_stops_a_push_whose_object_needs_more`), a wall-clock limit + not enforced on macOS; on Linux a push whose object needs more fails with git's out-of-memory error and moves nothing, + `the_git_memory_limit_stops_a_push_whose_object_needs_more`, and a cgroup limit on the service is the host-wide bound), a wall-clock limit (`--git-timeout`) and directional stall deadlines (`--git-idle-timeout`: no request bytes while git waits for them, or no room in the response because the client does not read), all of which end the transport (`slots_are_bounded_then_released_when_the_client_disconnects`, `a_stalled_upload_is_cut_off_by_the_idle_deadline`,