- put_as(algorithm, len: Option<u64>, stream) is the facade's sole public write surface; put, put_git_object, put_known, and the matches! family restriction are deleted outright (no shim, 0.x) - put_in_batch takes the algorithm parameter (batch, algorithm, len, stream); the one seam routes every member - Digest::derive, Key::derive, Digest::from_git_oid_hex deleted; explicit construction is Algorithm::derive / Key::from_bytes over the physical encoding - the put_unknown fleet arm honors its threaded algorithm parameter (the put.rs:360 re-hardcode), pinned by a live pg-lo fleet test - the unknown-length arm retained per §Decision 3; its identity-family form pinned (buffer + staged routes land the known-length key) - the seam-guard test transforms: blob algorithms are legal at the seam; put_as lands the canonical blob derivation (git vectors) - caller sweep: ~250 sites across src/, tests/, and the conformance columns; the ops put handler's store arm rides the seam's blob arm canonically until the put-handler task (§Decision 4) - fuzz key_encoding target: the from_git_oid_hex arm dies with the constructor; seed regeneration a no-op diff; corpus-replay gate green Verified: cargo test (default), cargo test --all-features server-less and against the live pglo-poc harness, both clippy variants (-D warnings), cargo fmt --check, cargo doc --no-deps, the fuzz corpus-replay gate.
509 lines
17 KiB
Rust
509 lines
17 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 alkblobs::{Algorithm, Key};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
/// Family-explicit construction from an external oid (ADR-020 §Decision
|
|
/// 5 — the interop constructor died with its length-implied family):
|
|
/// the registry byte names the family, the oid bytes follow, and
|
|
/// `Key::from_bytes` validates the pair over the physical encoding —
|
|
/// the same decoder the backend boundary and the wire's ingress trust.
|
|
fn oid_key(oid_hex: &str, algorithm: Algorithm) -> Key {
|
|
let oid = hex::decode(oid_hex).expect("git printed lowercase hex");
|
|
assert_eq!(oid.len(), algorithm.digest_len(), "oid width matches");
|
|
let mut tagged = vec![algorithm.byte()];
|
|
tagged.extend_from_slice(&oid);
|
|
Key::from_bytes(&tagged).expect("the tagged oid key decodes")
|
|
}
|
|
|
|
/// 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!(
|
|
oid_key(&oid, Algorithm::GitBlobSha1)
|
|
.digest()
|
|
.bytes()
|
|
.to_vec(),
|
|
Algorithm::GitBlobSha1
|
|
.derive(&content, content.len() as u64)
|
|
.bytes()
|
|
.to_vec(),
|
|
"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!(
|
|
oid_key(&oid, Algorithm::GitBlobSha256)
|
|
.digest()
|
|
.bytes()
|
|
.to_vec(),
|
|
Algorithm::GitBlobSha256
|
|
.derive(&content, content.len() as u64)
|
|
.bytes()
|
|
.to_vec(),
|
|
"sha256 {tag}: derived digest != git hash-object",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The external-oid round-trip (POC finding 4 corollary, key level):
|
|
/// an oid produced by external git (`Key::from_bytes` over the
|
|
/// physical encoding → `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 algorithm = if sha256 {
|
|
Algorithm::GitBlobSha256
|
|
} else {
|
|
Algorithm::GitBlobSha1
|
|
};
|
|
let external: Key = oid_key(&oid, algorithm);
|
|
let derived: Key = Algorithm::GitBlobSha256
|
|
.derive(content, content.len() as u64)
|
|
.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 put arms (known-length, unknown-length)
|
|
/// key entries git-identically on both tiers under the named blob
|
|
/// derivation, 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 = oid_key(&oid, Algorithm::GitBlobSha256);
|
|
let (repo, oid) = git_hash_object(&big, true);
|
|
drop_dir(&repo);
|
|
let expected_big: Key = oid_key(&oid, Algorithm::GitBlobSha256);
|
|
|
|
let (key, pin) = store
|
|
.put_as(
|
|
Algorithm::GitBlobSha256,
|
|
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_as(Algorithm::GitBlobSha256, 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_as(
|
|
Algorithm::GitBlobSha256,
|
|
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_as(Algorithm::GitBlobSha256, 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(oid_key(&oid1, Algorithm::GitBlobSha1));
|
|
pool.insert(oid_key(&oid2, Algorithm::GitBlobSha256));
|
|
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 the one put seam under the family's algorithm
|
|
/// 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_as_family_member_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_as(
|
|
Algorithm::GitObjectSha1,
|
|
Some(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_as(
|
|
Algorithm::GitObjectSha1,
|
|
Some(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 tag finds nothing (the tree oid is not a blob key) —
|
|
// family-explicit construction names the tag; this one names the
|
|
// wrong family on purpose (Key::from_bytes over [blob tag] + oid).
|
|
let blob_tagged = {
|
|
let mut tagged = vec![Algorithm::GitBlobSha1.byte()];
|
|
tagged.extend_from_slice(key.digest().bytes());
|
|
tagged
|
|
};
|
|
let blob_tagged_key = Key::from_bytes(&blob_tagged).expect("blob-tag key decodes");
|
|
assert!(
|
|
store
|
|
.get(&blob_tagged_key.as_bytes())
|
|
.await
|
|
.expect("blob-tag get ok")
|
|
.is_none(),
|
|
"the tree oid is not reachable through the blob tags"
|
|
);
|
|
}
|
|
|
|
/// The seam's blob arm is the old canonical put's behavior (ADR-020
|
|
/// §Decision 5 — no dedicated method remains to guard): a blob
|
|
/// algorithm through `put_as` lands under the canonical blob
|
|
/// derivation, git-identically — the known-answer vector is
|
|
/// `git hash-object`'s.
|
|
#[cfg(feature = "mem")]
|
|
#[tokio::test]
|
|
async fn put_as_blob_algorithm_lands_under_the_canonical_derivation() {
|
|
use std::io::Cursor;
|
|
|
|
use alkblobs::Store;
|
|
|
|
let store = Store::mem_only();
|
|
let content = b"hello";
|
|
let expected = [
|
|
(
|
|
"8aec4e4876f854f688d0ebfc8f37598f38e5fd6903cccc850ca36591175aeb60",
|
|
Algorithm::GitBlobSha256,
|
|
),
|
|
(
|
|
"b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0",
|
|
Algorithm::GitBlobSha1,
|
|
),
|
|
];
|
|
for (vector, algorithm) in expected {
|
|
let (key, pin) = store
|
|
.put_as(algorithm, Some(5), Cursor::new(content))
|
|
.await
|
|
.expect("blob algorithms are legal at the seam");
|
|
assert_eq!(key.algorithm(), algorithm);
|
|
assert_eq!(
|
|
hex::encode(key.digest().bytes()),
|
|
vector,
|
|
"{algorithm:?}: the seam's blob arm is the canonical derivation"
|
|
);
|
|
drop(pin);
|
|
}
|
|
}
|