receive-pack has no quarantine and no fsck — the position, and what it costs
Class:
reasoning. A written position, not a plan. It exists because the thing it records was not written down anywhere: gunnar takes a pushed pack into the object database before the policy gate runs, so a refused push still leaves its objects behind, and until 2026-08-05 that was not even a known position. Shipping a forge that accepts unvalidated object bytes is a defensible choice. Shipping one where nobody wrote down that it does is not.Measured on oden, 2026-08-05, against stock git 2.53.0.
1. What stock git does
git receive-pack unpacks a push into a temporary object directory — objects/incoming-XXXXXX, exported to hooks as GIT_QUARANTINE_PATH — and migrates it into the real ODB only after receive.fsckObjects and the pre-receive hook have both passed. A rejected push leaves nothing: the quarantine directory is removed. git index-pack --strict (which is what receive.fsckObjects=true turns on) runs fsck over every object in the pack as it indexes, so a malformed tree or commit never reaches the store either.
Two separate mechanisms, often conflated:
- quarantine — where the objects land, and whether a refusal can undo it.
- fsck — whether the bytes are well-formed at all.
gunnar has neither.
2. What gunnar does, measured
crates/gunnar-wire/src/receive_pack.rs states the order in its own header:
advertise refs+caps → command list → push-options → PACK → index →
connectivity → policy gate → ref transaction → report
ingest.ingest(&mut counting) runs at step 4 and the gate at step 6. The objects are in the ObjectStore — and, under FsStore, in a .pack on disk — before any gate has an opinion.
The measurement, not the inference: crates/gunnar-cli/tests/push_facts.rs::a_refused_push_leaves_its_objects_in_the_object_database starts a real server, configures a policy that refuses, pushes with stock git over real SSH, and then reads the server's object database:
| push | refused, ! [remote rejected] |
refs/heads/main on the server |
does not exist — ls-remote is empty |
| objects from the refused push found in the ODB | 3 of 3 |
| pack bytes on disk after the refusal | 7 781 |
So the gate works and the reference does not move. The bytes stay.
That test asserts the current behaviour deliberately. The day somebody implements quarantine it goes red and names this document — a position nobody can see change is not a position.
3. Honest sizing
This is a disk-space and integrity problem, not a disclosure one.
WantPolicy::ReachableFromRefsis the default (crates/gunnar-wire/src/upload_pack.rs), so objects left behind by a refused push are not fetchable: no reference points at them, so nowantfor them is answered. A client cannot push a blob past the gate and then read it back.- Connectivity is verified before any reference moves (
verify_connectivity,receive_pack.rs), which is the harder half of the problem and the one that actually corrupts a repository when it is missing. - What remains: a client with write access can consume disk by pushing packs that are then refused, and a malformed object is storable — gunnar's indexer computes each object's id from the bytes it decodes, which makes the id honest but says nothing about whether a tree parses.
The one ceiling that is in front of this is a per-object one, not a per-push one: IndexOptions::alloc_limit_bytes defaults to 1 GiB (crates/gunnar-pack/src/read.rs) and the server's ingest takes the default (IndexOptions::new(self.hash_kind) in server/transport.rs), so a single object that inflates past 1 GiB is refused by the indexer rather than by a gate. That bounds one object; it bounds neither the pack nor the total.
LimitPolicy::max_pack_bytes is enforced by a gate, so an oversized pack is read, counted and stored, and then refused for being too big. crates/gunnar-policy/src/gates/limits.rs already says so in its own header — "this gate rejects before the references are applied … not before the bytes arrive" — and that note was correct and was the only place any of this was written down.
4. The position
gunnar 1.1 accepts a pushed pack into the object database before the policy gate runs, does not fsck incoming objects, and does not remove the objects of a refused push. This is recorded, not fixed, and it is not a release blocker.
Why it is defensible for this release:
- the failure it enables needs an authenticated principal with write access to the repository — the ACL and the SSH publickey layer are in front of it;
- nothing left behind is reachable, so nothing is served;
- the half that would corrupt a repository — connectivity — is checked;
- the alternative on release day is a storage-seam change (below) rushed.
Why it is not left silent: a forge operator reading LimitPolicy reasonably concludes that a byte ceiling protects the disk. It protects the repository, not the disk, and now something says so out loud.
5. What implementing it would take
Not large in the gate, large in the seam. gunnar-wire deliberately does not decide where a pack goes — spec §0 requires it to work with no filesystem, so the caller supplies a PackIngest and the only contract is "after ingest returns, the objects must be readable from the ObjectStore". A quarantine therefore cannot live in gunnar-wire; it has to be a two-phase ingest across that seam:
trait PackIngest {
fn ingest(&self, pack: &mut dyn BufRead) -> Result<PackReceipt>;
fn commit(&self, receipt: PackReceipt) -> Result<()>; // new
fn discard(&self, receipt: PackReceipt); // new
}
with ReceivePack::serve calling discard on the path that today only writes a RefStatus::Rejected. Then each store implements it:
FsStore— index intoobjects/incoming-<id>/,rename(2)the.packand.idxintoobjects/pack/on commit,remove_dir_allon discard. The rename is atomic within a filesystem, which is why git does it this way.- the appliance / znippy cold tier — has no
objects/packto rename into; the staged pack would have to be held in memory or in the initramfs and only then folded into the archive. This is the half that makes it a design change rather than a patch. MemStoreand the closure-basedPackIngestused by most ofgunnar-wire's own tests — need the two new methods, so every test ingest changes shape.
fsck is the cheaper of the two and is not blocked by the seam: it is a check over the objects the indexer already decoded, and it can be added inside gunnar-pack's index_thin_pack without any store knowing. git index-pack --strict is the reference for what it must reject; gunnar spawns no git, so the checks (tree entry ordering and mode validity, commit and tag header grammar, no .git path component) would be written against gix-object's parsers.
Ranking, if it is picked up: fsck first — it is contained, it is what actually stops a malformed object being storable, and it needs no seam change. Quarantine second, and only with the appliance path designed rather than retrofitted.
6. The refusal path this hardens is already exercised
/home/rickard/git/bifrost holds a corrupt pack, and gunnar refused a push carrying it. That refusal was correct and is the path all of the above is about: the objects of that refused push are, today, in the receiving repository's ODB.
Measured, not inferred: the object counts and pack bytes in §2 come from the named test on oden, 2026-08-05. Inferred, and said so: the appliance-side cost in §5 is a reading of the code, not a prototype — nobody has tried to stage a pack in the initramfs.