Skip to main content

znippy_plugin_git/
oid_index.rs

1//! `__gunnar_oid__` — the reserved oid index.
2//!
3//! A raw (non-Arrow) section holding an **`stree`** keyspace over the first eight
4//! bytes of every object id, plus, per entry, the full oid, the first lookup row
5//! of that object's chunk run, and its object ordinal.
6//!
7//! ## Why stree and not the stock fst trie
8//!
9//! Git oids are fixed-width and uniformly random, so they share no prefixes: an
10//! fst gets no prefix compression, still walks its automaton byte by byte, and
11//! has **no batch path**. `stree` (`znippy-zoomies/src/stree.rs`) is built for
12//! sorted fixed-width keys — one cache-line node, branchless AVX2 compare, and a
13//! software-pipelined batch traversal. Serving one git pack is thousands of
14//! lookups, so the batch path is the whole point.
15//!
16//! ## The 8-byte prefix is NOT a key — it is a filter
17//!
18//! Two distinct oids can share their first eight bytes. It is vanishingly
19//! unlikely and it is **not impossible**, so the key is treated as what it is: a
20//! filter that narrows to a short candidate run, after which the **full oid is
21//! compared**. `lookup` is only ever correct because of that comparison;
22//! [`GitOidIndex::candidate_run`] exposes the unverified run precisely so a test
23//! can prove the verify step is load-bearing rather than decoration.
24//!
25//! ## Section layout (little-endian; keys are 8-byte aligned for `stree`)
26//!
27//! ```text
28//!   0  magic  b"ZNPYGOID"                8
29//!   8  u32 version                       4
30//!  12  u8  hash code (1=sha1, 2=sha256)  1
31//!  13  u8  oid_len (20 or 32)            1
32//!  14  u16 reserved (0)                  2
33//!  16  u64 count                         8
34//!  24  i64 keys   [count]                8*count   ← sorted ascending, the stree keyspace
35//!      u64 rows   [count]                8*count   ← first lookup row of that oid
36//!      u32 ords   [count]                4*count   ← object ordinal (oid-lexicographic)
37//!      u8  oids   [count * oid_len]                ← full oid, for the verify step
38//! ```
39//!
40//! ## The key is order-preserving, and the sign bit is why
41//!
42//! `i64::from_be_bytes(oid[..8])` — the literal reading of "the first eight bytes
43//! as an i64" — is **not** order-preserving over oid bytes: an oid whose first
44//! byte is `0x80` or higher goes negative and sorts before every oid starting
45//! `0x00..0x7f`, which is roughly half the keyspace on the wrong side. The key
46//! here therefore flips the top bit, `(u64::from_be_bytes(first8) ^ (1 << 63)) as
47//! i64`, which maps unsigned order onto signed order exactly. Key rank is then
48//! oid-lexicographic rank.
49//!
50//! ## …and the parallel arrays are still not redundant
51//!
52//! With an order-preserving key it is tempting to drop both parallel arrays and
53//! read them off the rank. Only one of the two can go:
54//!
55//! * `ords` equals the rank for every index [`crate::sections::GitIndexBuilder`]
56//!   builds, because that is where the ordinal is defined and it numbers the same
57//!   oid-lexicographic sequence. It is still stored, because [`build_section`] is
58//!   the lower-level API and its contract does **not** require the caller's
59//!   ordinal to be a rank — the ordinal is the `__gunnar_reach__` bitmap space,
60//!   and a caller indexing a subset of a larger archive has ordinals from the
61//!   larger space. Dropping the array is 4 bytes per object and a narrower
62//!   contract; it is not free, and it is not done here.
63//! * `rows` is **not** the rank and cannot become it. A lookup row is a *chunk*
64//!   row: an object above `file_split_block_size` occupies several consecutive
65//!   rows, and the lookup covers **every** path in the archive, not only git
66//!   objects — an archive holding anything besides the object store has git rows
67//!   that are not contiguous at all. `rows` is monotonic in rank and equal to it
68//!   only in the special case of a single-chunk, git-only archive.
69
70use std::path::Path;
71
72use anyhow::{Result, bail, ensure};
73use znippy_common::read_reserved_section_bytes;
74use znippy_common::GUNNAR_OID_MODULE;
75use znippy_zoomies::stree::STree64Mmap;
76
77use crate::object::GitHashKind;
78
79pub const GIT_OID_MAGIC: [u8; 8] = *b"ZNPYGOID";
80/// Bumped 1 → 2 when the key became order-preserving. A v1 section holds the same
81/// bytes in the same places but sorted on a different key, so a v2 reader walking
82/// it would return wrong rows rather than fail — which is why the reader below
83/// requires an **exact** match instead of `<=`.
84pub const GIT_OID_VERSION: u32 = 2;
85const HEADER_LEN: usize = 24;
86
87/// Number of queries the pipelined batch path keeps in flight. 8 matched the
88/// stree bench sweet spot for i64 keys; it is a const-generic on the zoomies
89/// side, so changing it here is a one-token edit.
90const BATCH_P: usize = 8;
91
92/// One object as the index records it.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct OidEntry {
95    /// Raw object id.
96    pub oid: Vec<u8>,
97    /// First row of this object's contiguous chunk run in the sorted lookup
98    /// sub-index.
99    pub lookup_row: u64,
100    /// Position of this object in the archive's oid-lexicographic ordering —
101    /// the ordinal space `__gunnar_reach__` bitmaps address.
102    pub ordinal: u32,
103}
104
105/// The key an oid maps into: its first eight bytes as a big-endian unsigned
106/// integer, with the top bit flipped so that unsigned order becomes signed order.
107///
108/// The flip is the whole point — `stree` compares `i64`, and without it every oid
109/// starting `0x80` or higher sorts before every oid starting `0x00..0x7f`. See the
110/// module docs.
111///
112/// Oids shorter than eight bytes cannot occur (sha1 is 20), but the function is
113/// total anyway: it zero-pads rather than panicking.
114pub fn key_for_oid(oid: &[u8]) -> i64 {
115    let mut b = [0u8; 8];
116    let n = oid.len().min(8);
117    b[..n].copy_from_slice(&oid[..n]);
118    (u64::from_be_bytes(b) ^ (1u64 << 63)) as i64
119}
120
121/// Serialize the `__gunnar_oid__` section. `entries` may be in any order; they
122/// are sorted by key here, which is what `stree` requires.
123pub fn build_section(entries: &[OidEntry], hash: GitHashKind) -> Result<Vec<u8>> {
124    let oid_len = hash.oid_len();
125    for e in entries {
126        ensure!(
127            e.oid.len() == oid_len,
128            "oid length {} does not match hash kind {:?}",
129            e.oid.len(),
130            hash
131        );
132    }
133    let mut order: Vec<usize> = (0..entries.len()).collect();
134    // Sort by (key, full oid) so a duplicate-key run has a deterministic layout.
135    order.sort_by(|&a, &b| {
136        key_for_oid(&entries[a].oid)
137            .cmp(&key_for_oid(&entries[b].oid))
138            .then_with(|| entries[a].oid.cmp(&entries[b].oid))
139    });
140
141    let n = entries.len();
142    let mut out = Vec::with_capacity(HEADER_LEN + n * (8 + 8 + 4 + oid_len));
143    out.extend_from_slice(&GIT_OID_MAGIC);
144    out.extend_from_slice(&GIT_OID_VERSION.to_le_bytes());
145    out.push(hash.code());
146    out.push(oid_len as u8);
147    out.extend_from_slice(&0u16.to_le_bytes());
148    out.extend_from_slice(&(n as u64).to_le_bytes());
149    debug_assert_eq!(out.len(), HEADER_LEN);
150    for &i in &order {
151        out.extend_from_slice(&key_for_oid(&entries[i].oid).to_le_bytes());
152    }
153    for &i in &order {
154        out.extend_from_slice(&entries[i].lookup_row.to_le_bytes());
155    }
156    for &i in &order {
157        out.extend_from_slice(&entries[i].ordinal.to_le_bytes());
158    }
159    for &i in &order {
160        out.extend_from_slice(&entries[i].oid);
161    }
162    Ok(out)
163}
164
165/// What a successful lookup resolved to.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub struct OidHit {
168    /// Index of the entry within the index (its position in key order).
169    pub entry: usize,
170    /// First lookup row of the object's chunk run.
171    pub lookup_row: u64,
172    /// Object ordinal (the `__gunnar_reach__` bitmap space).
173    pub ordinal: u32,
174}
175
176/// Reader over a `__gunnar_oid__` section.
177pub struct GitOidIndex {
178    bytes: Vec<u8>,
179    count: usize,
180    oid_len: usize,
181    hash: GitHashKind,
182    /// `None` for an empty index — `STree64Mmap` requires `count > 0`.
183    tree: Option<STree64Mmap>,
184}
185
186impl GitOidIndex {
187    /// Parse a section produced by [`build_section`].
188    pub fn parse(bytes: Vec<u8>) -> Result<Self> {
189        ensure!(bytes.len() >= HEADER_LEN, "__gunnar_oid__ section truncated");
190        ensure!(bytes[..8] == GIT_OID_MAGIC, "__gunnar_oid__ bad magic");
191        let version = u32::from_le_bytes(bytes[8..12].try_into().unwrap());
192        ensure!(
193            version == GIT_OID_VERSION,
194            "__gunnar_oid__ is version {version}, this reader speaks {GIT_OID_VERSION} \
195             only — v1 sorted its keys on a non-order-preserving key, so reading one \
196             here would return wrong rows instead of failing"
197        );
198        let Some(hash) = GitHashKind::from_code(bytes[12]) else {
199            bail!("__gunnar_oid__ unknown hash code {}", bytes[12]);
200        };
201        let oid_len = bytes[13] as usize;
202        ensure!(
203            oid_len == hash.oid_len(),
204            "__gunnar_oid__ oid_len {oid_len} disagrees with hash {hash:?}"
205        );
206        let count = u64::from_le_bytes(bytes[16..24].try_into().unwrap()) as usize;
207        let need = HEADER_LEN
208            .checked_add(count.checked_mul(8 + 8 + 4 + oid_len).unwrap_or(usize::MAX))
209            .unwrap_or(usize::MAX);
210        ensure!(
211            bytes.len() >= need,
212            "__gunnar_oid__ declares {count} entries but section is {} bytes (needs {need})",
213            bytes.len()
214        );
215
216        let tree = if count == 0 {
217            None
218        } else {
219            let keys = &bytes[HEADER_LEN..HEADER_LEN + count * 8];
220            Some(STree64Mmap::new_with_stride(keys, count, 8))
221        };
222        Ok(Self { bytes, count, oid_len, hash, tree })
223    }
224
225    /// Read the section out of a sealed archive. `Ok(None)` when the archive
226    /// carries no oid index (i.e. it is not a `git`-format archive).
227    pub fn open(archive: &Path) -> Result<Option<Self>> {
228        match read_reserved_section_bytes(archive, GUNNAR_OID_MODULE)? {
229            Some(b) => Ok(Some(Self::parse(b)?)),
230            None => Ok(None),
231        }
232    }
233
234    pub fn len(&self) -> usize {
235        self.count
236    }
237
238    pub fn is_empty(&self) -> bool {
239        self.count == 0
240    }
241
242    pub fn hash_kind(&self) -> GitHashKind {
243        self.hash
244    }
245
246    fn keys(&self) -> &[u8] {
247        &self.bytes[HEADER_LEN..HEADER_LEN + self.count * 8]
248    }
249
250    /// The key of entry `i`.
251    pub fn key_at(&self, i: usize) -> i64 {
252        let off = HEADER_LEN + i * 8;
253        i64::from_le_bytes(self.bytes[off..off + 8].try_into().unwrap())
254    }
255
256    /// The full oid of entry `i`.
257    pub fn oid_at(&self, i: usize) -> &[u8] {
258        let base = HEADER_LEN + self.count * (8 + 8 + 4) + i * self.oid_len;
259        &self.bytes[base..base + self.oid_len]
260    }
261
262    fn row_at(&self, i: usize) -> u64 {
263        let off = HEADER_LEN + self.count * 8 + i * 8;
264        u64::from_le_bytes(self.bytes[off..off + 8].try_into().unwrap())
265    }
266
267    fn ordinal_at(&self, i: usize) -> u32 {
268        let off = HEADER_LEN + self.count * 16 + i * 4;
269        u32::from_le_bytes(self.bytes[off..off + 4].try_into().unwrap())
270    }
271
272    /// The **unverified** candidate run for a key: every entry sharing that
273    /// 8-byte prefix, as `start..end`. Normally length 1; length > 1 is a real
274    /// prefix collision.
275    ///
276    /// Exposed so a test can assert that a collision actually produces a run of
277    /// two and that the verify step is what tells the two oids apart. A caller
278    /// resolving an oid should use [`lookup`](Self::lookup), never this.
279    pub fn candidate_run(&self, key: i64) -> std::ops::Range<usize> {
280        let Some(tree) = self.tree.as_ref() else { return 0..0 };
281        let Some(pos) = tree.find_exact(key, self.keys()) else { return 0..0 };
282        self.expand_run(pos, key)
283    }
284
285    /// Widen a hit to the whole run of equal keys. `stree` routes to *a* member
286    /// of the run; which member is an implementation detail, so both directions
287    /// are walked rather than assumed.
288    fn expand_run(&self, pos: usize, key: i64) -> std::ops::Range<usize> {
289        let mut lo = pos;
290        while lo > 0 && self.key_at(lo - 1) == key {
291            lo -= 1;
292        }
293        let mut hi = pos + 1;
294        while hi < self.count && self.key_at(hi) == key {
295            hi += 1;
296        }
297        lo..hi
298    }
299
300    /// Resolve a raw oid. `None` when absent.
301    ///
302    /// stree narrows to a candidate run; the full oid is then compared against
303    /// every candidate. Skipping that comparison would return a *different*
304    /// object's row whenever two oids share their first eight bytes.
305    pub fn lookup(&self, oid: &[u8]) -> Option<OidHit> {
306        if oid.len() != self.oid_len {
307            return None;
308        }
309        let tree = self.tree.as_ref()?;
310        let key = key_for_oid(oid);
311        let pos = tree.find_exact(key, self.keys())?;
312        self.verify(pos, key, oid)
313    }
314
315    /// Resolve a hex oid.
316    pub fn lookup_hex(&self, hex_oid: &str) -> Option<OidHit> {
317        if hex_oid.len() != self.oid_len * 2 {
318            return None;
319        }
320        let raw = hex::decode(hex_oid).ok()?;
321        self.lookup(&raw)
322    }
323
324    fn verify(&self, pos: usize, key: i64, oid: &[u8]) -> Option<OidHit> {
325        for i in self.expand_run(pos, key) {
326            if self.oid_at(i) == oid {
327                return Some(OidHit {
328                    entry: i,
329                    lookup_row: self.row_at(i),
330                    ordinal: self.ordinal_at(i),
331                });
332            }
333        }
334        None
335    }
336
337    /// Resolve many oids at once through stree's software-pipelined batch
338    /// traversal. This is the path that matters: serving one pack is hundreds to
339    /// thousands of lookups, and the pipelined walk overlaps their memory
340    /// latency instead of paying it serially.
341    ///
342    /// Results are positional — `out[i]` corresponds to `oids[i]`. Every hit is
343    /// full-oid verified, exactly as in [`lookup`](Self::lookup).
344    pub fn lookup_batch(&self, oids: &[&[u8]]) -> Vec<Option<OidHit>> {
345        let Some(tree) = self.tree.as_ref() else { return vec![None; oids.len()] };
346        let keys: Vec<i64> = oids.iter().map(|o| key_for_oid(o)).collect();
347        let raw = tree.lookup_batch_pipeline::<BATCH_P>(&keys, self.keys());
348        raw.into_iter()
349            .zip(oids.iter())
350            .enumerate()
351            .map(|(i, (pos, oid))| {
352                if oid.len() != self.oid_len {
353                    return None;
354                }
355                self.verify(pos?, keys[i], oid)
356            })
357            .collect()
358    }
359
360    /// The **baseline** the stree keyspace has to beat: `std::binary_search` over
361    /// the very same sorted key array, followed by the very same full-oid verify.
362    ///
363    /// It exists only under `bench-kernels`, and it exists so the choice of stree
364    /// is a measurement rather than an argument. Anything cheaper than this would
365    /// not be the same question: it derives the key the same way, expands the
366    /// equal-key run the same way, and compares the same 20 or 32 bytes — the
367    /// only difference is how it finds the run.
368    #[cfg(feature = "bench-kernels")]
369    pub fn lookup_binary_search(&self, oid: &[u8]) -> Option<OidHit> {
370        if oid.len() != self.oid_len || self.count == 0 {
371            return None;
372        }
373        let key = key_for_oid(oid);
374        // The keys live little-endian in `bytes`; read them through `key_at` so
375        // there is one decoder, not two.
376        let mut lo = 0usize;
377        let mut hi = self.count;
378        while lo < hi {
379            let mid = lo + (hi - lo) / 2;
380            if self.key_at(mid) < key { lo = mid + 1 } else { hi = mid }
381        }
382        if lo >= self.count || self.key_at(lo) != key {
383            return None;
384        }
385        self.verify(lo, key, oid)
386    }
387
388    /// Hex convenience over [`lookup_batch`](Self::lookup_batch).
389    pub fn lookup_batch_hex(&self, hex_oids: &[&str]) -> Vec<Option<OidHit>> {
390        let raw: Vec<Vec<u8>> = hex_oids.iter().map(|h| hex::decode(h).unwrap_or_default()).collect();
391        let refs: Vec<&[u8]> = raw.iter().map(|v| v.as_slice()).collect();
392        self.lookup_batch(&refs)
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn oid(bytes: &[u8], len: usize) -> Vec<u8> {
401        let mut v = bytes.to_vec();
402        v.resize(len, 0);
403        v
404    }
405
406    fn idx(entries: Vec<OidEntry>, hash: GitHashKind) -> GitOidIndex {
407        GitOidIndex::parse(build_section(&entries, hash).unwrap()).unwrap()
408    }
409
410    #[test]
411    fn resolves_every_entry_it_was_built_from() {
412        // 300 entries → tall enough that stree has real internal layers.
413        let n = 300usize;
414        let entries: Vec<OidEntry> = (0..n)
415            .map(|i| {
416                let mut o = [0u8; 32];
417                o[..8].copy_from_slice(&(i as u64).wrapping_mul(0x0123_4567_89ab_cdef).to_be_bytes());
418                o[8] = (i % 251) as u8;
419                OidEntry { oid: o.to_vec(), lookup_row: (i * 3) as u64, ordinal: i as u32 }
420            })
421            .collect();
422        let index = idx(entries.clone(), GitHashKind::Sha256);
423        assert_eq!(index.len(), n);
424        for e in &entries {
425            let hit = index.lookup(&e.oid).unwrap_or_else(|| panic!("miss for {}", hex::encode(&e.oid)));
426            assert_eq!(hit.lookup_row, e.lookup_row);
427            assert_eq!(hit.ordinal, e.ordinal);
428        }
429        // And an oid that is NOT in the index must miss.
430        let mut absent = entries[0].oid.clone();
431        absent[31] ^= 0xff;
432        assert!(index.lookup(&absent).is_none());
433    }
434
435    /// LAW 2 — the collision case, constructed rather than hoped for.
436    ///
437    /// Two oids that agree on their first eight bytes and differ after. They
438    /// share one stree key, so the tree alone cannot tell them apart; only the
439    /// full-oid comparison can. The assertions below are on the *applied
440    /// output* (the two rows resolved), so an implementation that dropped the
441    /// verify and returned the first candidate would return the same row twice
442    /// and fail here.
443    #[test]
444    fn eight_byte_prefix_collision_is_resolved_by_the_full_oid() {
445        let prefix = [0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04];
446        let mut a = oid(&prefix, 32);
447        let mut b = oid(&prefix, 32);
448        a[8] = 0xaa;
449        b[8] = 0xbb;
450        assert_eq!(key_for_oid(&a), key_for_oid(&b), "test premise: keys must collide");
451        assert_ne!(a, b);
452
453        // Some filler so the tree is not a single leaf block.
454        let mut entries = vec![
455            OidEntry { oid: a.clone(), lookup_row: 100, ordinal: 7 },
456            OidEntry { oid: b.clone(), lookup_row: 200, ordinal: 9 },
457        ];
458        for i in 0..64u64 {
459            let mut o = [0u8; 32];
460            o[..8].copy_from_slice(&i.wrapping_mul(0x1111_1111_1111_1111).to_be_bytes());
461            o[9] = 1;
462            entries.push(OidEntry { oid: o.to_vec(), lookup_row: 900 + i, ordinal: 100 + i as u32 });
463        }
464        let index = idx(entries, GitHashKind::Sha256);
465
466        // The collision is real in the built index: one key, two candidates.
467        let run = index.candidate_run(key_for_oid(&a));
468        assert_eq!(run.len(), 2, "expected a 2-entry candidate run, got {run:?}");
469        assert_eq!(index.key_at(run.start), index.key_at(run.start + 1));
470
471        // Applied output: the two oids resolve to their OWN rows.
472        let ha = index.lookup(&a).expect("a must resolve");
473        let hb = index.lookup(&b).expect("b must resolve");
474        assert_eq!(ha.lookup_row, 100);
475        assert_eq!(hb.lookup_row, 200);
476        assert_eq!(ha.ordinal, 7);
477        assert_eq!(hb.ordinal, 9);
478        assert_ne!(ha.lookup_row, hb.lookup_row);
479
480        // A third oid on the same prefix that was never inserted must MISS —
481        // a verify-less lookup would happily hand back a candidate's row.
482        let mut c = oid(&prefix, 32);
483        c[8] = 0xcc;
484        assert!(index.lookup(&c).is_none(), "unstored oid on a colliding prefix must miss");
485    }
486
487    #[test]
488    fn batch_path_agrees_with_the_serial_path_including_on_a_collision() {
489        let prefix = [0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
490        let mut a = oid(&prefix, 20);
491        let mut b = oid(&prefix, 20);
492        a[8] = 1;
493        b[8] = 2;
494        let mut entries = vec![
495            OidEntry { oid: a.clone(), lookup_row: 11, ordinal: 1 },
496            OidEntry { oid: b.clone(), lookup_row: 22, ordinal: 2 },
497        ];
498        for i in 0..200u64 {
499            let mut o = [0u8; 20];
500            o[..8].copy_from_slice(&(i.wrapping_mul(0x9e37_79b9_7f4a_7c15)).to_be_bytes());
501            o[10] = (i % 97) as u8;
502            entries.push(OidEntry { oid: o.to_vec(), lookup_row: 1000 + i, ordinal: 500 + i as u32 });
503        }
504        let index = idx(entries.clone(), GitHashKind::Sha1);
505
506        let mut queries: Vec<&[u8]> = entries.iter().map(|e| e.oid.as_slice()).collect();
507        let absent = oid(&[0xab, 0xcd, 0xef, 0x00, 0x11, 0x22, 0x33, 0x44], 20);
508        queries.push(&absent);
509
510        let batched = index.lookup_batch(&queries);
511        assert_eq!(batched.len(), queries.len());
512        for (i, q) in queries.iter().enumerate() {
513            assert_eq!(batched[i], index.lookup(q), "batch/serial disagree at {i}");
514        }
515        assert!(batched.last().unwrap().is_none(), "absent oid must miss in the batch path too");
516        assert_eq!(batched[0].unwrap().lookup_row, 11);
517        assert_eq!(batched[1].unwrap().lookup_row, 22);
518    }
519
520    #[test]
521    fn empty_index_is_a_clean_miss_not_a_panic() {
522        let index = idx(Vec::new(), GitHashKind::Sha256);
523        assert!(index.is_empty());
524        assert!(index.lookup(&oid(&[1], 32)).is_none());
525        assert_eq!(index.lookup_batch(&[&oid(&[1], 32)[..]]), vec![None]);
526    }
527
528    #[test]
529    fn truncated_or_mislabelled_sections_are_rejected() {
530        let entries = vec![OidEntry { oid: oid(&[9], 20), lookup_row: 0, ordinal: 0 }];
531        let good = build_section(&entries, GitHashKind::Sha1).unwrap();
532        assert!(GitOidIndex::parse(good.clone()).is_ok());
533
534        let mut bad_magic = good.clone();
535        bad_magic[0] = b'X';
536        assert!(GitOidIndex::parse(bad_magic).is_err());
537
538        let mut newer = good.clone();
539        newer[8..12].copy_from_slice(&(GIT_OID_VERSION + 1).to_le_bytes());
540        assert!(GitOidIndex::parse(newer).is_err());
541
542        assert!(GitOidIndex::parse(good[..HEADER_LEN + 4].to_vec()).is_err());
543        assert!(GitOidIndex::parse(Vec::new()).is_err());
544    }
545
546    /// LAW 2 — the sign-bit trap, asserted on applied output.
547    ///
548    /// Half of all oids start `0x80..0xff`. Under the literal
549    /// `i64::from_be_bytes` key those sort *before* every oid starting
550    /// `0x00..0x7f`, so entry order is not oid order. This asserts entry `i` holds
551    /// the `i`-th oid lexicographically — which is exactly what fails if the top-
552    /// bit flip in [`key_for_oid`] is removed, and which a test built only from
553    /// low-byte oids could never see.
554    #[test]
555    fn entry_order_is_oid_lexicographic_across_the_sign_boundary() {
556        let firsts: [u8; 8] = [0x00, 0x7f, 0x80, 0xff, 0x01, 0xfe, 0x81, 0x7e];
557        let entries: Vec<OidEntry> = firsts
558            .iter()
559            .enumerate()
560            .map(|(i, &f)| {
561                let mut o = [0u8; 32];
562                o[0] = f;
563                o[1] = i as u8;
564                OidEntry { oid: o.to_vec(), lookup_row: i as u64, ordinal: i as u32 }
565            })
566            .collect();
567        let index = idx(entries.clone(), GitHashKind::Sha256);
568
569        let mut want: Vec<Vec<u8>> = entries.iter().map(|e| e.oid.clone()).collect();
570        want.sort();
571        for (i, w) in want.iter().enumerate() {
572            assert_eq!(
573                index.oid_at(i),
574                w.as_slice(),
575                "entry {i} is {} but the {i}-th oid lexicographically is {}",
576                hex::encode(index.oid_at(i)),
577                hex::encode(w)
578            );
579        }
580        // And the keys themselves must be ascending — stree requires it, and an
581        // unsorted keyspace is the failure that would otherwise surface as an
582        // occasional wrong row rather than an error.
583        for i in 1..index.len() {
584            assert!(
585                index.key_at(i - 1) < index.key_at(i),
586                "keys not ascending at {i}: {} then {}",
587                index.key_at(i - 1),
588                index.key_at(i)
589            );
590        }
591        // Every oid still resolves to its own row, sign bit or not.
592        for e in &entries {
593            assert_eq!(index.lookup(&e.oid).unwrap().lookup_row, e.lookup_row);
594        }
595    }
596
597    /// A v1 section must be refused, not silently misread: the layout is
598    /// identical and only the key ordering changed, so a `<=` version check would
599    /// hand back wrong rows without erroring.
600    #[test]
601    fn a_v1_section_is_refused_rather_than_misread() {
602        let entries = vec![OidEntry { oid: oid(&[0x80], 20), lookup_row: 3, ordinal: 0 }];
603        let mut v1 = build_section(&entries, GitHashKind::Sha1).unwrap();
604        v1[8..12].copy_from_slice(&1u32.to_le_bytes());
605        let err = match GitOidIndex::parse(v1) {
606            Ok(_) => panic!("a v1 section must be refused"),
607            Err(e) => e.to_string(),
608        };
609        assert!(err.contains("version 1"), "error must name the version: {err}");
610    }
611
612    #[test]
613    fn build_rejects_an_oid_of_the_wrong_width() {
614        let entries = vec![OidEntry { oid: oid(&[1], 20), lookup_row: 0, ordinal: 0 }];
615        assert!(build_section(&entries, GitHashKind::Sha256).is_err());
616    }
617}