Files
alkblobs/tests/git_interop.rs
T
glm-5.3-flash 9134d4444d feat(hashing): the git-object derivation family lands — review-002 C-1's implementation half (wave 13)
The hash registry extends per ADR-018: bytes 0x03/0x04 (git-object
sha-1/sha-256), preimage-identity hashing over git's loose-object form
as the stored preimage — the digest byte-identical to git's oid, every
kind included. Re-derived from poc-o-git-objects, ADR-stamped, not
merged.

- Registry + Algorithm members after the blob members (total order
  rides the registry bytes, 0x02 < 0x03 < 0x04); the derivation arms
  (derive's len parameter inert for the family — documented,
  test-pinned); the BlobHasher no-preamble arms (finish pinned equal
  to derive); from_git_oid_hex's contract pinned blob-family-only.
- keys.rs: the extended physical layout round-trips in total order;
  rejects-never-guesses unchanged (0x05 is the unknown-tag example).
- put path: put_git_object(algorithm, len, stream) public — family
  members only (KeyInvalid otherwise), known-length only, the
  inherited Verification/Io mappings — via put_known_as; the
  unknown-length drivers take their derivation from callers.
- fleet threading (ADR-018 §7): every hasher site
  derivation-parameterized (put_known_as; fleet_put_pglo_known/
  _unknown); put_git_object under a fleet declaration lands through
  the joint entry+pin tx / pg-lo consolidation / pin-first staged
  forms — pinned by two env-driven tests with exact-count sweep
  outcomes (kv joint form byte-exact + dedup row-alone; consolidation
  form with LO pages landing and dying exactly).
- git_interop: the family tests green against the real git CLI
  (tree/commit/tag byte-exact, dedup, the blob-tag negative, the
  sha-256 sibling), the seam guard (blob algorithms reject), the
  preimage-length stat assertion; in-module empty-tree known-answer
  vectors.
- Conformance: the INV-1/2/6 reference rows gain family legs
  (round-trip incl. the staged form, dedup-by-oid, the stored-preimage
  stat semantic); backlog pins + README inventory updated.
- kind-decision delta (ADR-018 §2): behavior unchanged, texts honest —
  the ops module doc's digest-wire-form paragraph, digest_ingress's
  doc, the specs' headers.
- fuzz: the key_encoding generator + shared battery extended to the
  four-member registry (family keys, known-answer vectors, the len
  inertness pin, 0x05 as the invalid tag); committed corpus
  regenerated per policy (0x03 was an invalid-tag seed — 117 rewritten,
  67 added); idempotent after landing.

Verification: cargo test (default, 153+54+7+2), cargo test
--all-features server-less (skip-clean) and against the live pglo-poc
harness (336+125+7+3), cargo clippy --all-targets both feature
variants (-D warnings), cargo fmt --check, cargo doc --no-deps, Cargo
test --manifest-path fuzz/shared/Cargo.toml (the corpus-replay gate).
Task: tasks/c1-git-object-family-impl.md (status completed, notes +
summary recorded).
2026-10-18 00:00:46 +00:00

462 lines
16 KiB
Rust

//! 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. The
//! git-object derivation family (ADR-018) rides the same standard:
//! the family's digest over git's loose-object form is byte-exact
//! git's oid of non-blob kinds, CLI-validated over real
//! `git hash-object -t` bodies, tree/commit/tag.
//!
//! 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`. With a
/// `kind` the object type is set (`-t tree|commit|tag` — non-blob
/// hashes validate the body as that kind); `None` hashes as a blob.
fn git_hash_object(content: &[u8], sha256: bool) -> (PathBuf, String) {
git_hash_object_kind(content, sha256, None)
}
fn git_hash_object_kind(content: &[u8], sha256: bool, kind: Option<&str>) -> (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");
if let Some(kind) = kind {
hash.arg("-t").arg(kind);
}
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());
}
}
/// The facade put seam against git (store-api.md §Invariants 1's
/// source-of-truth leg): both internal put paths (known-length,
/// unknown-length) key entries git-identically on both tiers, and the
/// get path round-trips git's content byte-exactly. mem-gated: the
/// wave-2 constructible shape (`dual_tier_mem_local`).
#[cfg(feature = "mem")]
#[tokio::test]
async fn put_seam_keys_match_git_hash_object_on_both_tiers_and_paths() {
use std::io::Cursor;
use alkblobs::Store;
let root = scratch_dir("store-root");
let store = Store::dual_tier_mem_local(root.join("root"), 32)
.await
.expect("dual-tier store constructs");
let small = b"small git-interop blob";
let big = (0..200).map(|i| (i % 253) as u8).collect::<Vec<u8>>();
let (repo, oid) = git_hash_object(small, true);
drop_dir(&repo);
let expected_small: Key = Digest::from_git_oid_hex(&oid)
.expect("git oid decodes")
.into();
let (repo, oid) = git_hash_object(&big, true);
drop_dir(&repo);
let expected_big: Key = Digest::from_git_oid_hex(&oid)
.expect("git oid decodes")
.into();
let (key, pin) = store
.put(Some(small.len() as u64), Cursor::new(&small[..]))
.await
.expect("known-length put");
assert_eq!(key, expected_small, "kv put keys git-identical");
drop(pin);
let (key, pin) = store
.put(None, Cursor::new(&small[..]))
.await
.expect("unknown-length put");
assert_eq!(key, expected_small, "buffered form keys git-identical");
let (len, blob) = store
.get(&expected_small.as_bytes())
.await
.expect("get succeeds")
.expect("entry present");
assert_eq!(blob.read_at(0, len).await.unwrap(), &small[..]);
drop(pin);
let (key, pin) = store
.put(Some(big.len() as u64), Cursor::new(&big[..]))
.await
.expect("known-length large put");
assert_eq!(key, expected_big, "staged put keys git-identical");
drop(pin);
let (key, pin) = store
.put(None, Cursor::new(&big[..]))
.await
.expect("unknown-length large put");
assert_eq!(
key, expected_big,
"staged-bytes derivation keys git-identical"
);
let (len, blob) = store
.get(&expected_big.as_bytes())
.await
.expect("get succeeds")
.expect("entry present");
assert_eq!(blob.read_at(0, len).await.unwrap(), big);
drop(pin);
let _ = fs::remove_dir_all(root);
}
/// 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");
}
/// The git-object derivation family (ADR-018): the digest over git's
/// loose-object form (`"<type> <len>\0" + content`) is byte-exact
/// git's oid of the object, for non-blob kinds. The reference oids
/// come from the CLI itself (`hash-object -t <kind> --stdin` over a
/// crafted body — the CLI prepends its own header, so the bodies are
/// bare contents and the CLI computes the true oid; a tree/commit/tag
/// body's shape keeps the CLI's kind validation honest).
#[test]
fn git_object_derivation_is_byte_exact_against_git_hash_object() {
let tree_body: Vec<u8> = {
// "100644 f\0" + sha1 'hello' bytes
let mut t = "100644 f\0".as_bytes().to_vec();
t.extend_from_slice(
&hex::decode("b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0").expect("blob oid decodes"),
);
t
};
let commit_body = format!(
"tree {}\nauthor t <t@t> 1792210192 +0000\ncommitter t <t@t> 1792210192 +0000\n\nx\n",
hex::encode(
Algorithm::GitObjectSha1
.derive(&tree_body, tree_body.len() as u64)
.bytes()
)
)
.into_bytes();
let tag_body = format!(
"object {}\ntype commit\ntag v1\ntagger t <t@t> 1792210192 +0000\n\nx\n",
hex::encode(
Algorithm::GitObjectSha1
.derive(&commit_body, commit_body.len() as u64)
.bytes()
)
)
.into_bytes();
for (kind, body) in [
("tree", tree_body),
("commit", commit_body),
("tag", tag_body),
] {
let (dir, oid) = git_hash_object_kind(&body, false, Some(kind));
drop_dir(&dir);
assert_eq!(oid.len(), 40);
let loose_form = {
let mut v = format!("{} {}\0", kind, body.len()).into_bytes();
v.extend_from_slice(&body);
v
};
let derived = Algorithm::GitObjectSha1.derive(&loose_form, loose_form.len() as u64);
assert_eq!(
hex::encode(derived.bytes()),
oid,
"{kind}: git-object derivation != git's oid (body len {})",
body.len()
);
let key = Key::from_bytes(&Key::from(derived).as_bytes()).expect("git-object key decodes");
assert_eq!(key.algorithm(), Algorithm::GitObjectSha1);
}
// The sha-256 sibling: same loose form, 64-hex oid from the CLI.
let (dir, oid) = git_hash_object(b"x", true);
drop_dir(&dir);
let loose = b"blob 1\0x";
let derived = Algorithm::GitObjectSha256.derive(loose, loose.len() as u64);
assert_eq!(
hex::encode(derived.bytes()),
oid,
"sha256 git-object derivation"
);
}
/// The family's facade put/get round trip (store level): a non-blob
/// object put through `put_git_object` lands under its git oid key,
/// verifies on get, dedups, and — the negative evidence for DQ-1's
/// original problem statement — is not reachable through the
/// blob-family tags (no cross-family collision: the same oid digest
/// under a different tag addresses nothing).
#[cfg(feature = "mem")]
#[tokio::test]
async fn put_git_object_round_trips_under_the_oid_key() {
use std::io::Cursor;
use alkblobs::Store;
let store = Store::mem_only();
let tree_body: Vec<u8> = {
let mut t = "100644 f\0".as_bytes().to_vec();
t.extend_from_slice(
&hex::decode("b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0").expect("blob oid decodes"),
);
t
};
let loose_form = {
let mut v = format!("tree {}\0", tree_body.len()).into_bytes();
v.extend_from_slice(&tree_body);
v
};
let (key, pin) = store
.put_git_object(
Algorithm::GitObjectSha1,
loose_form.len() as u64,
Cursor::new(loose_form.clone()),
)
.await
.expect("git-object put");
assert_eq!(
key.algorithm(),
Algorithm::GitObjectSha1,
"the key lands under the git-object family"
);
drop(pin);
// Dedup by construction: the re-put resolves to the same key.
let (key2, pin2) = store
.put_git_object(
Algorithm::GitObjectSha1,
loose_form.len() as u64,
Cursor::new(&loose_form[..]),
)
.await
.expect("re-put");
assert_eq!(key2, key);
drop(pin2);
// The get verifies byte-exactly (verification-on-get against the
// preimage-identity derivation) and round-trips.
let (len, blob) = store
.get(&key.as_bytes())
.await
.expect("get ok")
.expect("entry present");
let value = blob.read_at(0, len).await.expect("read");
assert_eq!(value, loose_form);
// The stat semantic (ADR-018 §Decision 3): the preimage length —
// the loose form, git's header included.
let meta = store
.stat(&key.as_bytes())
.await
.expect("stat ok")
.expect("stat sees the entry");
assert_eq!(
meta.len,
loose_form.len() as u64,
"stat.len is the stored-preimage length"
);
assert_eq!(meta.algorithm, Algorithm::GitObjectSha1);
// The negative evidence: the same oid addressed through the
// blob-family tags finds nothing (the tree oid is not a blob key).
let blob_tagged_oid = Digest::from_git_oid_hex(&hex::encode(key.digest().bytes()))
.expect("oid hex decodes into the blob family");
assert!(
store
.get(&Key::from(blob_tagged_oid).as_bytes())
.await
.expect("blob-tag get ok")
.is_none(),
"the tree oid is not reachable through the blob tags"
);
}
/// The family put's guard: the seam is family-members-only with the
/// ADR's pinned error mapping — a blob algorithm is [`Error::KeyInvalid`].
#[cfg(feature = "mem")]
#[tokio::test]
async fn put_git_object_rejects_blob_algorithms() {
use std::io::Cursor;
use alkblobs::{Error, Store};
let store = Store::mem_only();
for algorithm in [Algorithm::GitBlobSha256, Algorithm::GitBlobSha1] {
let err = store
.put_git_object(algorithm, 5, Cursor::new(&b"hello"[..]))
.await
.expect_err("blob algorithms reject");
assert_eq!(err, Error::KeyInvalid);
}
}