feat(keys): the content-address layer — canonical hash derivation and key encoding (ADR-002)

Wave 1 task core-hashing-keys: the crate's declared one-way door,
implemented byte-exact to ADR-002.

- src/hashing.rs: Algorithm (exactly GitBlobSha256 canonical /
  GitBlobSha1 tolerated; BLAKE3 absent), Digest (known-length
  derivation H("blob <len>\0" + content) as primary constructor;
  from_git_oid_hex interop constructor, lowercase-only acceptance),
  and the hashing::registry constants (0x01/0x02, 0x00 reserved; new
  algorithm = new ADR)
- src/keys.rs: typed Key wrapper; as_bytes/from_bytes are the only
  backend-boundary conversions; 1 algorithm byte + digest bytes
  (33/21 total); rejects-never-guesses validation -> Error::KeyInvalid
- tests/git_interop.rs: the load-bearing test — byte-exact derivation
  against the `git hash-object` CLI, both algorithms, small and
  75-KiB content; external-oid round-trip into pool keys; both oid
  formats coexisting in one flat pool
- named design point (oid hex policy): lowercase-only acceptance,
  recorded in the task Notes and on from_git_oid_hex

Verified: cargo test (15 passed), cargo clippy --all-targets -- -D
warnings, cargo fmt --check, cargo test --all-features — all green.
This commit is contained in:
glm-5.3-flash committed 2026-10-11 18:20:12 +00:00
1 parent fc5ab57d18
commit fef05b4ec5
6 files changed
+775 -13

No files matched your search

+3
View File
@@ -32,5 +32,8 @@ parking_lot = "0.12"
alkcall = { version = "0.8.1", optional = true }
deadpool-postgres = { version = "0.14", optional = true }
hex = "0.4"
rusqlite = { version = "0.40", features = ["bundled"], optional = true }
sha1 = "0.10"
sha2 = "0.10"
tokio-postgres = { version = "0.7", optional = true }
+304 -3
View File
@@ -1,3 +1,304 @@
//! Hashing & keys (ADR-002): `Digest`/`Key` types, the canonical
//! git-blob-sha-256 derivation, tolerated `git-blob-sha-1`, and the
//! algorithm-tagged byte encoding.
//! 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 domain-separated
//! derivation + a digest. Every algorithm here derives 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 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.
//!
//! **Hash policy.** Exactly two members, per ADR-002's three classes:
//! [`Algorithm::GitBlobSha256`] (canonical) and
//! [`Algorithm::GitBlobSha1`] (tolerated legacy — git's own
//! collision-hardened SHA-1 threat model inherited verbatim). BLAKE3
//! is absent from the crate entirely (ADR-002 Decision class 3).
use sha1::Sha1;
use sha2::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;
/// 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 — three
/// classes, two members).
///
/// The variant order matches the registry tag order
/// ([`registry::GIT_BLOB_SHA256`] `0x01` < [`registry::GIT_BLOB_SHA1`]
/// `0x02`), 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,
}
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,
}
}
/// 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),
_ => None,
}
}
/// The fixed digest length (`32` / `20`); total key lengths 33/21.
pub const fn digest_len(self) -> usize {
match self {
Self::GitBlobSha256 => 32,
Self::GitBlobSha1 => 20,
}
}
/// The algorithm's known-length derivation:
/// `H("blob <len>\0" + content)`. The preamble is built the same
/// way for every algorithm — the domain separation is shared, the
/// hasher is the only per-algorithm choice (ADR-002 §The preamble
/// as abstraction).
pub fn derive(self, content: &[u8], len: u64) -> Digest {
let preamble = blob_preamble(len);
let bytes = match self {
Self::GitBlobSha256 => blob_digest::<Sha256>(&preamble, content),
Self::GitBlobSha1 => blob_digest::<Sha1>(&preamble, 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()
}
/// 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).
///
/// 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 validated pair constructor; the only way a `Digest` is
/// built, so every digest's length is pinned by its algorithm.
pub(crate) const fn from_parts(algorithm: Algorithm, bytes: Vec<u8>) -> Digest {
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",
);
}
/// 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);
}
/// 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)
);
}
/// 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);
}
}
+213
View File
@@ -0,0 +1,213 @@
//! The key side of the content-address layer (ADR-002 §Key encoding;
//! hashing-and-keys.md §Key encoding): the typed [`Key`] wrapper and
//! its physical byte encoding — the crate's declared one-way door.
//!
//! **One-way door.** Key bytes persist in backend rows and `local`
//! engine filenames with no migration story; the encoding is frozen
//! once a backend ships data (AGENTS.md convention 5, ADR-002). It is
//! implemented byte-exactly to the ADR and reviewed line-by-line at
//! the wave gate. Backends are opaque to all of it: they store
//! `&[u8]` keys, and [`Key::as_bytes`]/[`Key::from_bytes`] are the
//! only conversions across the backend boundary; only the store core
//! constructs and interprets typed keys (ADR-003).
//!
//! Physical layout: `key := <algorithm byte> <digest bytes>` —
//! `0x01` = git-blob-sha-256 (32-byte digest; total 33), `0x02` =
//! git-blob-sha-1 (20-byte digest; total 21); fixed length per
//! algorithm so enum-tagged keys sort in total order (required for
//! the sweep's live-set diffs). Validation is rejects-never-guesses:
//! truncated keys, unknown algorithm bytes (reserved `0x00` included)
//! and wrong digest lengths are [`Error::KeyInvalid`] — never a
//! guess (POC #1 finding 5's length-checking standard).
use crate::hashing::{Algorithm, Digest};
use crate::Error;
/// The typed content-address wrapper: a [`Digest`] that converts to
/// the physical key encoding where it crosses the backend boundary.
///
/// Ordering is the keys' total order — registry byte first, then
/// digest bytes — matching [`Key::as_bytes`] ordering byte-for-byte.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Key(Digest);
impl Key {
/// The known-length canonical derivation, wrapped —
/// `H("blob <len>\0" + content)` (see [`Digest::derive`]).
pub fn derive(content: &[u8], len: u64) -> Key {
Digest::derive(content, len).into()
}
/// The digest the key carries.
pub const fn digest(&self) -> &Digest {
&self.0
}
/// The key's algorithm.
pub const fn algorithm(&self) -> Algorithm {
self.0.algorithm()
}
/// The physical encoding: `1 algorithm byte + digest bytes` —
/// total 33 (`0x01`) or 21 (`0x02`).
pub fn as_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(1 + self.0.bytes().len());
bytes.push(self.0.algorithm().byte());
bytes.extend_from_slice(self.0.bytes());
bytes
}
/// The backend-boundary decode. The exact inverse of
/// [`Key::as_bytes`]: anything else — empty input, truncated
/// keys, short/long digests for the algorithm, unknown algorithm
/// bytes (`0x00`… reserved, `0x03`, `0xff`, …) — is
/// [`Error::KeyInvalid`]. Rejects, never guesses.
pub fn from_bytes(bytes: &[u8]) -> Result<Key, Error> {
let (&tag, digest_bytes) = bytes.split_first().ok_or(Error::KeyInvalid)?;
let algorithm = Algorithm::try_from_byte(tag).ok_or(Error::KeyInvalid)?;
if digest_bytes.len() != algorithm.digest_len() {
return Err(Error::KeyInvalid);
}
Ok(Key(Digest::from_parts(algorithm, digest_bytes.to_vec())))
}
}
impl From<Digest> for Key {
fn from(digest: Digest) -> Key {
Key(digest)
}
}
impl From<Key> for Digest {
fn from(key: Key) -> Digest {
key.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hashing::Algorithm as A;
use std::collections::BTreeSet;
/// Encoding byte-exactness (ADR-002 §Key encoding): 33/21 total
/// lengths, 0x01/0x02 tags, digest bytes following untouched.
#[test]
fn encoding_is_byte_exact() {
let content = b"hello";
let sha256 = Digest::derive(content, content.len() as u64);
let sha1 = A::GitBlobSha1.derive(content, content.len() as u64);
let k256: Key = sha256.clone().into();
let k1: Key = sha1.clone().into();
let b256 = k256.as_bytes();
assert_eq!(b256.len(), 33);
assert_eq!(b256[0], 0x01);
assert_eq!(&b256[1..], sha256.bytes());
let b1 = k1.as_bytes();
assert_eq!(b1.len(), 21);
assert_eq!(b1[0], 0x02);
assert_eq!(&b1[1..], sha1.bytes());
}
/// Round-trip: `from_bytes` inverts `as_bytes` exactly.
#[test]
fn as_bytes_from_bytes_round_trip() {
let content = b"round trip";
for algorithm in [Algorithm::GitBlobSha256, Algorithm::GitBlobSha1] {
let key: Key = algorithm.derive(content, content.len() as u64).into();
assert_eq!(Key::from_bytes(&key.as_bytes()), Ok(key));
}
}
/// Rejects, never guesses (POC finding 5): empty, truncated,
/// over-long, unknown algorithm bytes (incl. reserved 0x00, 0x03,
/// 0xFF), and tag/digest-length mismatches are all
/// [`Error::KeyInvalid`].
#[test]
fn malformed_keys_are_rejected() {
let key: Key = Digest::derive(b"rejects", 7).into();
assert_eq!(Key::from_bytes(&[]), Err(Error::KeyInvalid), "empty");
assert_eq!(
Key::from_bytes(&key.as_bytes()[..key.as_bytes().len() - 1]),
Err(Error::KeyInvalid),
"truncated"
);
assert_eq!(
Key::from_bytes(&key.as_bytes()[..32]),
Err(Error::KeyInvalid),
"digest truncated"
);
assert_eq!(
Key::from_bytes(&[&key.as_bytes()[..], &[0u8][..]].concat()),
Err(Error::KeyInvalid),
"over-long"
);
assert_eq!(Key::from_bytes(&[0x00]), Err(Error::KeyInvalid), "reserved");
assert_eq!(
Key::from_bytes(&[0x00; 33]),
Err(Error::KeyInvalid),
"reserved full-width"
);
assert_eq!(
Key::from_bytes(&[0x03; 33]),
Err(Error::KeyInvalid),
"unknown"
);
assert_eq!(
Key::from_bytes(&[0xFF; 33]),
Err(Error::KeyInvalid),
"unknown top"
);
assert_eq!(Key::from_bytes(&[0xFF; 21]), Err(Error::KeyInvalid));
}
/// Total-order sort stability (ADR-002 / hashing-and-keys.md
/// §Key encoding): a mixed-algorithm flat collection sorts
/// deterministically, and the derived order equals the physical
/// byte order.
#[test]
fn mixed_algorithms_sort_in_total_order() {
let contents: &[&[u8]] = &[b"a", b"bb", b"ccc", b"dddd"];
let mut keys: Vec<Key> = contents
.iter()
.flat_map(|c| {
[
Key::from(Digest::derive(c, c.len() as u64)),
Key::from(Algorithm::GitBlobSha1.derive(c, c.len() as u64)),
]
})
.collect();
keys.push(Key::from(Algorithm::GitBlobSha1.derive(b"b", 1)));
keys.sort();
let encoded: Vec<Vec<u8>> = keys.iter().map(|k| k.as_bytes()).collect();
let mut sorted_encoded = encoded.clone();
sorted_encoded.sort();
assert_eq!(encoded, sorted_encoded, "derived order == byte order");
let mut set = BTreeSet::new();
for key in &keys {
set.insert(key.as_bytes());
}
assert_eq!(set.len(), keys.len(), "all keys addressable, none collide");
}
/// Tagged-key coexistence (POC finding 5, the simplification's
/// evidence): both algorithms' keys are disjoint in one flat
/// collection with zero mapping.
#[test]
fn both_algorithms_coexist_in_one_collection() {
let content = b"coexist";
let mut set = BTreeSet::new();
set.insert(Key::from(Digest::derive(content, content.len() as u64)));
set.insert(Key::from(
Algorithm::GitBlobSha1.derive(content, content.len() as u64),
));
assert_eq!(set.len(), 2);
}
}
+3
View File
@@ -4,9 +4,12 @@ pub mod error;
pub mod facade;
pub mod gc;
pub mod hashing;
pub mod keys;
pub mod local;
pub use error::Error;
pub use hashing::{Algorithm, Digest};
pub use keys::Key;
#[cfg(feature = "mem")]
pub mod mem;
+63 -10
View File
@@ -1,7 +1,7 @@
---
id: core-hashing-keys
name: Hashing & keys — the canonical derivation and the one-way-door encoding
status: pending
status: completed
depends_on: [core-error-model]
scope: narrow
risk: medium
@@ -61,19 +61,19 @@ line-by-line at the wave gate.
## Acceptance Criteria
- [ ] `Digest`/`Key`/algorithm enum per ADR-002; encoding byte-exact
- [x] `Digest`/`Key`/algorithm enum per ADR-002; encoding byte-exact
(33/21 total lengths; 0x01/0x02 tags; 0x00 rejected)
- [ ] Canonical derivation matches `git hash-object` byte-exact for
- [x] Canonical derivation matches `git hash-object` byte-exact for
both algorithms (CLI-driven test, both a small and a multi-KiB
content case)
- [ ] `from_git_oid_hex` round-trips external oids into pool keys
- [ ] Rejects: truncated keys, unknown algorithm bytes (incl. 0x00,
- [x] `from_git_oid_hex` round-trips external oids into pool keys
- [x] Rejects: truncated keys, unknown algorithm bytes (incl. 0x00,
0xFF), wrong digest lengths → `Error::KeyInvalid`, never guesses
- [ ] Total-order sort stability asserted (mixed-algorithm collection
- [x] Total-order sort stability asserted (mixed-algorithm collection
sorts deterministically)
- [ ] Doc comments carry the one-way-door warning and the registry
- [x] Doc comments carry the one-way-door warning and the registry
note (new algorithm = new ADR)
- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --check` green
## References
@@ -85,8 +85,61 @@ line-by-line at the wave gate.
## Notes
> To be filled by implementation agent
- Module layout: two files, one module per file — `src/hashing.rs`
(the hash side: `Algorithm` + `Digest` + the derivation + the
interop constructor) and `src/keys.rs` (the key side: the typed
`Key` wrapper + the physical encoding). The scaffold's stub doc on
`hashing.rs` was replaced by the full module doc. Both re-exported
from `lib.rs` (`Algorithm`, `Digest`, `Key`, mirroring the
`Error` re-export).
- **Named design point resolved — oid hex parsing policy:
lowercase-only acceptance.** `git hash-object`'s own output is
always lowercase; strictness at the boundary is the
rejects-never-guesses posture. Uppercase oids are rejected as
malformed (`Error::KeyInvalid`) — the check is a byte scan for
`[0-9a-f]` before decode (the `hex` crate's `decode` alone would
accept uppercase). Policy stated verbatim on
`Digest::from_git_oid_hex` and covered by test. If an external
interop consumer ever needs uppercase acceptance, that is a change
at this boundary with its own test.
- `Algorithm` is declared `GitBlobSha256` before `GitBlobSha1` so
derived `Ord` matches registry-byte order; the sort-stability test
asserts derived order == `as_bytes()` byte order. `Digest` keeps
its fields private behind `from_parts` (pub(crate)), so every
digest's length stays pinned by its algorithm.
- Registry constants live in `hashing::registry` (`GIT_BLOB_SHA256`
0x01, `GIT_BLOB_SHA1` 0x02, `RESERVED` 0x00) with the new-ADR doc
note. `Algorithm::try_from_byte` is public; `Key::from_bytes` is
the boundary consumer.
- Deps pinned as the task scoped: `sha2 0.10`, `sha1 0.10`, `hex
0.4` (RustCrypto + hex). The generic derivation helper is bound
over the RustCrypto digest trait so the derivation body has zero
per-algorithm special-casing; the match arms pick only the hasher.
- No `unwrap`/`expect`/panic outside tests; library paths are
`Result`/`Option` only (error.rs's `Error::KeyInvalid` carries the
boundary rejections).
## Summary
> To be filled on completion
`src/hashing.rs` + `src/keys.rs` implement the content-address layer
per ADR-002, the crate's declared one-way door. `Algorithm` (exactly
`GitBlobSha256` canonical / `GitBlobSha1` tolerated; BLAKE3 absent),
`Digest` (known-length derivation `H("blob <len>\0" + content)` as
primary constructor; `from_git_oid_hex` interop constructor with
lowercase-only acceptance, both oid formats selected by length), and
`Key` (typed wrapper; `as_bytes`/`from_bytes` the only backend-boundary
conversions; `1 algorithm byte + digest bytes` — 0x01+32, 0x02+20,
33/21 total) with rejects-never-guesses validation (`Error::KeyInvalid`
for truncated/unknown-tag/wrong-length/reserved-0x00; never a guess).
Registry constants in `hashing::registry` with the new-ADR doc note;
one-way-door warnings in both module docs. Tests: byte-exact
derivation against the live `git hash-object` CLI (the load-bearing
test — both algorithms × empty/short/binary-NUL/75-KiB, sha256 repos
via `git init --object-format=sha256` per POC finding 4's harness
gotcha), external-oid round-trip into pool keys, both-oid-formats
coexistence in one flat pool, hardcoded git-computed vectors, encoding
byte-exactness, full rejection matrix, and derived-order == byte-order
sort stability on mixed-algorithm collections. Verified: `cargo test`
(15 passed), `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --check`, and `cargo test --all-features` all green. Deps
added: `sha1`, `sha2`, `hex`.
+189
View File
@@ -0,0 +1,189 @@
//! The load-bearing interop test (ADR-002; POC #1 findings 4/5's
//! standard): byte-exact derivation against the `git hash-object` CLI
//! for both algorithms — validated against git, not just hardcoded
//! vectors — plus the external-oid round-trip into pool keys.
//!
//! Harness detail (POC finding 4, empirically learned): in current
//! git, `--object-format` is not a `hash-object` flag; the object
//! format is set on the repo (`git init --object-format=sha256`) and
//! the hash-object runs inside that repo. Repos are created fresh in
//! a per-call temp directory, with global/system config detached.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use alkblobs::{Algorithm, Digest, Key};
/// Content cases: empty, short text, binary-with-NULs, and a
/// multi-KiB case (75 KiB, deterministic).
fn content_cases() -> Vec<(&'static str, Vec<u8>)> {
vec![
("empty", Vec::new()),
("short", b"hello".to_vec()),
(
"binary-nuls",
vec![0u8, 1, 255, 0, 10, 13, 0, 7, 255, 255, 0, 0],
),
(
"multi-kib",
(0..75 * 1024).map(|i| (i % 251) as u8).collect::<Vec<u8>>(),
),
]
}
fn scratch_dir(tag: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"alkblobs-git-interop-{}-{}-{}",
tag,
std::process::id(),
nanos
));
fs::create_dir_all(&dir).expect("temp dir creation");
dir
}
fn detached_config(command: &mut Command) {
let devnull = if cfg!(windows) { "NUL" } else { "/dev/null" };
command
.env("GIT_CONFIG_GLOBAL", devnull)
.env("GIT_CONFIG_SYSTEM", devnull);
}
/// Initialize a fresh repo of the requested object format and return
/// the oid hex `git hash-object --stdin` prints for `content`.
fn git_hash_object(content: &[u8], sha256: bool) -> (PathBuf, String) {
let dir = scratch_dir("repo");
let mut init = Command::new("git");
detached_config(&mut init);
init.arg("init").arg("-q").current_dir(&dir);
if sha256 {
init.arg("--object-format=sha256");
}
let out = init.output().expect("git init spawn");
assert!(
out.status.success(),
"git init failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let mut hash = Command::new("git");
detached_config(&mut hash);
hash.arg("-C").arg(&dir).arg("hash-object").arg("--stdin");
use std::io::Write as _;
let mut child = hash
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.expect("git hash-object spawn");
child
.stdin
.as_mut()
.expect("stdin piped")
.write_all(content)
.expect("stdin write");
let out = child.wait_with_output().expect("git hash-object output");
assert!(
out.status.success(),
"git hash-object failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let oid = String::from_utf8(out.stdout)
.expect("git output utf8")
.trim()
.to_owned();
(dir, oid)
}
fn drop_dir(dir: &Path) {
let _ = fs::remove_dir_all(dir);
}
/// The load-bearing test: `Digest`'s derivation matches the git CLI
/// byte-exact, both algorithms, small and multi-KiB content.
#[test]
fn derivation_is_byte_exact_against_git_hash_object() {
for (tag, content) in content_cases() {
let (dir, oid) = git_hash_object(&content, false);
drop_dir(&dir);
assert_eq!(oid.len(), 40, "git printed a sha1 oid");
assert_eq!(
Digest::from_git_oid_hex(&oid),
Ok(Algorithm::GitBlobSha1.derive(&content, content.len() as u64)),
"sha1 {tag}: derived digest != git hash-object",
);
let (dir, oid) = git_hash_object(&content, true);
drop_dir(&dir);
assert_eq!(oid.len(), 64, "git printed a sha256 oid");
assert_eq!(
Digest::from_git_oid_hex(&oid),
Ok(Digest::derive(&content, content.len() as u64)),
"sha256 {tag}: derived digest != git hash-object",
);
}
}
/// The external-oid round-trip (POC finding 4 corollary, key level):
/// an oid produced by external git (`from_git_oid_hex` →
/// `as_bytes` → `from_bytes`) becomes exactly the pool key the store
/// core would derive — no mapping layer.
#[test]
fn external_oid_round_trips_into_pool_keys() {
for sha256 in [false, true] {
let content = b"external producer blob \0 bytes";
let (dir, oid) = git_hash_object(content, sha256);
drop_dir(&dir);
let external: Key = Digest::from_git_oid_hex(&oid)
.expect("external oid decodes")
.into();
let derived: Key = {
let len = content.len() as u64;
Digest::derive(content, len).into()
};
if sha256 {
assert_eq!(external, derived, "sha256: external oid == derived key");
} else {
assert_eq!(
external,
Key::from(Algorithm::GitBlobSha1.derive(content, content.len() as u64)),
"sha1: external oid == derived key"
);
}
let decoded = Key::from_bytes(&external.as_bytes()).expect("physical encoding decodes");
assert_eq!(decoded, external);
assert_eq!(decoded.as_bytes(), external.as_bytes());
}
}
/// Tagged-key coexistence (POC finding 5): git oids of both formats
/// address the same flat key universe from one external producer.
#[test]
fn both_oid_formats_coexist_in_one_pool() {
let content = b"same content, two formats";
let (dir1, oid1) = git_hash_object(content, false);
drop_dir(&dir1);
let (dir2, oid2) = git_hash_object(content, true);
drop_dir(&dir2);
let mut pool = std::collections::BTreeSet::new();
pool.insert(
Key::from_bytes(
&Key::from(Digest::from_git_oid_hex(&oid1).expect("sha1 oid decodes")).as_bytes(),
)
.expect("sha1 external key decodes"),
);
pool.insert(
Key::from_bytes(
&Key::from(Digest::from_git_oid_hex(&oid2).expect("sha256 oid decodes")).as_bytes(),
)
.expect("sha256 external key decodes"),
);
assert_eq!(pool.len(), 2, "both algorithms coexist in one flat pool");
}