alkblobs 0.1.0

Content-addressed blob storage: multi-hash put/get/verify store with pluggable backends, transport- and protocol-agnostic
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
//! The hash side of the content-address layer (ADR-002;
//! hashing-and-keys.md): the algorithm family, the canonical git-blob
//! derivation, and the [`Digest`] value type.
//!
//! **One-way door.** The algorithm bytes and digest lengths this
//! module defines are persisted in backend rows and `local`-engine
//! filenames the moment data is written, with no schema-migration
//! story; they are implemented byte-exactly to ADR-002 and reviewed
//! line-by-line against it (AGENTS.md convention 5). The key bytes are
//! frozen once a backend ships data.
//!
//! **The preamble as abstraction.** An algorithm = a derivation + a
//! digest. The blob members derive git's oid form
//! `H("blob <len>\0" + content)` — the preamble is the derivation's
//! domain separation, not a special case: the length lands *inside*
//! the hashed input for all blob algorithms, which is what forces the
//! two put paths (ADR-003) and caps verification at whole-blob
//! (ADR-006) — both accepted rather than worked around. Known-length
//! derivation ([`Digest::derive`] / [`Algorithm::derive`]) is the
//! primary constructor; the unknown-length path's
//! derivation-over-staged-bytes restart is wave 2 facade logic
//! (ADR-003), not here.
//!
//! **The git-object derivation family (ADR-018).** The registry also
//! carries [`registry::GIT_OBJECT_SHA1`] / [`registry::GIT_OBJECT_SHA256`]
//! — a single derivation family (git's oid function is uniform across
//! kinds), registered for git's non-blob objects (trees, commits,
//! tags). Its derivation is **preimage-identity**: the stored preimage
//! is git's loose-object form (`"<type> <len>\0" + content` — the
//! preamble lives inside the value), so [`Algorithm::derive`] for a
//! family member is the plain hash of `content` (the `len` parameter
//! is inert), and the digest is byte-identical to git's oid of the
//! object, every kind included. This is ADR-002's annotated exception
//! (the domain separation arrives inside the caller-provided
//! preimage); its invariants — digest length fixed per algorithm,
//! rejects-never-guesses key decode, total order, verification-on-get
//! — survive verbatim.
//!
//! **Hash policy.** Two derivation families, per ADR-002's three
//! classes as amended by ADR-018: the blob family
//! ([`Algorithm::GitBlobSha256`] canonical, [`Algorithm::GitBlobSha1`]
//! tolerated legacy — git's own collision-hardened SHA-1 threat model
//! inherited verbatim) and the git-object family
//! ([`Algorithm::GitObjectSha1`] / [`Algorithm::GitObjectSha256`]).
//! BLAKE3 is absent from the crate entirely (ADR-002 Decision class 3).

use sha1::Sha1;
use sha2::{Digest as _, Sha256};

use crate::Error;

/// The ADR-002 key-encoding registry (ADR-002 §Key encoding).
///
/// Algorithm bytes are assigned from this registry; a new algorithm is
/// a new ADR (another one-way-door byte). `RESERVED` (`0x00`) is
/// reserved and never assigned — a future encoding revision would need
/// a different key universe or a migration ADR.
pub mod registry {
    /// `git-blob-sha-256` — 32-byte digest (total key length 33).
    pub const GIT_BLOB_SHA256: u8 = 0x01;

    /// `git-blob-sha-1` — 20-byte digest (total key length 21).
    pub const GIT_BLOB_SHA1: u8 = 0x02;

    /// `git-object-sha-1` — 20-byte digest (total key length 21). The
    /// git-object derivation family (ADR-018): the stored preimage is
    /// git's own loose-object form (`"<type> <len>\0" + content`), so
    /// the digest is the plain SHA-1 over it — exactly git's oid of the
    /// object, every kind included.
    pub const GIT_OBJECT_SHA1: u8 = 0x03;

    /// `git-object-sha-256` — 32-byte digest (total key length 33). The
    /// sha-256 sibling of [`GIT_OBJECT_SHA1`], registered in advance so
    /// the object-format=sha256 passthrough lands without renumbering.
    pub const GIT_OBJECT_SHA256: u8 = 0x04;

    /// Reserved and never assigned; unknown-algorithm and this tag are
    /// rejects, never guesses.
    pub const RESERVED: u8 = 0x00;
}

/// The hash algorithm of a [`Digest`] (ADR-002 §Hash policy; ADR-018).
///
/// The variant order matches the registry tag order
/// ([`registry::GIT_BLOB_SHA256`] `0x01` < [`registry::GIT_BLOB_SHA1`]
/// `0x02` < [`registry::GIT_OBJECT_SHA1`] `0x03` <
/// [`registry::GIT_OBJECT_SHA256`] `0x04`; ADR-018 §Decision 1), so
/// derived ordering over algorithm-then-digest pairs is the keys'
/// total order in byte form (required for the sweep's live-set diffs
/// over range scans; ADR-002/ADR-005).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Algorithm {
    /// Canonical (ADR-002 Decision class 1): `H("blob <len>\0" +
    /// content)` under SHA-256; one algorithm domain = one address
    /// space (ADR-005).
    GitBlobSha256,
    /// Tolerated legacy (ADR-002 Decision class 2): the same
    /// derivation under SHA-1, for existing-repo interop.
    GitBlobSha1,
    /// The git-object family (ADR-018): the digest is the plain SHA-1
    /// over the *stored preimage* — git's loose-object form
    /// `"<type> <len>\0" + content` — which makes the digest
    /// byte-identical to git's oid of the object, every kind included.
    /// The preamble git derives over already lives in the stored
    /// value; what this crate enforces is exactly git's own oid
    /// function (ADR-002's annotated exception — git's header is the
    /// domain separation, inside the preimage the caller provides).
    /// The put path verifies the declared length against the stream;
    /// git's header carries the length inside the preimage, so
    /// [`Algorithm::derive`] ignores the `len` parameter here.
    GitObjectSha1,
    /// The sha-256 sibling of [`Algorithm::GitObjectSha1`] (ADR-018
    /// §Decision 1 — registered in advance for the
    /// object-format=sha256 passthrough).
    GitObjectSha256,
}

impl Algorithm {
    /// The algorithm's registry byte (ADR-002 §Key encoding).
    pub const fn byte(self) -> u8 {
        match self {
            Self::GitBlobSha256 => registry::GIT_BLOB_SHA256,
            Self::GitBlobSha1 => registry::GIT_BLOB_SHA1,
            Self::GitObjectSha1 => registry::GIT_OBJECT_SHA1,
            Self::GitObjectSha256 => registry::GIT_OBJECT_SHA256,
        }
    }

    /// The registry byte back into an algorithm. Unknown bytes —
    /// including reserved `0x00` — are `None`; callers reject, never
    /// guess.
    pub const fn try_from_byte(byte: u8) -> Option<Self> {
        match byte {
            registry::GIT_BLOB_SHA256 => Some(Self::GitBlobSha256),
            registry::GIT_BLOB_SHA1 => Some(Self::GitBlobSha1),
            registry::GIT_OBJECT_SHA1 => Some(Self::GitObjectSha1),
            registry::GIT_OBJECT_SHA256 => Some(Self::GitObjectSha256),
            _ => None,
        }
    }

    /// The fixed digest length (`32` / `20`); total key lengths 33/21.
    pub const fn digest_len(self) -> usize {
        match self {
            Self::GitBlobSha256 | Self::GitObjectSha256 => 32,
            Self::GitBlobSha1 | Self::GitObjectSha1 => 20,
        }
    }

    /// The algorithm's known-length derivation (ADR-002 §The preamble
    /// as abstraction; ADR-018 for the family): for the blob members,
    /// `H("blob <len>\0" + content)` — the preamble is built the same
    /// way for both, the hasher is the only per-algorithm choice. For
    /// the git-object family, the **plain hash of `content`** — the
    /// stored preimage *is* git's loose-object form (the preamble
    /// lives inside the value), so this arm ignores the `len`
    /// parameter (the uniform-signature quirk ADR-018 §Neutral keeps
    /// deliberately; see [`registry::GIT_OBJECT_SHA1`]).
    pub fn derive(self, content: &[u8], len: u64) -> Digest {
        let bytes = match self {
            Self::GitBlobSha256 => blob_digest::<Sha256>(&blob_preamble(len), content),
            Self::GitBlobSha1 => blob_digest::<Sha1>(&blob_preamble(len), content),
            Self::GitObjectSha1 => blob_digest::<Sha1>(&[], content),
            Self::GitObjectSha256 => blob_digest::<Sha256>(&[], content),
        };
        Digest::from_parts(self, bytes)
    }
}

/// The derivation's preamble: `"blob <len>\0"` (git's format, shared
/// by both algorithms).
fn blob_preamble(len: u64) -> Vec<u8> {
    format!("blob {len}\0").into_bytes()
}

/// The shared derivation body: feed the preamble, then the content,
/// to one hasher — no algorithm special-cases the input.
fn blob_digest<H>(preamble: &[u8], content: &[u8]) -> Vec<u8>
where
    H: sha2::Digest,
{
    let mut hasher = H::new();
    hasher.update(preamble);
    hasher.update(content);
    hasher.finalize().to_vec()
}

/// The streaming form of the derivation: one hasher fed the preamble
/// at initialization (`"blob <len>\0"`, the length known up front on
/// the known-length put path) and the content as it flows. The finish
/// is byte-identical to [`Algorithm::derive`] over the same input —
/// this is the same derivation, driven incrementally so content can
/// stream once (ADR-003's put paths; the wave-2 facade logic this
/// module's doc anticipated).
///
/// Crate-internal: the derivation is a store-core concern; this type
/// is the put path's tool, not public API.
pub(crate) struct BlobHasher {
    algorithm: Algorithm,
    inner: InnerHasher,
}

enum InnerHasher {
    Sha256(Sha256),
    Sha1(Sha1),
}

impl BlobHasher {
    /// A hasher initialized with the preamble for an artifact of `len`
    /// bytes — the known-length path's one-pass form (the preamble is
    /// known before content flows). The git-object family feeds no
    /// preamble (the loose-object header is inside the stored
    /// preimage; ADR-018); its `len` is the preimage length, verified
    /// against the stream by the put path like the blob family's is.
    pub(crate) fn new(algorithm: Algorithm, len: u64) -> BlobHasher {
        let mut hasher = BlobHasher {
            algorithm,
            inner: match algorithm {
                Algorithm::GitBlobSha256 => InnerHasher::Sha256(Sha256::new()),
                Algorithm::GitBlobSha1 => InnerHasher::Sha1(Sha1::new()),
                Algorithm::GitObjectSha1 => InnerHasher::Sha1(Sha1::new()),
                Algorithm::GitObjectSha256 => InnerHasher::Sha256(Sha256::new()),
            },
        };
        if !matches!(
            algorithm,
            Algorithm::GitObjectSha1 | Algorithm::GitObjectSha256
        ) {
            hasher.update(&blob_preamble(len));
        }
        hasher
    }

    /// Feed the next chunk of content.
    pub(crate) fn update(&mut self, chunk: &[u8]) {
        match &mut self.inner {
            InnerHasher::Sha256(inner) => inner.update(chunk),
            InnerHasher::Sha1(inner) => inner.update(chunk),
        }
    }

    /// The derivation's answer over preamble + everything fed so far —
    /// pinned equal to [`Algorithm::derive`] by the in-module tests.
    pub(crate) fn finish(self) -> Digest {
        let bytes = match self.inner {
            InnerHasher::Sha256(inner) => inner.finalize().to_vec(),
            InnerHasher::Sha1(inner) => inner.finalize().to_vec(),
        };
        Digest::from_parts(self.algorithm, bytes)
    }
}

/// An algorithm + digest pair — the hash of a blob under one of this
/// crate's derivations (ADR-002; hashing-and-keys.md §The
/// Key/Digest surface).
///
/// Digest ordering is the key total order (algorithm registry byte
/// first, then digest bytes — see [`Algorithm`]).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Digest {
    algorithm: Algorithm,
    bytes: Vec<u8>,
}

impl Digest {
    /// Known-length canonical derivation: [`Digest`] under
    /// [`Algorithm::GitBlobSha256`], `H("blob <len>\0" + content)`.
    /// The primary constructor; see [`Algorithm::derive`] for the
    /// legacy variant.
    pub fn derive(content: &[u8], len: u64) -> Digest {
        Algorithm::derive(Algorithm::GitBlobSha256, content, len)
    }

    /// Algorithm accessor.
    pub const fn algorithm(&self) -> Algorithm {
        self.algorithm
    }

    /// The raw digest bytes under the derivation (length fixed by
    /// [`Algorithm::digest_len`]).
    pub fn bytes(&self) -> &[u8] {
        &self.bytes
    }

    /// Interop constructor (POC #1 finding 4): a pre-computed pair
    /// from an external git oid in lowercase hex form — either
    /// format, selected by length (`64` hex chars →
    /// [`Algorithm::GitBlobSha256`], `40` →
    /// [`Algorithm::GitBlobSha1`]). External git oids address pool
    /// entries directly — no mapping layer (ADR-002; the
    /// external-oid round-trip is the load-bearing interop test).
    ///
    /// **The contract is the blob family only (ADR-018 §Decision 2).**
    /// The name's "git oid" reads as "git *blob* oid" — its validated,
    /// load-bearing interop case since POC #1. It never produces a
    /// git-object family member: 40-hex is ambiguous between
    /// `GitBlobSha1` and `GitObjectSha1` (and 64-hex between the
    /// sha-256 siblings), and length cannot name a family — the
    /// rejects-never-guesses posture holds at the one boundary where
    /// the wrong answer would be silently accepted content. A
    /// family-explicit digest/key rides the physical encoding: a
    /// `Key::from_bytes` over `[tag] + oid bytes` (the registry byte
    /// naming the family validates the pair). A family-explicit wire
    /// form is a named-consumer + new-ADR door.
    ///
    /// Hex parsing policy (named design point): **lowercase-only
    /// acceptance** — git's own oid output is always lowercase, and
    /// strictness at the boundary is the rejects-never-guesses
    /// posture; uppercase hex is rejected as malformed
    /// ([`Error::KeyInvalid`]). An external producer that
    /// uppercases must downcase before this boundary.
    ///
    /// Unparseable, wrong-length, or uppercase input is
    /// `Error::KeyInvalid`; never a guess.
    pub fn from_git_oid_hex(oid_hex: &str) -> Result<Digest, Error> {
        if !oid_hex
            .bytes()
            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
        {
            return Err(Error::KeyInvalid);
        }
        let algorithm = match oid_hex.len() {
            64 => Algorithm::GitBlobSha256,
            40 => Algorithm::GitBlobSha1,
            _ => return Err(Error::KeyInvalid),
        };
        let decoded = hex::decode(oid_hex).map_err(|_| Error::KeyInvalid)?;
        if decoded.len() != algorithm.digest_len() {
            return Err(Error::KeyInvalid);
        }
        Ok(Digest::from_parts(algorithm, decoded))
    }

    /// The pair constructor; the only way a `Digest` is built. Full
    /// pair validation is the typed paths' job — the derivations
    /// (length fixed by the hash), `from_git_oid_hex`'s decode
    /// checks, and `Key`'s boundary parsing all validate before
    /// arriving here. What this constructor itself carries is the
    /// debug-integrity check: debug builds assert the bytes' length
    /// agrees with the algorithm's digest length (review-001 D-5),
    /// release builds trust the typed paths.
    pub(crate) const fn from_parts(algorithm: Algorithm, bytes: Vec<u8>) -> Digest {
        debug_assert!(
            bytes.len() == algorithm.digest_len(),
            "digest length disagrees with its algorithm"
        );
        Digest { algorithm, bytes }
    }
}

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

    /// Byte-exact hardcoded vectors, themselves produced by
    /// `git hash-object` (the POC quality note: known-answer tests
    /// are only as trustworthy as their source of truth — these were
    /// computed with the CLI at implementation time, and the CLI test
    /// in `tests/git_interop.rs` keeps them honest).
    #[test]
    fn derivation_matches_git_computed_vectors() {
        assert_eq!(
            Digest::derive(b"hello", 5).bytes().to_vec(),
            hex::decode("8aec4e4876f854f688d0ebfc8f37598f38e5fd6903cccc850ca36591175aeb60")
                .expect("sha256 'hello' vector decodes"),
            "sha256 'hello'",
        );
        assert_eq!(
            Digest::derive(b"", 0).bytes().to_vec(),
            hex::decode("473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813")
                .expect("sha256 empty vector decodes"),
            "sha256 empty",
        );
        assert_eq!(
            Algorithm::GitBlobSha1.derive(b"hello", 5).bytes().to_vec(),
            hex::decode("b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0").unwrap(),
            "sha1 'hello'",
        );
        assert_eq!(
            Algorithm::GitBlobSha1.derive(b"", 0).bytes().to_vec(),
            hex::decode("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391").unwrap(),
            "sha1 empty",
        );
    }

    /// The git-object family's known-answer vectors (ADR-018):
    /// the digest over git's loose-object form is the object's git oid.
    /// The empty tree's oid — sha1("tree 0\0") — is git's own famous
    /// constant; the sha-256 sibling vector rides it (empty-tree oids
    /// are git's own constants, kept honest by the CLI test in
    /// `tests/git_interop.rs`).
    #[test]
    fn git_object_derivation_matches_git_oids() {
        let empty_tree = b"tree 0\0";
        assert_eq!(
            Algorithm::GitObjectSha1
                .derive(empty_tree, empty_tree.len() as u64)
                .bytes()
                .to_vec(),
            hex::decode("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap(),
            "sha1 empty tree via the git-object derivation",
        );
        assert_eq!(
            Algorithm::GitObjectSha256
                .derive(empty_tree, empty_tree.len() as u64)
                .bytes()
                .to_vec(),
            hex::decode("6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321")
                .expect("sha256 empty tree vector decodes"),
            "sha256 empty tree via the git-object derivation",
        );
        // A blob's loose form hashes equal under both sha-1 arms —
        // the derivations agree byte-for-byte; only the key tags differ
        // (ADR-018 §Consequences: family-level identity, never a
        // cross-family collision, and the docs never imply "one content,
        // one key" across families).
        assert_eq!(
            Algorithm::GitObjectSha1
                .derive(b"blob 5\0hello", 13)
                .bytes()
                .to_vec(),
            Algorithm::GitBlobSha1.derive(b"hello", 5).bytes().to_vec(),
            "blob loose form through the git-object derivation == the blob oid",
        );
    }

    /// The family's `len` parameter is inert (ADR-018 §Decision 1 —
    /// documented, not inherited silently): `derive` over a family
    /// member is the plain hash of the content, so the same preimage
    /// derives the same digest at every `len`, boundary values
    /// included.
    #[test]
    fn git_object_derive_len_parameter_is_inert() {
        for content in [
            &b"tree 0\0"[..],
            b"blob 5\0hello",
            &(0..1000u32).map(|i| (i % 251) as u8).collect::<Vec<u8>>()[..],
        ] {
            for algorithm in [Algorithm::GitObjectSha1, Algorithm::GitObjectSha256] {
                let baseline = algorithm.derive(content, content.len() as u64);
                for len in [0u64, 1, content.len() as u64 - 1, u64::MAX] {
                    assert_eq!(
                        algorithm.derive(content, len),
                        baseline,
                        "{algorithm:?}: derive over len {len} equals the plain hash"
                    );
                }
            }
        }
    }

    /// Registry bytes and total order of the extended family (ADR-018
    /// §Decision 1): the new members sort after the blob members and
    /// decode round-trip.
    #[test]
    fn git_object_registry_bytes_and_order() {
        assert_eq!(Algorithm::GitObjectSha1.byte(), registry::GIT_OBJECT_SHA1);
        assert_eq!(
            Algorithm::try_from_byte(registry::GIT_OBJECT_SHA256),
            Some(Algorithm::GitObjectSha256)
        );
        assert!(
            Algorithm::GitBlobSha1 < Algorithm::GitObjectSha1
                && Algorithm::GitObjectSha1 < Algorithm::GitObjectSha256,
            "ordering follows the registry bytes (0x02 < 0x03 < 0x04)"
        );
        assert_eq!(Algorithm::GitObjectSha1.digest_len(), 20);
        assert_eq!(Algorithm::GitObjectSha256.digest_len(), 32);
    }

    /// Digest lengths are pinned by their algorithm.
    #[test]
    fn digest_lengths_pinned() {
        assert_eq!(Digest::derive(b"x", 1).bytes().len(), 32);
        assert_eq!(Algorithm::GitBlobSha1.derive(b"x", 1).bytes().len(), 20);
        assert_eq!(Algorithm::GitObjectSha1.derive(b"x", 1).bytes().len(), 20);
        assert_eq!(Algorithm::GitObjectSha256.derive(b"x", 1).bytes().len(), 32);
    }

    /// The interop constructor, over git's actual output format (the
    /// lowercase oid hex `git hash-object` prints): both algorithm
    /// lengths decode, and the pair matches the derived digest.
    /// Uppercase, wrong-length, and non-hex input is
    /// `Error::KeyInvalid` (the lowercase-only acceptance policy).
    #[test]
    fn from_git_oid_hex_round_trips_and_rejects() {
        let derived = Digest::derive(b"hello", 5);
        let legacy = Algorithm::GitBlobSha1.derive(b"hello", 5);

        let hex256 = hex::encode(derived.bytes());
        let hex1 = hex::encode(legacy.bytes());

        assert_eq!(
            Digest::from_git_oid_hex(&hex256)
                .unwrap_or_else(|_| { panic!("sha256 oid should decode") }),
            derived,
        );
        assert_eq!(
            Digest::from_git_oid_hex(&hex1).unwrap_or_else(|_| panic!("sha1 oid should decode")),
            legacy,
        );

        assert_eq!(Digest::from_git_oid_hex(""), Err(Error::KeyInvalid));
        assert_eq!(
            Digest::from_git_oid_hex(&hex1[..hex1.len() - 1]),
            Err(Error::KeyInvalid)
        );
        assert_eq!(
            Digest::from_git_oid_hex(&hex256[..hex256.len() - 1]),
            Err(Error::KeyInvalid)
        );
        let upper = hex1.to_uppercase();
        assert_eq!(Digest::from_git_oid_hex(&upper), Err(Error::KeyInvalid));
        assert_eq!(
            Digest::from_git_oid_hex(&"z".repeat(40)),
            Err(Error::KeyInvalid)
        );
        assert_eq!(
            Digest::from_git_oid_hex(&"a".repeat(39)),
            Err(Error::KeyInvalid)
        );
        assert_eq!(
            Digest::from_git_oid_hex(&"a".repeat(65)),
            Err(Error::KeyInvalid)
        );
    }

    /// The streaming form is the same derivation: a `BlobHasher` fed
    /// the content in arbitrary chunk boundaries finishes byte-identical
    /// to [`Algorithm::derive`] — every algorithm (the blob preamble
    /// arms and the family's no-preamble arms), boundaries around the
    /// known-answer vectors, and a multi-chunk bulk body. The family
    /// members stream git's loose-object form as the content itself.
    #[test]
    fn streaming_hasher_matches_known_length_derivation() {
        for algorithm in [
            Algorithm::GitBlobSha256,
            Algorithm::GitBlobSha1,
            Algorithm::GitObjectSha1,
            Algorithm::GitObjectSha256,
        ] {
            let bodies: [&[u8]; 5] = [b"", b"hello", b"tree 0\0", &[0u8; 100_003], &[7u8; 9]];

            for body in bodies {
                let expected = algorithm.derive(body, body.len() as u64);

                // whole-body update.
                let mut hasher = BlobHasher::new(algorithm, body.len() as u64);
                hasher.update(body);
                assert_eq!(hasher.finish(), expected, "whole-body, {algorithm:?}");

                // byte-at-a-time.
                let mut hasher = BlobHasher::new(algorithm, body.len() as u64);
                for byte in body {
                    hasher.update(std::slice::from_ref(byte));
                }
                assert_eq!(hasher.finish(), expected, "byte-at-a-time, {algorithm:?}");

                // awkward fixed chunks (7, including past the end).
                let mut hasher = BlobHasher::new(algorithm, body.len() as u64);
                for chunk in body.chunks(7) {
                    hasher.update(chunk);
                }
                assert_eq!(hasher.finish(), expected, "chunks of 7, {algorithm:?}");
            }

            // The canonical 'hello' and empty vectors, streamed.
            let expected = algorithm.derive(b"hello", 5).bytes().to_vec();
            let mut hasher = BlobHasher::new(algorithm, 5);
            hasher.update(b"hel");
            hasher.update(b"lo");
            assert_eq!(hasher.finish().bytes().to_vec(), expected);

            let hasher = BlobHasher::new(algorithm, 0);
            assert_eq!(
                hasher.finish().bytes().to_vec(),
                algorithm.derive(b"", 0).bytes().to_vec(),
                "empty content streams with no update"
            );
        }
    }

    /// Algorithm ordering equals registry-byte ordering, so
    /// digest-derived sort is the keys' byte total order.
    #[test]
    fn algorithm_order_matches_registry_bytes() {
        assert!(Algorithm::GitBlobSha256.byte() < Algorithm::GitBlobSha1.byte());
        assert!(Algorithm::GitBlobSha256 < Algorithm::GitBlobSha1);
        assert!(Algorithm::GitBlobSha1 < Algorithm::GitObjectSha1);
        assert!(Algorithm::GitObjectSha1 < Algorithm::GitObjectSha256);
        assert!(Algorithm::GitObjectSha256.byte() < 0xFF);
    }
}