gunnar-store 1.6.0

The gunnar server's storage seam: RepoStore (which repository), SecretStore (what has no git equivalent), and the one conformance suite both GitOps backends run
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
//! **The fixture packfile**, written by the suite so the suite knows its bytes.
//!
//! [`GitOps::put_pack`](git_storage_trait::GitOps::put_pack) takes a packfile and
//! nothing else, so a conformance suite for that contract has to produce one.
//! Producing it *here* rather than borrowing a writer is what makes three of the
//! contract's promises checkable at all:
//!
//! * [`Stored::bytes`](git_storage_trait::Stored) is *"the pack entry, header
//!   and all, byte for byte as the client sent them"*. Only a caller that knows
//!   which byte range it wrote can assert that.
//! * [`Stored::uncompressed_size`](git_storage_trait::Stored) is the
//!   **post-resolution** size, *"not `bytes.len()` for a delta"*. Only a fixture
//!   that deliberately contains a delta whose stored length differs from its
//!   resolved length can catch a backend that returns the wrong one — and that
//!   is a bug which passes every wire-byte and object-count test, because the
//!   number is only consulted by a quota gate.
//! * [`ObjType`] is the type *in the pack*, so an entry may be `OfsDelta`. A
//!   fixture of four plain objects can never see a backend that resolves and
//!   relabels.
//!
//! # What this writer deliberately is not
//!
//! It is not a general packfile writer and must never grow into one. It emits
//! **base entries and `OFS_DELTA` entries only** — no `REF_DELTA` (which
//! `gunnar_pack::index_pack` refuses and gitoxide's thin-pack resolver cannot
//! close in-pack), no thin packs, no delta
//! *compression* worth the name: every delta here is one insert instruction over
//! the whole result. The delta exists to make the entry's stored length differ
//! from its resolved length, which is the only property the contract talks about.
//!
//! # One implementation: this writer and the in-crate reader agree by construction
//!
//! [`crate::mem::MemGitOps`] parses what this writes, and both were written in
//! the same sitting by the same hand, so their agreeing is worth nothing on its
//! own. The outside oracle is `gunnar_pack::index_pack`, a dev-dependency with
//! no stake in either, driven over these exact bytes by
//! `the_fixture_pack_is_a_real_packfile` in `tests/conformance.rs`. That test
//! going red means this module has stopped emitting git's format, whatever the
//! rest of the suite says.

use std::io::Write;

use flate2::write::ZlibEncoder;
use flate2::Compression;
use git_storage_trait::{Extent, ObjType};
use gix_hash::ObjectId;
use gix_object::Kind;

use crate::error::{Error, Result};

/// One entry of a fixture pack, and everything the suite must be able to assert
/// about it.
#[derive(Debug, Clone)]
pub struct Entry {
    /// The object's id — the id git would give it, computed by `gix-object` so
    /// that no part of gunnar invents one (one implementation).
    pub id: ObjectId,
    /// The object's git kind, after any delta is resolved.
    pub kind: Kind,
    /// The entry's type **in the pack**. `OfsDelta` for a delta entry, so a
    /// backend that hands back a resolved type is caught.
    pub obj_type: ObjType,
    /// The object's content, delta chain applied. This is what a git object of
    /// [`Entry::kind`] holds, without the `<kind> <len>\0` loose header.
    pub content: Vec<u8>,
    /// Where this entry's bytes are in the pack: `(offset, len)`.
    ///
    /// **Not** what [`GitOps::extents`](git_storage_trait::GitOps::extents) must
    /// return. The contract says an extent's unit is *"wherever this backend
    /// keeps the bytes"* and promises only that the pair is stable, so the suite
    /// never compares a backend's extent against this one.
    pub extent: Extent,
}

impl Entry {
    /// The verbatim entry bytes, sliced out of the pack that carries them.
    ///
    /// This is what `Stored::bytes` promises back.
    pub fn bytes<'a>(&self, pack: &'a [u8]) -> &'a [u8] {
        let (offset, len) = self.extent;
        &pack[offset as usize..(offset + len) as usize]
    }
}

/// A packfile and the ground truth about what is inside it.
#[derive(Debug, Clone)]
pub struct Fixture {
    /// The pack, ready for `put_pack`.
    pub bytes: Vec<u8>,
    /// Its entries, in pack order.
    pub entries: Vec<Entry>,
}

impl Fixture {
    /// The entry for `id`, if this pack carries it.
    pub fn entry(&self, id: &gix_hash::oid) -> Option<&Entry> {
        self.entries.iter().find(|e| e.id == id)
    }

    /// Every object id in the pack, in pack order.
    pub fn ids(&self) -> Vec<ObjectId> {
        self.entries.iter().map(|e| e.id).collect()
    }
}

enum Item {
    Base {
        kind: Kind,
        content: Vec<u8>,
    },
    /// A delta whose base is the item at this index. The content is the
    /// **result** — the delta instructions are derived from it.
    Delta {
        base: usize,
        content: Vec<u8>,
    },
}

/// Builds a [`Fixture`].
pub struct PackBuilder {
    hash_kind: gix_hash::Kind,
    items: Vec<Item>,
    level: Compression,
}

impl PackBuilder {
    /// A builder for a pack of `hash_kind`.
    pub fn new(hash_kind: gix_hash::Kind) -> Self {
        PackBuilder {
            hash_kind,
            items: Vec::new(),
            level: Compression::default(),
        }
    }

    /// **The zlib level every entry is written at** — `Compression::default()` unless a caller
    /// says otherwise, so every existing fixture is byte-for-byte what it was.
    ///
    /// For a fixture of incompressible bytes, where the level buys nothing and costs
    /// everything: MEASURED in `gunnar-server/tests/twin_snapshot_memory.rs`, deflating 512 MiB
    /// of random blobs at the default level took ~250 s of a debug test binary's 376, and
    /// produced a pack no smaller. `Compression::none()` writes stored blocks — a legal pack
    /// that every reader inflates like any other.
    #[must_use]
    pub fn compression(mut self, level: Compression) -> Self {
        self.level = level;
        self
    }

    /// Append a whole object. Returns its index, for [`PackBuilder::delta`].
    pub fn object(&mut self, kind: Kind, content: impl Into<Vec<u8>>) -> usize {
        self.items.push(Item::Base {
            kind,
            content: content.into(),
        });
        self.items.len() - 1
    }

    /// Append an `OFS_DELTA` entry against an earlier item, resolving to
    /// `content`.
    ///
    /// The delta's kind is the base's kind: a delta entry carries no type of its
    /// own, which is exactly why `ObjType` distinguishes `OfsDelta` from the four
    /// real kinds.
    pub fn delta(&mut self, base: usize, content: impl Into<Vec<u8>>) -> usize {
        assert!(base < self.items.len(), "a delta's base must precede it");
        self.items.push(Item::Delta {
            base,
            content: content.into(),
        });
        self.items.len() - 1
    }

    /// Write the pack.
    pub fn build(self) -> Result<Fixture> {
        let mut bytes = Vec::new();
        bytes.extend_from_slice(b"PACK");
        bytes.extend_from_slice(&2u32.to_be_bytes());
        bytes.extend_from_slice(&(self.items.len() as u32).to_be_bytes());

        let mut entries: Vec<Entry> = Vec::with_capacity(self.items.len());
        for item in &self.items {
            let start = bytes.len() as u64;
            let (kind, content, obj_type, payload) = match item {
                Item::Base { kind, content } => {
                    (*kind, content.clone(), obj_type_of(*kind), content.clone())
                }
                Item::Delta { base, content } => {
                    let base_entry = &entries[*base];
                    let payload = encode_delta(&base_entry.content, content);
                    (base_entry.kind, content.clone(), ObjType::OfsDelta, payload)
                }
            };

            // The size in an entry header is the length of the entry's own
            // uncompressed data — the delta stream for a delta, not the object
            // it resolves to. Getting this backwards produces a pack that
            // inflates correctly and indexes wrong.
            write_entry_header(&mut bytes, obj_type, payload.len() as u64);
            if let Item::Delta { base, .. } = item {
                let distance = start - entries[*base].extent.0;
                write_offset(&mut bytes, distance);
            }
            deflate_at(&mut bytes, &payload, self.level)?;

            let id = gix_object::compute_hash(self.hash_kind, kind, &content)?;
            entries.push(Entry {
                id,
                kind,
                obj_type,
                content,
                extent: (start, bytes.len() as u64 - start),
            });
        }

        let mut hasher = gix_hash::hasher(self.hash_kind);
        hasher.update(&bytes);
        let trailer = hasher.try_finalize()?;
        bytes.extend_from_slice(trailer.as_slice());

        Ok(Fixture { bytes, entries })
    }
}

/// The [`ObjType`] a resolved git kind is stored under. The inverse of
/// [`kind_of`] over the four non-delta codes.
pub fn obj_type_of(kind: Kind) -> ObjType {
    match kind {
        Kind::Commit => ObjType::Commit,
        Kind::Tree => ObjType::Tree,
        Kind::Blob => ObjType::Blob,
        Kind::Tag => ObjType::Tag,
    }
}

/// The git kind an [`ObjType`] names, or `None` for the two delta codes.
pub fn kind_of(obj_type: ObjType) -> Option<Kind> {
    Some(match obj_type {
        ObjType::Commit => Kind::Commit,
        ObjType::Tree => Kind::Tree,
        ObjType::Blob => Kind::Blob,
        ObjType::Tag => Kind::Tag,
        ObjType::OfsDelta | ObjType::RefDelta => return None,
    })
}

// ───────────────────────────── the format ────────────────────────────────────

/// git's entry header: three type bits and a base-128 size, low nibble first.
///
/// `pub(crate)` because [`crate::mem::MemGitServe`]'s `emit_pack` writes entries
/// too, and two spellings of git's entry header in one crate is exactly the
/// twinning one implementation forbids — the emitter and the fixture writer agree because
/// they are the same function.
pub(crate) fn write_entry_header(out: &mut Vec<u8>, obj_type: ObjType, mut size: u64) {
    let mut byte = (obj_type.code() << 4) | (size as u8 & 0x0f);
    size >>= 4;
    while size > 0 {
        out.push(byte | 0x80);
        byte = size as u8 & 0x7f;
        size >>= 7;
    }
    out.push(byte);
}

/// git's `OFS_DELTA` distance: big-endian base-128, biased so no encoding is
/// wasted on a repeated prefix.
///
/// `pub(crate)` for the same reason as [`write_entry_header`]: the emitter
/// rewrites this field when it moves an entry into a new pack, and it must
/// write the encoding [`read_offset`] reads.
pub(crate) fn write_offset(out: &mut Vec<u8>, mut value: u64) {
    let mut buf = vec![(value & 0x7f) as u8];
    value >>= 7;
    while value > 0 {
        value -= 1;
        buf.push(0x80 | (value & 0x7f) as u8);
        value >>= 7;
    }
    buf.reverse();
    out.extend_from_slice(&buf);
}

/// git's delta-stream size varint: little-endian base-128.
fn write_varint(out: &mut Vec<u8>, mut value: u64) {
    loop {
        let byte = (value & 0x7f) as u8;
        value >>= 7;
        if value == 0 {
            out.push(byte);
            return;
        }
        out.push(byte | 0x80);
    }
}

/// A delta that reproduces `result` with insert instructions only.
///
/// Deliberately the dumbest possible encoding. A real deltifier emits copy
/// instructions against the base and that is what makes packs small; here the
/// only thing that matters is that the *stored* length differs from the
/// *resolved* length, so that a backend reporting one where the contract asks
/// for the other is caught.
fn encode_delta(base: &[u8], result: &[u8]) -> Vec<u8> {
    let mut out = Vec::new();
    write_varint(&mut out, base.len() as u64);
    write_varint(&mut out, result.len() as u64);
    for chunk in result.chunks(0x7f) {
        out.push(chunk.len() as u8);
        out.extend_from_slice(chunk);
    }
    out
}

/// `pub(crate)` for the same reason as [`write_entry_header`]: the in-memory
/// emitter rebuilds an entry whose delta base the request does not carry, and a
/// rebuilt entry has to be deflated with the encoder the reader below expects.
pub(crate) fn deflate_into(out: &mut Vec<u8>, payload: &[u8]) -> Result<()> {
    deflate_at(out, payload, Compression::default())
}

fn deflate_at(out: &mut Vec<u8>, payload: &[u8], level: Compression) -> Result<()> {
    let mut encoder = ZlibEncoder::new(Vec::new(), level);
    encoder.write_all(payload)?;
    out.extend_from_slice(&encoder.finish()?);
    Ok(())
}

// ───────────────────────────── reading it back ───────────────────────────────

/// One entry as a reader sees it, before any delta is resolved.
#[derive(Debug, Clone)]
pub struct RawEntry {
    /// The entry's pack type.
    pub obj_type: ObjType,
    /// For an `OFS_DELTA`, the absolute offset of its base entry.
    pub base_offset: Option<u64>,
    /// The entry's inflated payload: the object's content, or the delta stream.
    pub payload: Vec<u8>,
    /// Where the whole entry lives in the pack, header included.
    pub extent: Extent,
    /// How many bytes of [`RawEntry::extent`] are header — the type/size varint
    /// plus, for an `OFS_DELTA`, its base distance. The deflated payload is the
    /// rest.
    ///
    /// Recorded because a **pack-copying** emitter needs exactly this split: an
    /// `OFS_DELTA`'s distance is relative to where its base sits in *this* pack,
    /// so re-emitting the entry means rewriting those leading bytes and copying
    /// the zlib stream after them byte for byte. Without the boundary the only
    /// way to re-emit is to inflate and deflate again, which is the 18.4x
    /// fallback [`git_storage_trait::PackStats::recompressed`] exists to count.
    pub header_len: u64,
}

/// Parse a pack written by [`PackBuilder`].
///
/// Used by [`crate::mem::MemGitOps`]. It handles exactly the subset this module
/// emits: version 2, base entries and `OFS_DELTA`, no `REF_DELTA`. A backend is
/// never asked to use this — it exists so the suite has something to be
/// self-tested against.
pub fn parse(pack: &[u8], hash_kind: gix_hash::Kind) -> Result<Vec<RawEntry>> {
    let trailer_len = hash_kind.len_in_bytes();
    if pack.len() < 12 + trailer_len || &pack[..4] != b"PACK" {
        return Err(Error::backend("not a packfile"));
    }
    let version = u32::from_be_bytes(pack[4..8].try_into().expect("four bytes"));
    if version != 2 {
        return Err(Error::backend(format!("pack version {version}, want 2")));
    }
    let count = u32::from_be_bytes(pack[8..12].try_into().expect("four bytes"));

    let body_end = pack.len() - trailer_len;
    let mut hasher = gix_hash::hasher(hash_kind);
    hasher.update(&pack[..body_end]);
    if hasher.try_finalize()?.as_slice() != &pack[body_end..] {
        return Err(Error::backend("the pack trailer does not match its bytes"));
    }

    let mut out = Vec::with_capacity(count as usize);
    let mut pos = 12usize;
    for _ in 0..count {
        let start = pos;
        let (obj_type, size) = read_entry_header(pack, &mut pos)?;
        let base_offset = if obj_type == ObjType::OfsDelta {
            let distance = read_offset(pack, &mut pos)?;
            Some(
                (start as u64)
                    .checked_sub(distance)
                    .ok_or_else(|| Error::backend("an OFS_DELTA points before the pack"))?,
            )
        } else if obj_type == ObjType::RefDelta {
            return Err(Error::backend(
                "REF_DELTA is outside this fixture reader's subset",
            ));
        } else {
            None
        };
        let header_len = (pos - start) as u64;
        let payload = inflate(&pack[pos..body_end], size as usize, &mut pos)?;
        out.push(RawEntry {
            obj_type,
            base_offset,
            payload,
            extent: (start as u64, (pos - start) as u64),
            header_len,
        });
    }
    Ok(out)
}

fn read_entry_header(pack: &[u8], pos: &mut usize) -> Result<(ObjType, u64)> {
    let mut byte = *pack
        .get(*pos)
        .ok_or_else(|| Error::backend("truncated entry header"))?;
    *pos += 1;
    let code = (byte >> 4) & 0x07;
    let obj_type =
        ObjType::from_code(code).ok_or_else(|| Error::backend(format!("object type {code}")))?;
    let mut size = u64::from(byte & 0x0f);
    let mut shift = 4;
    while byte & 0x80 != 0 {
        byte = *pack
            .get(*pos)
            .ok_or_else(|| Error::backend("truncated entry header"))?;
        *pos += 1;
        size |= u64::from(byte & 0x7f) << shift;
        shift += 7;
    }
    Ok((obj_type, size))
}

fn read_offset(pack: &[u8], pos: &mut usize) -> Result<u64> {
    let mut byte = *pack
        .get(*pos)
        .ok_or_else(|| Error::backend("truncated delta offset"))?;
    *pos += 1;
    let mut value = u64::from(byte & 0x7f);
    while byte & 0x80 != 0 {
        byte = *pack
            .get(*pos)
            .ok_or_else(|| Error::backend("truncated delta offset"))?;
        *pos += 1;
        value = ((value + 1) << 7) | u64::from(byte & 0x7f);
    }
    Ok(value)
}

fn inflate(rest: &[u8], expected: usize, pos: &mut usize) -> Result<Vec<u8>> {
    use std::io::Read;
    let mut decoder = flate2::read::ZlibDecoder::new(rest);
    let mut payload = Vec::with_capacity(expected);
    decoder.read_to_end(&mut payload)?;
    if payload.len() != expected {
        return Err(Error::backend(format!(
            "entry header claims {expected} bytes and {} inflated",
            payload.len()
        )));
    }
    *pos += decoder.total_in() as usize;
    Ok(payload)
}

/// Apply a git delta stream to `base`.
pub fn apply_delta(base: &[u8], delta: &[u8]) -> Result<Vec<u8>> {
    let mut pos = 0usize;
    let base_size = read_varint(delta, &mut pos)?;
    if base_size as usize != base.len() {
        return Err(Error::backend(format!(
            "the delta expects a {base_size}-byte base and the base is {}",
            base.len()
        )));
    }
    let result_size = read_varint(delta, &mut pos)?;
    let mut out = Vec::with_capacity(result_size as usize);
    while pos < delta.len() {
        let op = delta[pos];
        pos += 1;
        if op & 0x80 != 0 {
            // Copy from the base. Never emitted by `encode_delta`, but a reader
            // that silently ignored it would turn a wrong pack into a wrong
            // object rather than into an error.
            let mut offset = 0u64;
            let mut len = 0u64;
            for i in 0..4 {
                if op & (1 << i) != 0 {
                    offset |= u64::from(delta[pos]) << (8 * i);
                    pos += 1;
                }
            }
            for i in 0..3 {
                if op & (1 << (4 + i)) != 0 {
                    len |= u64::from(delta[pos]) << (8 * i);
                    pos += 1;
                }
            }
            if len == 0 {
                len = 0x10000;
            }
            let (from, to) = (offset as usize, (offset + len) as usize);
            out.extend_from_slice(
                base.get(from..to)
                    .ok_or_else(|| Error::backend("a delta copies past the end of its base"))?,
            );
        } else if op == 0 {
            return Err(Error::backend("delta opcode 0 is reserved"));
        } else {
            let (from, to) = (pos, pos + op as usize);
            out.extend_from_slice(
                delta
                    .get(from..to)
                    .ok_or_else(|| Error::backend("a delta insert runs past the stream"))?,
            );
            pos = to;
        }
    }
    if out.len() as u64 != result_size {
        return Err(Error::backend(format!(
            "the delta claims a {result_size}-byte result and produced {}",
            out.len()
        )));
    }
    Ok(out)
}

fn read_varint(bytes: &[u8], pos: &mut usize) -> Result<u64> {
    let mut value = 0u64;
    let mut shift = 0;
    loop {
        let byte = *bytes
            .get(*pos)
            .ok_or_else(|| Error::backend("truncated delta varint"))?;
        *pos += 1;
        value |= u64::from(byte & 0x7f) << shift;
        if byte & 0x80 == 0 {
            return Ok(value);
        }
        shift += 7;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The round trip, and the one property the delta is here for: the entry's
    /// stored length is not its resolved length.
    #[test]
    fn a_delta_entry_stores_something_other_than_what_it_resolves_to() {
        let mut builder = PackBuilder::new(gix_hash::Kind::Sha256);
        let base = builder.object(Kind::Blob, b"the base blob\n".to_vec());
        builder.delta(
            base,
            b"a longer resolved result, not the delta stream\n".to_vec(),
        );
        let fixture = builder.build().expect("build");

        let raw = parse(&fixture.bytes, gix_hash::Kind::Sha256).expect("parse");
        assert_eq!(raw.len(), 2);
        assert_eq!(raw[1].obj_type, ObjType::OfsDelta);
        let resolved = apply_delta(&raw[0].payload, &raw[1].payload).expect("apply");
        assert_eq!(resolved, fixture.entries[1].content);
        assert_ne!(
            raw[1].payload.len(),
            resolved.len(),
            "the fixture delta must store a different number of bytes than it resolves to, \
             or the post-resolution-size check below is measuring nothing"
        );
    }

    #[test]
    fn every_entry_reads_back_at_the_extent_the_builder_recorded() {
        let mut builder = PackBuilder::new(gix_hash::Kind::Sha1);
        builder.object(Kind::Blob, b"one".to_vec());
        builder.object(Kind::Blob, vec![7u8; 100_000]);
        builder.object(Kind::Tree, Vec::new());
        let fixture = builder.build().expect("build");
        let raw = parse(&fixture.bytes, gix_hash::Kind::Sha1).expect("parse");
        for (entry, raw) in fixture.entries.iter().zip(&raw) {
            assert_eq!(entry.extent, raw.extent);
            assert_eq!(entry.content, raw.payload);
        }
    }

    /// A wrong trailer is an error, not a shrug. Without this the reader would
    /// happily accept a corrupted pack and the suite would blame the backend.
    #[test]
    fn a_corrupted_pack_is_refused() {
        let mut builder = PackBuilder::new(gix_hash::Kind::Sha1);
        builder.object(Kind::Blob, b"intact".to_vec());
        let mut fixture = builder.build().expect("build");
        let last = fixture.bytes.len() - 1;
        fixture.bytes[last] ^= 0xff;
        assert!(parse(&fixture.bytes, gix_hash::Kind::Sha1).is_err());
    }
}