- `put(len: Option<u64>, stream: impl AsyncRead + Unpin + Send) -> (Key, Pin)`: known-length one-pass flow; unknown-length buffer-to-threshold flow with the mid-stream fresh-stage flush (ADR-003 option (d), three numbered rules: flush-and-continue, derivation over the eventual stored artifact as a whole via buffer form and StagingCursor read-back form, zero residue on stream error) - staged-put seam amended (operator sign-off): `put_staged()` keyless handout, `StagingCursor::commit(self, key)` publishes under the store-core-resolved key — the canonical key completes at stream end and the wave-1 handout shape could not express the commit-rename flow - pin-before-publish rides the arbitration lock on both paths and both tiers; dedup re-puts refcount the existing entry - Batch seam: `Store::batch()` + `put_in_batch()`, members publish pin- before-publish, group pins drop together on `Batch::commit` (new public verb) or drop - kv-only rejection (ADR-012 §6.2): known-length over-threshold rejects immediately (stream never read), unknown-length at mid-stream overflow; zero residue; error carrier `Io(String)` (named design point) - declared-len mismatch rejects `Verification` zero-residue (named design point); stream shape generic-no-dyn (named design point) - incremental BlobHasher (pinned byte-equal to Algorithm::derive); AnyStaging cursor dispatch; git-interop put-path leg against git hash-object; eleven put-path behavior tests Verification: cargo test (default/--features mem/--all-features) green; clippy --all-targets -D warnings green in all three feature shapes; cargo fmt --check green; cargo doc --no-deps at the 24-warning baseline.
264 lines
8.8 KiB
Rust
264 lines
8.8 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.
|
|
//!
|
|
//! 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());
|
|
}
|
|
}
|
|
|
|
/// 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");
|
|
}
|