znippy_plugin_git/resolve.rs
1//! Pack entries → **oids**, which is the one thing [`crate::pack_walk`] cannot
2//! tell you and the `objects` table is keyed by.
3//!
4//! This is the **indexer's** work, not the ack path's (§13.9: "the index is built
5//! after the ack, over a channel"). It inflates every entry, applies every delta
6//! chain and hashes the result, which is what `git index-pack` does and what
7//! makes it the expensive half of a push. Nothing here is called before a client
8//! is told its push landed.
9//!
10//! # What it keeps and what it throws away
11//!
12//! Peak memory is deliberately not "the whole history inflated". **An object is
13//! held only while an unresolved entry still has to read it**, and for nothing
14//! else:
15//!
16//! | object | held | until |
17//! |---|---|---|
18//! | a delta base | yes | its **last** dependent has consumed it |
19//! | everything else — blob, commit, tree, tag | **no** | it leaves through the [`PayloadSink`] and is dropped |
20//!
21//! The base set and, more to the point, **how many entries read each base** are
22//! known before a byte is inflated: the walk already collected every delta base,
23//! so the resolver starts with an exact reference count per base and drops a
24//! payload the moment that count reaches zero. The last dependent gets the base
25//! **moved** into it rather than cloned, so a linear delta chain — one base, one
26//! dependent, the ordinary shape of real history — copies no payload at all.
27//!
28//! ## What this table said before 2026-08-11, and what it cost
29//!
30//! It said commits, trees and tags were kept "because the `graph` and `reach`
31//! tables are built from them". They are not, and had not been since §14's
32//! exploded table became the fold's input — `absorb_one` reads
33//! `Resolved::index_entry()` and nothing else. Two copies of the object set were
34//! being held for a reader that no longer existed:
35//!
36//! * `content` kept every base **and** every typed object for the whole call.
37//! Nothing ever removed a row, so "held only while something still needs them"
38//! described an intention, not the code.
39//! * `Resolved::payload` kept a second copy of the same bytes, cloned in.
40//!
41//! MEASURED on oden 2026-08-11, `tests/resolve_peak_memory.rs`, peak **live**
42//! heap over `resolve_walked` against the pack's own inflated payload:
43//!
44//! | pack | before | after |
45//! |---|---:|---:|
46//! | forge-year-8a-240b-12c, 28 893 objects, 111.0 MB | 2.03x | **0.03x** |
47//! | stage-red-nobitmap, 12 328 objects, 343.5 MB | 1.34x (461.2 MB) | **0.02x** (5.9 MB) |
48//! | facett, 7 232 objects, 72.8 MB | 1.20x | **0.42x** |
49//! | znippy's own, 5 388 objects, 84.4 MB | 0.75x | **0.14x** |
50//!
51//! The worst pack in the corpus went 2.03x → 0.42x. It is `facett`, whose
52//! delta chains fan out widely enough that a real base working set is genuinely
53//! live at once — which is the bound behaving as intended rather than a residue.
54//!
55//! End to end, one `gunnar-server`, one `push --mirror`, anonymous RSS held
56//! afterwards (not peak — this memory was never given back):
57//!
58//! | fixture | before | after |
59//! |---|---:|---:|
60//! | 36 172 objects, 290 MB inflated, 5.9 MB pack | 575.7 MB | **185.1 MB** |
61//! | 352 396 objects, 2.89 GB inflated, 61.8 MB pack | 4480.1 MB | **439.9 MB** |
62//!
63//! The second row is the one that mattered: 10x the repository cost 10x the
64//! memory before and 2.4x after, so the resolver's footprint stopped tracking
65//! the history. What is left is bounded and named — redb's page cache
66//! (`ZNIPPY_GIT_REDB_CACHE_BYTES`, 64 MiB by default, measured at 129.3 MB held
67//! with an 8 MiB ceiling and 303.9 MB with 256 MiB), the exploded table's
68//! [`FLUSH_BYTES`](crate::exploded::FLUSH_BYTES) write buffer, and
69//! `Derived`'s in-memory graph, which is genuinely O(repository).
70//!
71//! # ⚠ The clone that got slower, and why it is not this code
72//!
73//! **Read this before "fixing" the reference counting.** `vs_forge_clone` on the
74//! 352 396-object fixture reported this change as a **+78 % CPU regression on
75//! every znippy arm**, and the natural reading — a base dropped at its "last
76//! use" has to be re-resolved when something later wants it again — is wrong. It
77//! is not even mechanically possible: an undercount cannot cause a re-resolve
78//! here, because there is no re-resolve to fall into. `resolve_walked` fails the
79//! whole pack by name.
80//!
81//! What it actually is, MEASURED on oden 2026-08-11, one gunnar-server, seed the
82//! fixture then clone it, steady state (clones 2-3), quiet box, only the
83//! allocator and this file varying:
84//!
85//! | resolver | allocator | clone CPU | anon after seed |
86//! |---|---|---:|---:|
87//! | before | glibc | 1.47 s | 4481 MB |
88//! | **after** | glibc | **2.62 s** | **438 MB** |
89//! | before | mimalloc | 1.18 s | 1767 MB |
90//! | **after** | **mimalloc** | **1.17 s** | **701 MB** |
91//!
92//! With a competent allocator the two resolvers are **identical** (1.18 against
93//! 1.17). The regression is entirely glibc `malloc`: the old resolver's retained
94//! 4.5 GB was accidentally acting as a **pre-warmed pool**, so the clone's own
95//! ~2 GB of allocations came off a free list instead of out of the kernel. Take
96//! the leak away and glibc charges for the memory it should have been charging
97//! for all along.
98//!
99//! Three independent measurements say the same thing, and none of them involve
100//! this file being slower:
101//!
102//! 1. **Bisect.** A build with *only* `Resolved::payload` removed — the
103//! reference counting NOT applied, `content` still never pruned — reproduces
104//! the whole regression (2.56–2.62 s). Adding the reference counting on top
105//! then costs **nothing** (2.55–2.65 s) and takes anon-after-seed from
106//! 2350 MB to 438 MB. The counting is free; the *not leaking* is what glibc
107//! punishes.
108//! 2. **Restart.** Restart the server after seeding, so the derived tables are
109//! folded onto a fresh heap: before 2.20–2.22 s, after 2.20–2.24 s —
110//! identical to within 1 %. The old resolver's 1.47 s is the outlier, not the
111//! new one's 2.62 s.
112//! 3. **The resolver itself.** Over the same 6 real packs both revisions accept,
113//! 1.058 s before against 0.975 s after: this code is ~8 % *faster*.
114//!
115//! So there is no cheaper trade to find inside this module, and an LRU over
116//! bases would buy nothing. The lever, if the CPU is wanted back, is the
117//! allocator — and `LD_PRELOAD=libmimalloc.so.3` is enough to get it with no
118//! code change at all.
119//!
120//! # Where the payloads it throws away now go
121//!
122//! §14's exploded table is **eager** (decided 2026-08-08), so every object this
123//! module resolves has to reach [`crate::exploded::ExplodedTable`] — including
124//! the blobs the table above says are dropped. They still are: the payload
125//! leaves through a [`PayloadSink`] as it is produced, rather than being
126//! accumulated into the returned `Vec`. That is the difference between a peak
127//! footprint of "the bases plus the typed objects" and one of "the whole pack
128//! inflated at once", and eager resolution does not get to change it.
129//!
130//! A **thin** pack's `REF_DELTA` base is an object the client knows the server
131//! already has, and it is usually a blob. Resolving it needs that blob's
132//! *content*, which is exactly what the exploded table now holds — so the
133//! [`BaseSource`] can answer from it in one point lookup instead of re-resolving
134//! a whole pack. A thin pack whose external base is **still** not re-derivable
135//! is refused by name ([`ResolveError::MissingBase`]) rather than resolved by
136//! guesswork, unchanged.
137
138use std::collections::HashMap;
139
140use anyhow::{Result, anyhow, bail};
141
142use crate::exploded::{NoSink, PayloadSink};
143use crate::index_layout::{IndexEntry, ObjType};
144use crate::object::{GitHashKind, GitObjectKind, canonical};
145use crate::pack_walk::{DeltaBase, PackEntry, PackWalk, walk};
146
147/// Where an entry that deltas against something outside the pack gets its base.
148///
149/// Implemented by the store, which may or may not be able to answer — see the
150/// module docs on §14.
151pub trait BaseSource {
152 /// The object's type and inflated payload, or `None` if this source cannot
153 /// produce it.
154 fn content(&self, oid: &[u8]) -> Option<(GitObjectKind, Vec<u8>)>;
155}
156
157/// A source that has nothing. The right one for a self-contained pack, and it
158/// makes "this pack was thin" an error rather than a silent success.
159pub struct NoBases;
160
161impl BaseSource for NoBases {
162 fn content(&self, _oid: &[u8]) -> Option<(GitObjectKind, Vec<u8>)> {
163 None
164 }
165}
166
167/// One resolved object: **exactly what the `objects` table stores**, and no
168/// payload.
169///
170/// It carried an `Option<Vec<u8>>` payload until 2026-08-11, for a reader that
171/// stopped existing when §14's exploded table became the fold's input. Every
172/// production caller uses [`index_entry`](Self::index_entry) and nothing else —
173/// `absorb_one` builds index rows, and the one other caller discards the vector
174/// entirely and reads its sink. Content has one home now
175/// ([`PayloadSink`], LAW 5) and this struct is the row, so a 343 MB pack's rows
176/// cost the length of the pack's entry list rather than the length of its
177/// history.
178#[derive(Debug, Clone)]
179pub struct Resolved {
180 pub oid: Vec<u8>,
181 /// Byte extent of the **stored, verbatim** entry.
182 pub offset: u64,
183 pub len: u64,
184 /// The type the entry carries in the pack — which for a delta is
185 /// `OfsDelta` / `RefDelta`, exactly as `.idx` cannot tell you and this index
186 /// exists to record.
187 pub stored_type: ObjType,
188 /// The type the chain resolves to. Always one of the four real git types.
189 pub kind: GitObjectKind,
190 /// Inflated, **post-delta-resolution** size — the fact `.idx` and `.rev`
191 /// together still cannot answer.
192 pub uncompressed_size: u64,
193 /// The `objects.delta_base` column: an archive **offset**, `0` for none.
194 pub delta_base: u64,
195}
196
197impl Resolved {
198 /// The row [`crate::read_stack::ObjectReadStack::append`] takes.
199 ///
200 /// `delta_base` now has a column of its own in [`IndexEntry`] and rides
201 /// across in it — it was carried on this struct alone until that column
202 /// existed, and was deliberately never smuggled into `uncompressed_size` or
203 /// any other field on the way. **This is the only place it is written into
204 /// an index row** (LAW 5): [`resolve_walked`] computes it once, in the
205 /// archive's coordinate space, and nothing downstream recomputes it from the
206 /// walk.
207 pub fn index_entry(&self) -> IndexEntry {
208 IndexEntry {
209 oid: self.oid.clone(),
210 offset: self.offset,
211 len: self.len,
212 obj_type: self.stored_type,
213 uncompressed_size: self.uncompressed_size,
214 delta_base: self.delta_base,
215 }
216 }
217}
218
219/// The one failure that is a *decision* rather than a corrupt pack.
220#[derive(Debug)]
221pub struct ResolveError;
222
223impl ResolveError {
224 /// A thin pack's external base, named.
225 pub fn missing_base(oid: &[u8]) -> anyhow::Error {
226 anyhow!(
227 "this pack is thin: it deltas against {} which is not in it, and no base source could \
228 produce that object's content — §14's exploded table does not hold it and it could \
229 not be re-derived from the verbatim packs either, so this repository genuinely does \
230 not have that object. The pack is refused by name rather than half-resolved",
231 hex::encode(oid)
232 )
233 }
234}
235
236/// Resolve every entry of `pack` to an oid, **keeping no payloads**.
237///
238/// `archive_offset` is where the pack's first byte lives in the archive, so the
239/// offsets and `delta_base`s that come back are already in the archive's
240/// coordinate space and no caller has to remember to shift them.
241///
242/// The indexer does **not** come through here — it calls
243/// [`resolve_walked`] with the exploded table as its sink, which is what makes
244/// §14's side table fall out of the same pass rather than out of a second walk.
245pub fn resolve(
246 pack: &[u8],
247 hash: GitHashKind,
248 archive_offset: u64,
249 bases: &dyn BaseSource,
250) -> Result<Vec<Resolved>> {
251 let w = walk(pack, hash.oid_len())?;
252 resolve_walked(pack, &w, hash, archive_offset, bases, &NoSink)
253}
254
255/// Same, for a caller that already walked the pack (`put` does, for the closure
256/// check) and must not walk it twice — and for the one that wants the payloads.
257///
258/// `sink` is handed **every** object exactly once, in resolution order, with the
259/// payload its chain resolves to. Pass [`NoSink`] to keep nothing. This is the
260/// one pass §14's exploded table is built from: no second walk, no second
261/// inflate, no second resolver (LAW 5).
262pub fn resolve_walked(
263 pack: &[u8],
264 w: &PackWalk,
265 hash: GitHashKind,
266 archive_offset: u64,
267 bases: &dyn BaseSource,
268 sink: &dyn PayloadSink,
269) -> Result<Vec<Resolved>> {
270 // **How many entries have to read each base**, known before a byte is
271 // inflated. A count and not a set: the set says "keep this", the count says
272 // "keep this until exactly here", and only the second one lets a payload be
273 // dropped mid-pass. An entry is resolved at most once (the fixpoint removes
274 // it from `remaining` the round it succeeds), so one dependent is one
275 // decrement and the count is exact rather than conservative.
276 let mut ofs_uses: HashMap<u64, usize> = HashMap::new();
277 let mut ref_uses: HashMap<&[u8], usize> = HashMap::new();
278 for e in &w.entries {
279 match &e.delta_base {
280 DeltaBase::Offset(o) => *ofs_uses.entry(*o).or_default() += 1,
281 DeltaBase::Ref(oid) => *ref_uses.entry(oid.as_slice()).or_default() += 1,
282 DeltaBase::None => {}
283 }
284 }
285
286 let mut out: Vec<Option<Resolved>> = vec![None; w.entries.len()];
287 // Payloads held only while something still needs them — and now that is
288 // true of the code and not only of this comment. The `usize` is the number
289 // of dependents that have yet to consume the row; at zero the row goes.
290 let mut content: HashMap<u64, (GitObjectKind, Vec<u8>, usize)> = HashMap::new();
291 // Only for the payloads a `REF_DELTA` in this pack actually names. An oid
292 // nothing ref-deltas against is never looked up here, so recording it would
293 // be 20 bytes and a hash per object of the repository for no reader.
294 let mut oid_to_offset: HashMap<Vec<u8>, u64> = HashMap::new();
295
296 // A fixpoint rather than one pass: a `REF_DELTA` may name a base that is in
297 // this pack but *later* in it, and that is legal. Every round resolves at
298 // least one entry or the pack cannot be resolved at all, so this terminates
299 // in at most `entries` rounds.
300 let mut remaining: Vec<usize> = (0..w.entries.len()).collect();
301 while !remaining.is_empty() {
302 let mut progressed = false;
303 let mut stuck: Vec<usize> = Vec::new();
304 for &i in &remaining {
305 let e = &w.entries[i];
306 let base: Option<(GitObjectKind, Vec<u8>)> = match &e.delta_base {
307 DeltaBase::None => None,
308 DeltaBase::Offset(o) => match consume(&mut content, *o) {
309 Some(c) => Some(c),
310 None => {
311 // Not yet resolved. It cannot be resolved-and-dropped:
312 // this entry's own use is part of the base's count, so
313 // the count cannot have reached zero before this line.
314 stuck.push(i);
315 continue;
316 }
317 },
318 DeltaBase::Ref(oid) => match oid_to_offset
319 .get(oid.as_slice())
320 .copied()
321 .and_then(|o| consume(&mut content, o))
322 {
323 Some(c) => Some(c),
324 None => match bases.content(oid) {
325 Some(c) => Some(c),
326 None => {
327 stuck.push(i);
328 continue;
329 }
330 },
331 },
332 };
333
334 let (kind, payload) = match base {
335 None => {
336 let kind = whole_kind(e.obj_type)?;
337 (kind, inflate(pack, e, hash.oid_len())?)
338 }
339 Some((base_kind, base_payload)) => {
340 let delta = inflate(pack, e, hash.oid_len())?;
341 (base_kind, apply_delta(&base_payload, &delta)?)
342 }
343 };
344
345 let oid = hash.oid_of(&canonical(kind, &payload));
346 // **Every object, before anything is dropped.** §14's exploded table
347 // is eager, so the sink sees the blob whose payload the next lines
348 // are about to throw away as well as the ones they keep.
349 sink.explode(&oid, kind, &payload)?;
350 let size = payload.len() as u64;
351
352 // Held only if something still has to read it, and then only for as
353 // many reads as the walk counted. `ref_uses` is consulted by oid
354 // because that is how a `REF_DELTA` names its base; the two counts
355 // add, because one object can be both kinds of base.
356 let by_ref = ref_uses.get(oid.as_slice()).copied().unwrap_or(0);
357 let uses = ofs_uses.get(&e.offset).copied().unwrap_or(0) + by_ref;
358 if uses > 0 {
359 if by_ref > 0 {
360 oid_to_offset.insert(oid.clone(), e.offset);
361 }
362 content.insert(e.offset, (kind, payload, uses));
363 }
364 // `payload` is gone by here unless a dependent needs it. Nothing
365 // below reads it: the row is the index entry.
366
367 out[i] = Some(Resolved {
368 oid,
369 offset: e.offset + archive_offset,
370 len: e.len,
371 stored_type: e.obj_type,
372 kind,
373 uncompressed_size: size,
374 delta_base: match &e.delta_base {
375 DeltaBase::Offset(o) => o + archive_offset,
376 _ => 0,
377 },
378 });
379 progressed = true;
380 }
381 if !progressed {
382 // Nothing moved: every remaining entry waits on a base nobody can
383 // supply. Name the first one — a thin pack is the ordinary reason.
384 let i = stuck[0];
385 if let DeltaBase::Ref(oid) = &w.entries[i].delta_base {
386 return Err(ResolveError::missing_base(oid));
387 }
388 bail!(
389 "entry at offset {} deltas against offset {} which no entry starts at — the pack \
390 is corrupt",
391 w.entries[i].offset,
392 w.entries[i].delta_base.as_offset()
393 );
394 }
395 remaining = stuck;
396 }
397
398 // `content` is empty here for any pack whose bases were all resolved from
399 // inside it. What can survive is the one case the fixpoint cannot count: a
400 // `REF_DELTA` whose in-pack base had not been reached yet, which
401 // `bases.content` answered from the store instead — that dependent's use is
402 // then never decremented off the in-pack copy. Bounded by the number of
403 // ref-deltas, and it goes out of scope on the next line.
404 Ok(out
405 .into_iter()
406 .map(|r| r.expect("the fixpoint only exits when every slot is filled"))
407 .collect())
408}
409
410/// Take one use off a held base, **moving** the payload out on the last one.
411///
412/// The move is the point. A base with a single dependent — a linear delta
413/// chain, which is what real history is mostly made of — is handed straight to
414/// `apply_delta` with no copy at all, where the previous code cloned every base
415/// on every application. Only a base with more dependents still to come is
416/// cloned, and only for the ones that are not last.
417fn consume(
418 content: &mut HashMap<u64, (GitObjectKind, Vec<u8>, usize)>,
419 at: u64,
420) -> Option<(GitObjectKind, Vec<u8>)> {
421 let (kind, payload, left) = content.get_mut(&at)?;
422 *left -= 1;
423 if *left > 0 {
424 return Some((*kind, payload.clone()));
425 }
426 let (kind, payload, _) = content.remove(&at).expect("just borrowed it");
427 Some((kind, payload))
428}
429
430/// The real git type of a non-delta entry.
431fn whole_kind(t: ObjType) -> Result<GitObjectKind> {
432 Ok(match t {
433 ObjType::Commit => GitObjectKind::Commit,
434 ObjType::Tree => GitObjectKind::Tree,
435 ObjType::Blob => GitObjectKind::Blob,
436 ObjType::Tag => GitObjectKind::Tag,
437 ObjType::OfsDelta | ObjType::RefDelta => {
438 bail!("a delta entry has no type of its own — its base's type is the answer")
439 }
440 })
441}
442
443/// Inflate one entry's stream. The walk already proved the length, so this
444/// allocates exactly the declared size and never grows.
445fn inflate(pack: &[u8], e: &PackEntry, oid_len: usize) -> Result<Vec<u8>> {
446 let start = e.offset as usize;
447 let end = start + e.len as usize;
448 if end > pack.len() {
449 bail!("entry at {start} runs past the end of the pack");
450 }
451 // Skip the header the walk already parsed: the zlib stream is what is left
452 // after the type/size varint and any base reference.
453 let header = header_len(&pack[start..end], e, oid_len)?;
454 let mut out = Vec::with_capacity(e.uncompressed_size as usize);
455 let mut d = flate2::Decompress::new(true);
456 d.decompress_vec(
457 &pack[start + header..end],
458 &mut out,
459 flate2::FlushDecompress::Finish,
460 )
461 .map_err(|err| anyhow!("inflating the entry at {start}: {err}"))?;
462 if out.len() as u64 != e.uncompressed_size {
463 bail!(
464 "the entry at {start} inflated to {} bytes, the walk measured {}",
465 out.len(),
466 e.uncompressed_size
467 );
468 }
469 Ok(out)
470}
471
472/// How many bytes of an entry are header rather than zlib stream. Recomputed
473/// from the bytes rather than remembered, so it cannot drift from the walk's own
474/// reading of them — and the walk's `len` is what bounds it.
475fn header_len(entry: &[u8], e: &PackEntry, oid_len: usize) -> Result<usize> {
476 let mut i = 0usize;
477 let mut cont = true;
478 while cont {
479 let b = *entry
480 .get(i)
481 .ok_or_else(|| anyhow!("the type/size header runs off the entry"))?;
482 cont = b & 0x80 != 0;
483 i += 1;
484 }
485 match e.obj_type {
486 ObjType::OfsDelta => {
487 let mut cont = true;
488 while cont {
489 let b = *entry
490 .get(i)
491 .ok_or_else(|| anyhow!("the ofs-delta distance runs off the entry"))?;
492 cont = b & 0x80 != 0;
493 i += 1;
494 }
495 }
496 ObjType::RefDelta => i += oid_len,
497 _ => {}
498 }
499 if i >= entry.len() {
500 bail!("the entry's header consumes all of it, leaving no stream");
501 }
502 Ok(i)
503}
504
505/// git's delta format: two sizes, then copy-from-base and insert-literal
506/// instructions.
507fn apply_delta(base: &[u8], delta: &[u8]) -> Result<Vec<u8>> {
508 let mut i = 0usize;
509 let base_size = delta_varint(delta, &mut i)?;
510 if base_size != base.len() as u64 {
511 bail!(
512 "the delta expects a base of {base_size} bytes, its base is {}",
513 base.len()
514 );
515 }
516 let target_size = delta_varint(delta, &mut i)?;
517 let mut out = Vec::with_capacity(target_size as usize);
518
519 while i < delta.len() {
520 let op = delta[i];
521 i += 1;
522 if op & 0x80 != 0 {
523 // Copy from the base. Offset in up to 4 bytes, size in up to 3;
524 // a zero size means 0x10000, which is git's own special case.
525 let mut off = 0u64;
526 for bit in 0..4 {
527 if op & (1 << bit) != 0 {
528 off |= u64::from(*delta.get(i).ok_or_else(|| anyhow!("delta ends mid-copy"))?)
529 << (bit * 8);
530 i += 1;
531 }
532 }
533 let mut size = 0u64;
534 for bit in 0..3 {
535 if op & (0x10 << bit) != 0 {
536 size |= u64::from(*delta.get(i).ok_or_else(|| anyhow!("delta ends mid-copy"))?)
537 << (bit * 8);
538 i += 1;
539 }
540 }
541 if size == 0 {
542 size = 0x1_0000;
543 }
544 let from = off as usize;
545 let to = from
546 .checked_add(size as usize)
547 .ok_or_else(|| anyhow!("a delta copy range overflows"))?;
548 if to > base.len() {
549 bail!(
550 "a delta copies base[{from}..{to}] out of a {}-byte base",
551 base.len()
552 );
553 }
554 out.extend_from_slice(&base[from..to]);
555 } else {
556 // Insert literal. A zero-length insert is not a valid instruction.
557 let n = op as usize;
558 if n == 0 {
559 bail!("a delta carries a zero-length insert instruction");
560 }
561 let end = i
562 .checked_add(n)
563 .ok_or_else(|| anyhow!("a delta insert overflows"))?;
564 if end > delta.len() {
565 bail!("a delta insert of {n} bytes runs off the end");
566 }
567 out.extend_from_slice(&delta[i..end]);
568 i = end;
569 }
570 }
571
572 if out.len() as u64 != target_size {
573 bail!(
574 "the delta declares a {target_size}-byte result and produced {}",
575 out.len()
576 );
577 }
578 Ok(out)
579}
580
581/// The delta header's little-endian 7-bit varint. Not the pack entry's varint.
582fn delta_varint(b: &[u8], i: &mut usize) -> Result<u64> {
583 let mut v = 0u64;
584 let mut shift = 0u32;
585 loop {
586 let byte = *b
587 .get(*i)
588 .ok_or_else(|| anyhow!("a delta size varint runs off the end"))?;
589 *i += 1;
590 if shift >= 64 {
591 bail!("a delta size varint is longer than a u64");
592 }
593 v |= u64::from(byte & 0x7f) << shift;
594 shift += 7;
595 if byte & 0x80 == 0 {
596 return Ok(v);
597 }
598 }
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604 use std::path::{Path, PathBuf};
605
606 /// Parse a real `.idx` (v2) into `(oid, pack offset)` pairs. Twenty lines of
607 /// a frozen format, and it is git's own answer to the question this module
608 /// computes — which is what makes it a differential guard and not a
609 /// restatement of our own code.
610 fn read_idx(bytes: &[u8]) -> Vec<(Vec<u8>, u64)> {
611 assert_eq!(&bytes[0..4], b"\xfftOc", "not an idx v2");
612 assert_eq!(u32::from_be_bytes(bytes[4..8].try_into().unwrap()), 2);
613 let fanout_end = 8 + 256 * 4;
614 let n = u32::from_be_bytes(bytes[fanout_end - 4..fanout_end].try_into().unwrap()) as usize;
615 let oids = fanout_end;
616 let crcs = oids + n * 20;
617 let offs = crcs + n * 4;
618 let big = offs + n * 4;
619 let mut out = Vec::with_capacity(n);
620 for i in 0..n {
621 let oid = bytes[oids + i * 20..oids + i * 20 + 20].to_vec();
622 let raw = u32::from_be_bytes(bytes[offs + i * 4..offs + i * 4 + 4].try_into().unwrap());
623 let offset = if raw & 0x8000_0000 != 0 {
624 let j = (raw & 0x7fff_ffff) as usize;
625 u64::from_be_bytes(bytes[big + j * 8..big + j * 8 + 8].try_into().unwrap())
626 } else {
627 u64::from(raw)
628 };
629 out.push((oid, offset));
630 }
631 out
632 }
633
634 /// Real packs with their real indexes, from this machine's repositories.
635 fn real_pairs(cap: usize) -> Vec<(PathBuf, PathBuf)> {
636 let mut out = Vec::new();
637 let Ok(repos) = std::fs::read_dir(Path::new("/home/rickard/git")) else {
638 return out;
639 };
640 for repo in repos.flatten() {
641 let dir = repo.path().join(".git/objects/pack");
642 let Ok(files) = std::fs::read_dir(&dir) else {
643 continue;
644 };
645 for f in files.flatten() {
646 let p = f.path();
647 if p.extension().is_some_and(|e| e == "pack")
648 && f.metadata().map(|m| m.len() < 32 << 20).unwrap_or(false)
649 {
650 let idx = p.with_extension("idx");
651 if idx.exists() {
652 out.push((p, idx));
653 if out.len() >= cap {
654 return out;
655 }
656 }
657 }
658 }
659 }
660 out
661 }
662
663 /// **git's own `.idx` is the arbiter.** For every real pack on this machine,
664 /// every oid we compute and the offset we computed it at must appear in
665 /// git's index, and the two sets must have the same size. A delta applier
666 /// that is subtly wrong produces a different hash and cannot pass this.
667 ///
668 /// Seen RED by changing `size = 0x1_0000` to `size = 0x1_000` in
669 /// [`apply_delta`] — git's zero-size copy special case:
670 /// ".../facett/...pack-62582dd0…: the delta declares a 149392-byte result and
671 /// produced 87952". A single wrong constant in the delta applier and a real
672 /// repository's pack stops resolving.
673 ///
674 /// MEASURED while writing it: **10 899 objects over 3 packs** match git's own
675 /// `.idx` oid for oid and offset for offset, and **3 of the 6 real packs on
676 /// this machine are thin** — they delta against objects that are not in them,
677 /// which is the §14 hole showing up in the wild rather than in theory.
678 #[test]
679 fn every_oid_we_compute_is_the_oid_git_wrote_in_its_idx() {
680 let pairs = real_pairs(6);
681 assert!(
682 !pairs.is_empty(),
683 "no real pack found under /home/rickard/git — this guard has nothing to compare and \
684 must not pass silently"
685 );
686 let mut objects = 0usize;
687 let mut full = 0usize;
688 let mut thin = 0usize;
689 for (pack_path, idx_path) in pairs {
690 let pack = std::fs::read(&pack_path).unwrap();
691 let idx = read_idx(&std::fs::read(&idx_path).unwrap());
692 // MEASURED, and a surprise worth recording: real packs on this
693 // machine DO delta against objects outside themselves. Such a pack
694 // cannot be compared here — resolving it is exactly what §14 leaves
695 // open — but it must not make the guard pass vacuously either, so it
696 // is counted and named and at least one pack has to compare in full.
697 let ours = match resolve(&pack, GitHashKind::Sha1, 0, &NoBases) {
698 Ok(r) => r,
699 Err(e) => {
700 assert!(
701 e.to_string().contains("thin"),
702 "{}: {e}",
703 pack_path.display()
704 );
705 eprintln!("{}: THIN, external base — skipped", pack_path.display());
706 thin += 1;
707 continue;
708 }
709 };
710 full += 1;
711
712 assert_eq!(
713 ours.len(),
714 idx.len(),
715 "{}: we resolved {} objects, git indexed {}",
716 pack_path.display(),
717 ours.len(),
718 idx.len()
719 );
720 let theirs: HashMap<Vec<u8>, u64> = idx.into_iter().collect();
721 for r in &ours {
722 match theirs.get(&r.oid) {
723 Some(&off) => assert_eq!(
724 off,
725 r.offset,
726 "{}: {} is at {} in git's idx and we put it at {}",
727 pack_path.display(),
728 hex::encode(&r.oid),
729 off,
730 r.offset
731 ),
732 None => panic!(
733 "{}: we computed {} at offset {}, which git's idx does not contain — the \
734 resolution is wrong",
735 pack_path.display(),
736 hex::encode(&r.oid),
737 r.offset
738 ),
739 }
740 }
741 eprintln!("{}: {} objects agree with git", pack_path.display(), ours.len());
742 objects += ours.len();
743 }
744 eprintln!("{objects} objects over {full} packs agree with git's own .idx; {thin} thin");
745 assert!(full > 0, "every pack on this machine was thin — nothing was compared");
746 assert!(objects > 1000, "only {objects} objects compared");
747 }
748
749 /// The three facts the objects table needs and `.idx` cannot supply: the
750 /// **stored** type (delta or not), the **resolved** type, and the
751 /// post-resolution size. Asserted against the pack's own delta structure, on
752 /// a real pack, so it cannot be satisfied by a store-and-echo.
753 ///
754 /// Seen RED by resolving a delta to `GitObjectKind::Blob` instead of to its
755 /// base's kind: "a delta resolves to its base's type:
756 /// c6d50e761d025c179ffef1c3d68c56aae0a53270 vs base
757 /// 8e15741296ebe3a0508522b76fe2ec4982396b6d — left: Blob, right: Commit".
758 ///
759 /// MEASURED on that pack: 4292 deltas, 2715 of them resolving to something
760 /// other than a blob, and **all 4292** carrying a resolved size unlike their
761 /// delta-stream size.
762 #[test]
763 fn a_delta_carries_its_stored_type_and_its_resolved_type_and_size() {
764 let (pack_path, _) = real_pairs(1).pop().expect("a real pack");
765 let pack = std::fs::read(&pack_path).unwrap();
766 let ours = resolve(&pack, GitHashKind::Sha1, 0, &NoBases).unwrap();
767 // One walk, reused. Walking per entry turns this guard into an O(n^2)
768 // pass over a 7000-object pack and it stops finishing.
769 let stream_size: HashMap<u64, u64> = walk(&pack, 20)
770 .unwrap()
771 .entries
772 .iter()
773 .map(|e| (e.offset, e.uncompressed_size))
774 .collect();
775
776 let deltas: Vec<&Resolved> = ours
777 .iter()
778 .filter(|r| matches!(r.stored_type, ObjType::OfsDelta | ObjType::RefDelta))
779 .collect();
780 assert!(
781 !deltas.is_empty(),
782 "{} carries no deltas, so it proves nothing about resolution",
783 pack_path.display()
784 );
785
786 // A delta's resolved type is a real git type, never a delta code, and it
787 // matches its base's.
788 let by_offset: HashMap<u64, &Resolved> = ours.iter().map(|r| (r.offset, r)).collect();
789 let mut non_blob = 0usize;
790 for d in &deltas {
791 assert!(
792 matches!(
793 d.kind,
794 GitObjectKind::Blob
795 | GitObjectKind::Tree
796 | GitObjectKind::Commit
797 | GitObjectKind::Tag
798 ),
799 "a resolved type must be a real git type"
800 );
801 if d.kind != GitObjectKind::Blob {
802 non_blob += 1;
803 }
804 if d.delta_base != 0 {
805 let base = by_offset[&d.delta_base];
806 assert_eq!(
807 d.kind,
808 base.kind,
809 "a delta resolves to its base's type: {} vs base {}",
810 hex::encode(&d.oid),
811 hex::encode(&base.oid)
812 );
813 // The delta_base column addresses BYTES, and those bytes are an
814 // entry we also resolved. An ordinal could not be checked like
815 // this at all.
816 assert!(
817 base.offset < d.offset,
818 "an ofs-delta base is always earlier in the pack"
819 );
820 }
821 }
822 assert!(
823 non_blob > 0,
824 "{} deltas and not one resolved to a tree/commit/tag — the resolved type is not being \
825 taken from the base",
826 deltas.len()
827 );
828
829 // The post-resolution size is the payload's real length, not the delta
830 // stream's. For a delta the two are almost never equal.
831 let differing = deltas
832 .iter()
833 .filter(|d| stream_size[&d.offset] != d.uncompressed_size)
834 .count();
835 assert!(
836 differing > 0,
837 "not one delta's resolved size differs from its delta-stream size, which cannot be \
838 true of a real pack — the resolved size is being copied from the header"
839 );
840 eprintln!(
841 "{}: {} deltas, {non_blob} non-blob, {differing} with a resolved size unlike the \
842 stream size",
843 pack_path.display(),
844 deltas.len()
845 );
846 }
847
848 /// A thin pack is refused **by name**, and the refusal says what decision is
849 /// missing. It is not resolved to a wrong oid and it is not silently dropped.
850 ///
851 /// Seen RED by making the no-progress arm `bail!("stuck")` instead of
852 /// `ResolveError::missing_base`: "names the base: stuck".
853 #[test]
854 fn a_thin_pack_is_refused_by_name_and_says_what_is_missing() {
855 // One ref-delta against an oid nothing can supply.
856 let base_oid = vec![0x5a; 20];
857 let mut pack = b"PACK".to_vec();
858 pack.extend_from_slice(&2u32.to_be_bytes());
859 pack.extend_from_slice(&1u32.to_be_bytes());
860 // A delta whose base is 4 bytes and whose target is 4 bytes: copy all.
861 let delta = vec![0x04, 0x04, 0x90, 0x04];
862 pack.push(0x70 | (delta.len() as u8 & 0x0f)); // type 7, size 4
863 pack.extend_from_slice(&base_oid);
864 pack.extend_from_slice(&{
865 use std::io::Write;
866 let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
867 e.write_all(&delta).unwrap();
868 e.finish().unwrap()
869 });
870 pack.extend_from_slice(&[0u8; 20]);
871
872 let err = resolve(&pack, GitHashKind::Sha1, 0, &NoBases)
873 .expect_err("a thin pack with no base source cannot resolve");
874 let msg = err.to_string();
875 assert!(msg.contains(&hex::encode(&base_oid)), "names the base: {msg}");
876 assert!(msg.contains("§14"), "says which decision is missing: {msg}");
877
878 // With a source that CAN supply it, the same pack resolves — so the
879 // refusal is about the missing content, not about ref-deltas.
880 struct One(Vec<u8>);
881 impl BaseSource for One {
882 fn content(&self, oid: &[u8]) -> Option<(GitObjectKind, Vec<u8>)> {
883 (oid == self.0.as_slice()).then(|| (GitObjectKind::Blob, b"abcd".to_vec()))
884 }
885 }
886 let ok = resolve(&pack, GitHashKind::Sha1, 0, &One(base_oid)).expect("with a base it works");
887 assert_eq!(ok.len(), 1);
888 assert_eq!(ok[0].kind, GitObjectKind::Blob);
889 assert_eq!(ok[0].uncompressed_size, 4);
890 assert_eq!(
891 ok[0].oid,
892 GitHashKind::Sha1.oid_of(&canonical(GitObjectKind::Blob, b"abcd")),
893 "the resolved object is the base copied whole, so it hashes as `abcd`"
894 );
895 }
896
897 /// **The `delta_base` column, materialised — from a real pack, through all
898 /// three index layouts and the read stack, and back out again.**
899 ///
900 /// This is the guard the column was added for. It asserts *applied output*:
901 /// resolve a real pack, build every arm over the rows, and for every
902 /// `OFS_DELTA` read the base offset **back out of the index** and require it
903 /// to land on an object the same index holds — earlier in the archive, and
904 /// with a byte extent that contains it. An ordinal in that column could not
905 /// satisfy this at all, which is §13's decision checked rather than quoted.
906 ///
907 /// The three arms are compared row for row, because that is what caught a
908 /// swapped `len`/`size` write in the previous sweep while two of the three
909 /// stayed green.
910 ///
911 /// Seen RED three times, each for a different way the column can be wrong
912 /// while every other fact stays right:
913 ///
914 /// 1. **Never written.** `Resolved::index_entry` returning `delta_base: 0`:
915 /// "FourTables: b76687fbb34020672608bcb1a5e027c2d8b0346a is an ofs-delta
916 /// with no recorded base".
917 /// 2. **Written from the wrong field.** `delta_base: self.offset` — a
918 /// plausible archive offset that is non-zero, inside the pack, and lands
919 /// on a real entry boundary, so only the identity check catches it:
920 /// "FourTables: b76687fb… records base 91272 which is its own offset,
921 /// not its base's".
922 /// 3. **Written in the wrong coordinate space.** `resolve_walked` yielding
923 /// `*o` instead of `o + archive_offset`, i.e. pack-relative rather than
924 /// archive-relative — the bug that an *ordinal* column would have had no
925 /// way to express and no way to detect: "FourTables: b76687fb… records
926 /// base offset 86534, which starts no object in this index". This is why
927 /// the fixture resolves at `AT = 4096` rather than at 0; at 0 the two
928 /// spaces coincide and the guard would be blind.
929 ///
930 /// All three restored.
931 #[test]
932 fn the_delta_base_column_survives_all_three_arms_from_a_real_pack() {
933 use crate::index_layout::{
934 FourTables, IndexEntry, ObjType, ObjectIndex, OneTableFourColumns, PackedPayload,
935 };
936 use crate::read_stack::{ObjectReadStack, RebuildTriggers};
937
938 let (pack_path, _) = real_pairs(1).pop().expect("a real pack");
939 let pack = std::fs::read(&pack_path).unwrap();
940 // A non-zero archive offset on purpose: `delta_base` is in the archive's
941 // coordinate space, so a resolver that forgot to rebase it would give
942 // pack-relative values that no longer locate anything.
943 const AT: u64 = 4096;
944 let rows = resolve(&pack, GitHashKind::Sha1, AT, &NoBases).unwrap();
945 let entries: Vec<IndexEntry> = rows.iter().map(|r| r.index_entry()).collect();
946
947 let a = FourTables::build(&entries).unwrap();
948 let b = OneTableFourColumns::build(&entries).unwrap();
949 let c = PackedPayload::build(&entries).unwrap();
950 let stack = ObjectReadStack::<OneTableFourColumns>::in_memory(RebuildTriggers::manual())
951 .unwrap();
952 stack.append(&entries).unwrap();
953 stack.rebuild().unwrap();
954
955 // Every extent this pack's objects occupy, read back out of the index.
956 let extents: HashMap<u64, u64> = entries
957 .iter()
958 .map(|e| {
959 let r = a.lookup(&e.oid).expect("a stored oid resolves");
960 (r.offset, r.len)
961 })
962 .collect();
963 assert_eq!(extents.len(), entries.len(), "two objects share an offset");
964
965 let named: [(&str, &dyn ObjectIndex); 4] =
966 [("FourTables", &a), ("OneTableFourColumns", &b), ("PackedPayload", &c), ("ObjectReadStack", &stack)];
967 let mut ofs_deltas = 0usize;
968 for (name, idx) in named {
969 let mut with_base = 0usize;
970 for e in &entries {
971 let row = idx.lookup(&e.oid).expect("a stored oid resolves");
972 // All four layouts, row for row, on every fact.
973 assert_eq!(
974 row,
975 a.lookup(&e.oid).unwrap(),
976 "{name} disagrees with FourTables on {}",
977 hex::encode(&e.oid)
978 );
979 if row.obj_type != ObjType::OfsDelta {
980 continue;
981 }
982 with_base += 1;
983 assert_ne!(
984 row.delta_base, 0,
985 "{name}: {} is an ofs-delta with no recorded base",
986 hex::encode(&e.oid)
987 );
988 assert_ne!(
989 row.delta_base,
990 row.offset,
991 "{name}: {} records base {} which is its own offset, not its base's",
992 hex::encode(&e.oid),
993 row.delta_base
994 );
995 assert!(
996 row.delta_base >= AT,
997 "{name}: base {} is below the archive offset {AT} — it was never rebased",
998 row.delta_base
999 );
1000 // The whole claim: it addresses an entry this index holds.
1001 let base_len = extents.get(&row.delta_base).unwrap_or_else(|| {
1002 panic!(
1003 "{name}: {} records base offset {}, which starts no object in this index",
1004 hex::encode(&e.oid),
1005 row.delta_base
1006 )
1007 });
1008 assert!(
1009 row.delta_base + base_len <= row.offset,
1010 "{name}: the base at {} (+{base_len}) overlaps the delta at {}",
1011 row.delta_base,
1012 row.offset
1013 );
1014 }
1015 assert!(
1016 with_base > 0,
1017 "{name}: {} ofs-deltas resolved and 0 of them carry a base offset — the column \
1018 is not being written",
1019 entries
1020 .iter()
1021 .filter(|e| e.obj_type == ObjType::OfsDelta)
1022 .count()
1023 );
1024 ofs_deltas = with_base;
1025 }
1026 assert!(
1027 ofs_deltas > 100,
1028 "{} carries only {ofs_deltas} ofs-deltas — too few to prove anything",
1029 pack_path.display()
1030 );
1031 eprintln!(
1032 "{}: {ofs_deltas} ofs-delta base offsets located their base entry in all four layouts",
1033 pack_path.display()
1034 );
1035 }
1036
1037 /// **A base that several entries delta against survives until the last one
1038 /// has had it.**
1039 ///
1040 /// This replaces `a_blob_that_is_nobodys_base_is_hashed_and_dropped`, which
1041 /// read the retention policy off `Resolved::payload`. That field is gone
1042 /// (see the struct's docs), and the property it stood for — peak memory is
1043 /// the live base set, not the object set — is measured in bytes now by
1044 /// `tests/resolve_peak_memory.rs` rather than inferred from a flag.
1045 ///
1046 /// What is left here is the *risk* the reference counting introduced, and it
1047 /// is the one that matters: an off-by-one that frees a base while a
1048 /// dependent still needs it. That cannot be a silent wrong answer —
1049 /// `resolve_walked` fails the whole pack by name — so the assertion is that
1050 /// a pack full of shared bases resolves at all.
1051 ///
1052 /// **The test refuses to run on a pack that could not expose the bug.** A
1053 /// corpus of only single-dependent chains would pass this with the counting
1054 /// deleted entirely, which is the blind-guard shape LAW 2 is about, so the
1055 /// fan-out is asserted before the resolution is.
1056 ///
1057 /// Seen RED by `*left -= 1;` → `*left = 0;` in [`consume`] (drop every base
1058 /// on first use): "entry at offset 3141 deltas against offset 2724 which no
1059 /// entry starts at — the pack is corrupt", on the first pack tried.
1060 #[test]
1061 fn a_base_many_entries_share_outlives_all_of_them() {
1062 let mut checked = 0usize;
1063 let mut widest = 0usize;
1064 for (pack_path, _) in real_pairs(4) {
1065 let pack = std::fs::read(&pack_path).unwrap();
1066 let w = walk(&pack, 20).unwrap();
1067
1068 // How many entries name each base. Anything above 1 is a base the
1069 // counting has to hold across more than one consumer.
1070 let mut uses: HashMap<u64, usize> = HashMap::new();
1071 for e in &w.entries {
1072 if let DeltaBase::Offset(o) = e.delta_base {
1073 *uses.entry(o).or_default() += 1;
1074 }
1075 }
1076 let shared = uses.values().filter(|n| **n > 1).count();
1077 let fan_out = uses.values().copied().max().unwrap_or(0);
1078 if shared == 0 {
1079 continue;
1080 }
1081 widest = widest.max(fan_out);
1082
1083 let rows = resolve(&pack, GitHashKind::Sha1, 0, &NoBases)
1084 .unwrap_or_else(|e| panic!("{}: {e}", pack_path.display()));
1085 assert_eq!(
1086 rows.len(),
1087 w.entries.len(),
1088 "{}: {} of {} entries resolved",
1089 pack_path.display(),
1090 rows.len(),
1091 w.entries.len()
1092 );
1093 eprintln!(
1094 "{}: {shared} bases shared by more than one entry, widest fan-out {fan_out}, all \
1095 {} entries resolved",
1096 pack_path.display(),
1097 rows.len()
1098 );
1099 checked += 1;
1100 }
1101 assert!(
1102 checked > 0 && widest > 2,
1103 "no pack in the corpus had a base shared by more than two entries (checked \
1104 {checked}, widest {widest}) — this test cannot see an early free and must not \
1105 report a pass"
1106 );
1107 }
1108}