#![cfg(feature = "cli")]
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn net_blob() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_net-blob"))
}
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let mut base = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
base.push(format!(
"net-blob-cli-test-{}-{}-{}",
tag,
std::process::id(),
nanos
));
fs::create_dir_all(&base).expect("create temp dir");
Self(base)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn run_net_blob(dir: &Path, args: &[&str]) -> Output {
Command::new(net_blob())
.arg("-d")
.arg(dir)
.args(args)
.output()
.expect("spawn net-blob")
}
fn stdout_string(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn stderr_string(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
fn parse_put_hash(out: &Output) -> String {
let body = stdout_string(out);
let v: serde_json::Value = serde_json::from_str(body.trim())
.unwrap_or_else(|e| panic!("net-blob put JSON parse failed: {}; body: {:?}", e, body));
v["hash"]
.as_str()
.expect("hash field on put JSON")
.to_string()
}
#[test]
fn put_then_get_round_trips_file_bytes() {
let tmp = TempDir::new("round-trip");
let dir = tmp.path();
let input = dir.join("payload.txt");
let payload = b"hello from net-blob integration tests";
fs::write(&input, payload).expect("write input");
let put_out = run_net_blob(
dir,
&[
"--format",
"json",
"put",
input.to_str().expect("input path utf8"),
],
);
assert!(
put_out.status.success(),
"put must succeed; stderr={}",
stderr_string(&put_out)
);
let hash = parse_put_hash(&put_out);
assert_eq!(
hash.len(),
64,
"BLAKE3 hash hex must be 64 chars; got {}",
hash
);
let output = dir.join("out.bin");
let get_out = run_net_blob(
dir,
&[
"get",
&hash,
"--out",
output.to_str().expect("out path utf8"),
],
);
assert!(
get_out.status.success(),
"get must succeed; stderr={}",
stderr_string(&get_out)
);
let fetched = fs::read(&output).expect("read fetched");
assert_eq!(fetched, payload, "round-trip bytes must match");
}
#[test]
fn put_human_format_prints_uri_hash_size() {
let tmp = TempDir::new("human-put");
let input = tmp.path().join("p.bin");
fs::write(&input, b"abc").unwrap();
let out = run_net_blob(tmp.path(), &["put", input.to_str().unwrap()]);
assert!(out.status.success());
let body = stdout_string(&out);
assert!(body.contains("stored:"), "missing 'stored:' line: {}", body);
assert!(body.contains("hash:"), "missing 'hash:' line: {}", body);
assert!(
body.contains("size: 3"),
"missing 'size: 3' line: {}",
body
);
}
#[test]
fn get_out_refuses_to_clobber_existing_file() {
let tmp = TempDir::new("get-out-clobber");
let dir = tmp.path();
let input = dir.join("p.bin");
fs::write(&input, b"x").unwrap();
let put = run_net_blob(dir, &["--format", "json", "put", input.to_str().unwrap()]);
let hash = parse_put_hash(&put);
let output = dir.join("preexisting.bin");
fs::write(&output, b"do not clobber").unwrap();
let get = run_net_blob(dir, &["get", &hash, "--out", output.to_str().unwrap()]);
assert!(
!get.status.success(),
"get --out must error when the output path already exists"
);
let preserved = fs::read(&output).unwrap();
assert_eq!(
preserved, b"do not clobber",
"preexisting file contents must be untouched"
);
}
#[test]
fn exists_returns_zero_for_present_hash_one_for_absent() {
let tmp = TempDir::new("exists");
let input = tmp.path().join("p.txt");
fs::write(&input, b"check me").unwrap();
let put = run_net_blob(
tmp.path(),
&["--format", "json", "put", input.to_str().unwrap()],
);
let hash = parse_put_hash(&put);
let exists_present = run_net_blob(tmp.path(), &["exists", &hash]);
assert!(
exists_present.status.success(),
"exists must exit 0 for stored hash; stderr={}",
stderr_string(&exists_present)
);
let absent = "0".repeat(64);
let exists_absent = run_net_blob(tmp.path(), &["exists", &absent]);
assert_eq!(
exists_absent.status.code(),
Some(1),
"exists must exit 1 for missing hash; stderr={}",
stderr_string(&exists_absent),
);
}
#[test]
fn stat_json_carries_size_and_replicas_observed() {
let tmp = TempDir::new("stat");
let input = tmp.path().join("p.txt");
fs::write(&input, b"0123456789").unwrap(); let put = run_net_blob(
tmp.path(),
&["--format", "json", "put", input.to_str().unwrap()],
);
let hash = parse_put_hash(&put);
let stat = run_net_blob(
tmp.path(),
&["--format", "json", "stat", &hash, "--size", "10"],
);
assert!(stat.status.success());
let v: serde_json::Value =
serde_json::from_str(stdout_string(&stat).trim()).expect("stat JSON");
assert_eq!(v["hash"].as_str().unwrap(), hash);
assert_eq!(v["size"].as_u64().unwrap(), 10);
assert_eq!(v["replicas_observed"].as_u64().unwrap(), 0);
}
#[test]
fn get_rejects_malformed_hash_with_typed_error() {
let tmp = TempDir::new("bad-hash");
let out = run_net_blob(tmp.path(), &["get", "not-a-real-hash"]);
assert!(!out.status.success(), "get must reject malformed hash");
let err = stderr_string(&out);
assert!(
err.contains("net-blob:") || err.contains("hash"),
"stderr must surface a parse error; got {:?}",
err
);
}
#[test]
fn gc_dry_run_reports_zero_candidates_on_empty_dir() {
let tmp = TempDir::new("gc-empty");
let out = run_net_blob(
tmp.path(),
&["--format", "json", "gc", "--dry-run", "--retention", "1s"],
);
assert!(
out.status.success(),
"gc --dry-run on empty dir must succeed; stderr={}",
stderr_string(&out)
);
let v: serde_json::Value = serde_json::from_str(stdout_string(&out).trim()).expect("gc JSON");
assert!(v["dry_run"].as_bool().unwrap());
assert_eq!(v["candidates"].as_array().unwrap().len(), 0);
}
#[test]
fn metrics_emits_prometheus_text_with_dataforts_blob_prefix() {
let tmp = TempDir::new("metrics");
let out = run_net_blob(tmp.path(), &["metrics"]);
assert!(out.status.success());
let body = stdout_string(&out);
assert!(
body.contains("dataforts_blob"),
"metrics body must include dataforts_blob_* prefixes; got:\n{}",
body
);
assert!(
body.contains("# HELP") && body.contains("# TYPE"),
"metrics body must follow the Prometheus text-exposition format"
);
}
#[test]
fn pin_and_unpin_subcommands_acknowledge_in_separate_processes() {
let tmp = TempDir::new("pin-chain");
let known = "0".repeat(64);
let pin_out = run_net_blob(tmp.path(), &["pin", &known]);
assert!(
pin_out.status.success(),
"pin must succeed; stderr={}",
stderr_string(&pin_out)
);
let body = stdout_string(&pin_out);
assert!(
body.contains("pinned:") && body.contains(&known),
"pin output must echo the pinned hash; got:\n{}",
body
);
let unpin_out = run_net_blob(tmp.path(), &["unpin", &known]);
assert!(unpin_out.status.success());
let body = stdout_string(&unpin_out);
assert!(
body.contains("unpinned:") && body.contains(&known),
"unpin output must echo the unpinned hash; got:\n{}",
body
);
}
#[test]
fn put_reads_stdin_when_path_is_dash() {
use std::io::Write;
use std::process::Stdio;
let tmp = TempDir::new("stdin-put");
let mut child = Command::new(net_blob())
.arg("-d")
.arg(tmp.path())
.args(["--format", "json", "put", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn net-blob");
let payload = b"piped from stdin";
child
.stdin
.as_mut()
.unwrap()
.write_all(payload)
.expect("write stdin");
let out = child.wait_with_output().expect("wait child");
assert!(
out.status.success(),
"stdin put must succeed; stderr={}",
stderr_string(&out)
);
let v: serde_json::Value = serde_json::from_str(stdout_string(&out).trim()).expect("put JSON");
assert_eq!(v["size"].as_u64().unwrap(), payload.len() as u64);
}
#[test]
fn overflow_status_human_format_prints_disabled_by_default() {
let tmp = TempDir::new("overflow-status");
let out = run_net_blob(tmp.path(), &["overflow", "status"]);
assert!(
out.status.success(),
"overflow status must succeed on a clean dir; stderr={}",
stderr_string(&out)
);
let body = stdout_string(&out);
assert!(
body.contains("configured enabled: false"),
"human output must show enabled=false on a default adapter; got:\n{}",
body
);
assert!(
body.contains("runtime active (this proc): false"),
"human output must show active=false when no tick has fired; got:\n{}",
body
);
assert!(
body.contains("pushes_admitted_total: 0"),
"human output must include the admitted counter at zero; got:\n{}",
body
);
}
#[test]
fn overflow_status_json_format_shape_is_stable() {
let tmp = TempDir::new("overflow-status-json");
let out = run_net_blob(tmp.path(), &["--format", "json", "overflow", "status"]);
assert!(out.status.success());
let body = stdout_string(&out);
let v: serde_json::Value =
serde_json::from_str(body.trim()).expect("overflow status JSON parse");
assert_eq!(v["config"]["enabled"], serde_json::json!(false));
assert_eq!(v["active"], serde_json::json!(false));
assert_eq!(v["counters"]["pushes_admitted_total"], serde_json::json!(0));
for reason in [
"rejected_no_storage_cap_total",
"rejected_not_participating_total",
"rejected_sender_not_overflowing_total",
"rejected_unhealthy_total",
"rejected_scope_mismatch_total",
"rejected_insufficient_disk_total",
] {
assert_eq!(
v["counters"][reason],
serde_json::json!(0),
"JSON output must include counter `{}` at zero",
reason
);
}
}
#[test]
fn metrics_body_includes_overflow_counter_family() {
let tmp = TempDir::new("metrics-overflow");
let out = run_net_blob(tmp.path(), &["metrics"]);
assert!(out.status.success());
let body = stdout_string(&out);
for needle in [
"dataforts_blob_overflow_pushes_admitted_total",
"dataforts_blob_overflow_push_errors_total",
"dataforts_blob_overflow_pushed_bytes_total",
"dataforts_blob_overflow_rejected_no_target_total",
"dataforts_blob_overflow_rejected_total",
"dataforts_blob_overflow_high_water_triggered_total",
"dataforts_blob_overflow_low_water_cleared_total",
"dataforts_blob_overflow_active",
"dataforts_blob_overflow_disk_ratio",
"reason=\"no_storage_cap\"",
"reason=\"not_participating\"",
"reason=\"sender_not_overflowing\"",
"reason=\"unhealthy\"",
"reason=\"scope_mismatch\"",
"reason=\"insufficient_disk\"",
] {
assert!(
body.contains(needle),
"metrics body must include `{}`; got:\n{}",
needle,
body
);
}
}
const DUMMY_HASH: &str = "deadbeef00000000000000000000000000000000000000000000000000000000";
#[test]
fn path_subcommand_rejects_offset_at_or_past_size() {
let tmp = TempDir::new("path-offset-oob");
let out = run_net_blob(
tmp.path(),
&[
"path", DUMMY_HASH, "--size", "1024", "--depth", "1", "--offset", "1024",
],
);
assert!(!out.status.success(), "offset == size must exit nonzero");
let stderr = stderr_string(&out);
assert!(
stderr.contains("offset") && stderr.contains("size"),
"expected an offset-vs-size diagnostic, got: {}",
stderr
);
}
#[test]
fn tree_subcommand_on_missing_root_exits_cleanly() {
let tmp = TempDir::new("tree-missing-root");
let out = run_net_blob(
tmp.path(),
&["tree", DUMMY_HASH, "--size", "1024", "--depth", "1"],
);
assert!(
!out.status.success(),
"tree on a hash that doesn't exist locally must exit nonzero"
);
let stderr = stderr_string(&out);
assert!(
!stderr.contains("panicked"),
"tree must surface a clean error, not panic. stderr:\n{}",
stderr
);
}
#[test]
fn repair_subcommand_on_missing_root_exits_cleanly() {
let tmp = TempDir::new("repair-missing-root");
let out = run_net_blob(
tmp.path(),
&["repair", DUMMY_HASH, "--size", "1024", "--depth", "1"],
);
assert!(
!out.status.success(),
"repair on a missing root must exit nonzero"
);
let stderr = stderr_string(&out);
assert!(
!stderr.contains("panicked"),
"repair must surface a clean error, not panic. stderr:\n{}",
stderr
);
}
#[test]
fn verify_subcommand_on_missing_root_reports_root_unreachable() {
let tmp = TempDir::new("verify-missing-root");
let out = Command::new(net_blob())
.args([
"-d",
tmp.path().to_str().unwrap(),
"--format",
"json",
"verify",
DUMMY_HASH,
"--size",
"1024",
"--depth",
"1",
])
.output()
.expect("spawn net-blob");
let code = out.status.code();
assert_eq!(
code,
Some(3),
"missing root must exit 3 (root_unreachable), got exit {:?}",
code,
);
let stdout = stdout_string(&out);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("verify --format json must emit valid JSON");
assert_eq!(
parsed["root_unreachable"].as_bool(),
Some(true),
"missing root must surface root_unreachable=true; got: {}",
stdout,
);
assert_eq!(parsed["healthy"].as_u64(), Some(0));
assert_eq!(parsed["missing"].as_u64(), Some(0));
assert_eq!(parsed["corrupted"].as_u64(), Some(0));
}
#[test]
fn put_tree_emits_hash_size_depth_and_round_trips_through_tree_subcommand() {
let tmp = TempDir::new("put-tree-round-trip");
let dir = tmp.path();
let payload = vec![0xC4u8; 16 * 1024 * 1024];
let input = dir.join("payload.bin");
fs::write(&input, &payload).unwrap();
let put_out = run_net_blob(
dir,
&["--format", "json", "put-tree", input.to_str().unwrap()],
);
assert!(
put_out.status.success(),
"put-tree must succeed; stderr={}",
stderr_string(&put_out)
);
let body = stdout_string(&put_out);
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
let hash = parsed["hash"].as_str().expect("hash field").to_string();
let size = parsed["size"].as_u64().expect("size field");
let depth = parsed["depth"].as_u64().expect("depth field") as u8;
assert_eq!(hash.len(), 64, "BLAKE3 hex hash must be 64 chars");
assert_eq!(size, payload.len() as u64);
assert!(
(1..=4).contains(&depth),
"Tree depth must lie in 1..=MAX_TREE_DEPTH; got {depth}",
);
assert_eq!(parsed["encoding"].as_str(), Some("Replicated"));
let tree_out = run_net_blob(
dir,
&[
"tree",
&hash,
"--size",
&size.to_string(),
"--depth",
&depth.to_string(),
],
);
assert!(
tree_out.status.success(),
"tree subcommand must accept put-tree's emitted triple; stderr={}",
stderr_string(&tree_out),
);
}
#[test]
fn put_tree_rs_spec_produces_reed_solomon_encoded_tree() {
let tmp = TempDir::new("put-tree-rs");
let dir = tmp.path();
let payload = vec![0xE7u8; 16 * 1024 * 1024];
let input = dir.join("payload.bin");
fs::write(&input, &payload).unwrap();
let out = run_net_blob(
dir,
&[
"--format",
"json",
"put-tree",
input.to_str().unwrap(),
"--rs",
"k=4,m=2",
],
);
assert!(
out.status.success(),
"put-tree --rs must succeed; stderr={}",
stderr_string(&out)
);
let body = stdout_string(&out);
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
assert_eq!(
parsed["encoding"].as_str(),
Some("ReedSolomon(k=4,m=2)"),
"encoding field must reflect --rs spec; body: {}",
body
);
}
#[test]
fn put_tree_rs_spec_rejects_malformed_input() {
let tmp = TempDir::new("put-tree-rs-bad");
let dir = tmp.path();
let input = dir.join("p.bin");
fs::write(&input, b"x").unwrap();
for spec in &["k4,m=2", "k=4,m=", "k=4", "garbage"] {
let out = run_net_blob(dir, &["put-tree", input.to_str().unwrap(), "--rs", spec]);
assert!(
!out.status.success(),
"malformed --rs '{}' must exit nonzero",
spec
);
let stderr = stderr_string(&out);
assert!(
!stderr.contains("panicked"),
"put-tree --rs '{}' must clean-error, not panic. stderr:\n{}",
spec,
stderr,
);
}
}
#[test]
fn tree_subcommand_on_corrupt_root_decode_fails_cleanly() {
let tmp = TempDir::new("tree-corrupt-root");
let dir = tmp.path();
let input = dir.join("garbage.bin");
fs::write(&input, b"this is not a TreeNode postcard body").unwrap();
let put_out = run_net_blob(dir, &["--format", "json", "put", input.to_str().unwrap()]);
let hash = parse_put_hash(&put_out);
let out = run_net_blob(dir, &["tree", &hash, "--size", "1024", "--depth", "1"]);
assert!(
!out.status.success(),
"tree on corrupt-decode root must exit nonzero"
);
let stderr = stderr_string(&out);
assert!(
!stderr.contains("panicked"),
"tree must clean-error on bad TreeNode bytes, not panic. stderr:\n{}",
stderr,
);
}
#[test]
fn repair_subcommand_on_corrupt_root_decode_fails_cleanly() {
let tmp = TempDir::new("repair-corrupt-root");
let dir = tmp.path();
let input = dir.join("garbage.bin");
fs::write(&input, b"not-a-tree-node-body").unwrap();
let put_out = run_net_blob(dir, &["--format", "json", "put", input.to_str().unwrap()]);
let hash = parse_put_hash(&put_out);
let out = run_net_blob(dir, &["repair", &hash, "--size", "1024", "--depth", "1"]);
assert!(
!out.status.success(),
"repair on corrupt-decode root must exit nonzero"
);
let stderr = stderr_string(&out);
assert!(
!stderr.contains("panicked"),
"repair must clean-error on bad TreeNode bytes. stderr:\n{}",
stderr,
);
}
#[test]
fn verify_subcommand_on_corrupt_root_exits_cleanly() {
let tmp = TempDir::new("verify-corrupt-root");
let dir = tmp.path();
let input = dir.join("garbage.bin");
fs::write(&input, b"not-a-tree-node").unwrap();
let put_out = run_net_blob(dir, &["--format", "json", "put", input.to_str().unwrap()]);
let hash = parse_put_hash(&put_out);
let out = Command::new(net_blob())
.args([
"-d",
dir.to_str().unwrap(),
"--format",
"json",
"verify",
&hash,
"--size",
"1024",
"--depth",
"1",
])
.output()
.expect("spawn net-blob");
assert!(
!out.status.success(),
"verify on corrupt-decode root must exit nonzero"
);
let stderr = stderr_string(&out);
assert!(
!stderr.contains("panicked"),
"verify must clean-error on bad TreeNode bytes. stderr:\n{}",
stderr,
);
}
#[test]
fn tree_repair_verify_path_rejects_malformed_hash() {
let tmp = TempDir::new("malformed-hash");
let bogus = "not-a-real-hash";
for subcommand in &["tree", "repair", "verify"] {
let out = run_net_blob(
tmp.path(),
&[*subcommand, bogus, "--size", "1024", "--depth", "1"],
);
assert!(
!out.status.success(),
"{} with malformed hash must exit nonzero",
subcommand
);
let stderr = stderr_string(&out);
assert!(
!stderr.contains("panicked"),
"{} must clean-error on bad hash, not panic. stderr:\n{}",
subcommand,
stderr
);
}
let out = run_net_blob(
tmp.path(),
&[
"path", bogus, "--size", "1024", "--depth", "1", "--offset", "0",
],
);
assert!(!out.status.success());
let stderr = stderr_string(&out);
assert!(!stderr.contains("panicked"));
}