From fef05b4ec528e1764e96d271cf288144b4f8bdc1 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 11 Oct 2026 18:20:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(keys):=20the=20content-address=20layer=20?= =?UTF-8?q?=E2=80=94=20canonical=20hash=20derivation=20and=20key=20encodin?= =?UTF-8?q?g=20(ADR-002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 \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. --- Cargo.toml | 3 + src/hashing.rs | 307 ++++++++++++++++++++++++++++++++++++- src/keys.rs | 213 +++++++++++++++++++++++++ src/lib.rs | 3 + tasks/core-hashing-keys.md | 73 +++++++-- tests/git_interop.rs | 189 +++++++++++++++++++++++ 6 files changed, 775 insertions(+), 13 deletions(-) create mode 100644 src/keys.rs create mode 100644 tests/git_interop.rs diff --git a/Cargo.toml b/Cargo.toml index 6fa45a8..fb40ed0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } \ No newline at end of file diff --git a/src/hashing.rs b/src/hashing.rs index 7b2ecc3..16cdce0 100644 --- a/src/hashing.rs +++ b/src/hashing.rs @@ -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 \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 \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 { + 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 \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::(&preamble, content), + Self::GitBlobSha1 => blob_digest::(&preamble, content), + }; + Digest::from_parts(self, bytes) + } +} + +/// The derivation's preamble: `"blob \0"` (git's format, shared +/// by both algorithms). +fn blob_preamble(len: u64) -> Vec { + 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(preamble: &[u8], content: &[u8]) -> Vec +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, +} + +impl Digest { + /// Known-length canonical derivation: [`Digest`] under + /// [`Algorithm::GitBlobSha256`], `H("blob \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 { + 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) -> 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); + } +} diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 0000000..87bead2 --- /dev/null +++ b/src/keys.rs @@ -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 := ` — +//! `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 \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 { + 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 { + 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 for Key { + fn from(digest: Digest) -> Key { + Key(digest) + } +} + +impl From 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 = 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> = 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); + } +} diff --git a/src/lib.rs b/src/lib.rs index c4e8df4..89210b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/tasks/core-hashing-keys.md b/tasks/core-hashing-keys.md index aeae969..a62f0a3 100644 --- a/tasks/core-hashing-keys.md +++ b/tasks/core-hashing-keys.md @@ -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 \ No newline at end of file +`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 \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`. \ No newline at end of file diff --git a/tests/git_interop.rs b/tests/git_interop.rs new file mode 100644 index 0000000..ef95625 --- /dev/null +++ b/tests/git_interop.rs @@ -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)> { + 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::>(), + ), + ] +} + +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"); +}