Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/storage-git-gix/parked-gix/.nornir/receive-quarantine-position.md

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:

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.

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:

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:

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.