385 lines
15 KiB
Rust
385 lines
15 KiB
Rust
|
|
//! SIM_SPEC §9.6 behavioural property tests for the bundle writer.
|
||
|
|
//! Scenario-level posture: build a `FileBundleWriter` against a temp
|
||
|
|
//! directory, feed it records (in various orders), call `finalize`,
|
||
|
|
//! then verify the directory layout, NDJSON envelope, hash integrity,
|
||
|
|
//! and ordering rules.
|
||
|
|
|
||
|
|
use std::collections::BTreeMap;
|
||
|
|
use std::fs;
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
|
||
|
|
use tempfile::TempDir;
|
||
|
|
|
||
|
|
use simulation::bundle::{
|
||
|
|
BundleRecord, BundleWriter, DeliveryDropReason, EventPayload, EventRecord, MutationRecord,
|
||
|
|
SnapshotRecord,
|
||
|
|
};
|
||
|
|
use simulation::bundle_file::FileBundleWriter;
|
||
|
|
use simulation::network::{CacheTransition, DropReason};
|
||
|
|
use simulation::scenario::{HostKindRegistry, Mutation, MutationKind, Scenario, load_from_str};
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
// Fixtures
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
fn registry() -> HostKindRegistry {
|
||
|
|
HostKindRegistry::with_swim()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn fake_path() -> &'static Path {
|
||
|
|
Path::new("test://bundle.toml")
|
||
|
|
}
|
||
|
|
|
||
|
|
fn small_scenario() -> Scenario {
|
||
|
|
let body = r#"
|
||
|
|
name = "bundle_test"
|
||
|
|
seed = 99
|
||
|
|
duration_ns = 1_000_000
|
||
|
|
|
||
|
|
[default_tick]
|
||
|
|
period_ns = 1000
|
||
|
|
|
||
|
|
[default_link]
|
||
|
|
latency_ns = 0
|
||
|
|
jitter_stddev_ns = 0
|
||
|
|
loss_prob_ppm = 0
|
||
|
|
reorder_prob_ppm = 0
|
||
|
|
bandwidth_bps = 1_000_000_000
|
||
|
|
cold_dial_penalty_ns = 0
|
||
|
|
cache_warm_after_ns = 0
|
||
|
|
cache_invalidate_after_idle_ns = 10_000_000_000
|
||
|
|
|
||
|
|
[[peers]]
|
||
|
|
id = "a"
|
||
|
|
kind = "swim"
|
||
|
|
initial_state = "alive"
|
||
|
|
kind_config = { probe_interval_ns = 1, suspicion_timeout_ns = 10 }
|
||
|
|
|
||
|
|
[[peers]]
|
||
|
|
id = "b"
|
||
|
|
kind = "swim"
|
||
|
|
initial_state = "alive"
|
||
|
|
kind_config = { probe_interval_ns = 1, suspicion_timeout_ns = 10 }
|
||
|
|
|
||
|
|
[[links]]
|
||
|
|
from = "a"
|
||
|
|
to = "b"
|
||
|
|
|
||
|
|
[[links]]
|
||
|
|
from = "b"
|
||
|
|
to = "a"
|
||
|
|
"#;
|
||
|
|
load_from_str(fake_path(), body, ®istry()).unwrap()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn sample_records() -> Vec<BundleRecord> {
|
||
|
|
vec![
|
||
|
|
BundleRecord::Event(EventRecord {
|
||
|
|
virtual_time_ns: 100,
|
||
|
|
host_id: Some("a".into()),
|
||
|
|
kind_tag: "swim".into(),
|
||
|
|
event: EventPayload::Bytes(br#"{"kind":"state_transition","from":"alive","to":"suspect"}"#.to_vec()),
|
||
|
|
}),
|
||
|
|
BundleRecord::Event(EventRecord {
|
||
|
|
virtual_time_ns: 50,
|
||
|
|
host_id: Some("b".into()),
|
||
|
|
kind_tag: "engine".into(),
|
||
|
|
event: EventPayload::DropOnSend {
|
||
|
|
from: "a".into(),
|
||
|
|
to: "b".into(),
|
||
|
|
reason: DropReason::Lossy,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
BundleRecord::Event(EventRecord {
|
||
|
|
virtual_time_ns: 75,
|
||
|
|
host_id: Some("a".into()),
|
||
|
|
kind_tag: "engine".into(),
|
||
|
|
event: EventPayload::CacheStateChange {
|
||
|
|
from: "a".into(),
|
||
|
|
to: "b".into(),
|
||
|
|
transition: CacheTransition::Warmed,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
BundleRecord::Snapshot(SnapshotRecord {
|
||
|
|
virtual_time_ns: 200,
|
||
|
|
host_id: "a".into(),
|
||
|
|
kind_tag: "swim".into(),
|
||
|
|
snapshot: br#"{"members":[{"id":"a","state":"alive"}]}"#.to_vec(),
|
||
|
|
}),
|
||
|
|
BundleRecord::Snapshot(SnapshotRecord {
|
||
|
|
virtual_time_ns: 300,
|
||
|
|
host_id: "a".into(),
|
||
|
|
kind_tag: "swim".into(),
|
||
|
|
snapshot: br#"{"members":[{"id":"a","state":"alive"},{"id":"b","state":"alive"}]}"#
|
||
|
|
.to_vec(),
|
||
|
|
}),
|
||
|
|
BundleRecord::Snapshot(SnapshotRecord {
|
||
|
|
virtual_time_ns: 200,
|
||
|
|
host_id: "b".into(),
|
||
|
|
kind_tag: "swim".into(),
|
||
|
|
snapshot: br#"{"members":[{"id":"b","state":"alive"}]}"#.to_vec(),
|
||
|
|
}),
|
||
|
|
BundleRecord::Mutation(MutationRecord {
|
||
|
|
virtual_time_ns: 150,
|
||
|
|
mutation: Mutation {
|
||
|
|
at_ns: 150,
|
||
|
|
kind: MutationKind::Partition {
|
||
|
|
peers_a: vec!["a".into()],
|
||
|
|
peers_b: vec!["b".into()],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
fn write_bundle(tmp: &Path, records: &[BundleRecord]) -> PathBuf {
|
||
|
|
let out = tmp.join("bundle");
|
||
|
|
let mut w = FileBundleWriter::new(&out, small_scenario());
|
||
|
|
for r in records {
|
||
|
|
w.write(r.clone());
|
||
|
|
}
|
||
|
|
w.finalize().expect("finalize");
|
||
|
|
out
|
||
|
|
}
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
// §9.6 Layout
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn every_produced_bundle_has_the_9_1_entries() {
|
||
|
|
let tmp = TempDir::new().unwrap();
|
||
|
|
let out = write_bundle(tmp.path(), &sample_records());
|
||
|
|
|
||
|
|
assert!(out.join("manifest.json").is_file());
|
||
|
|
assert!(out.join("scenario.toml").is_file());
|
||
|
|
assert!(out.join("events.ndjson").is_file());
|
||
|
|
assert!(out.join("snapshots").is_dir());
|
||
|
|
// verdicts.json is the assertion evaluator's responsibility per
|
||
|
|
// §10.5; the writer must not pre-create it.
|
||
|
|
assert!(!out.join("verdicts.json").exists());
|
||
|
|
// Per §9.1 snapshots/<host>/<seq>.json:
|
||
|
|
assert!(out.join("snapshots/a/0.json").is_file());
|
||
|
|
assert!(out.join("snapshots/a/1.json").is_file());
|
||
|
|
assert!(out.join("snapshots/b/0.json").is_file());
|
||
|
|
}
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
// §9.6 Envelope conformance
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn every_events_ndjson_line_is_valid_json_with_envelope_shape() {
|
||
|
|
let tmp = TempDir::new().unwrap();
|
||
|
|
let out = write_bundle(tmp.path(), &sample_records());
|
||
|
|
let txt = fs::read_to_string(out.join("events.ndjson")).unwrap();
|
||
|
|
let mut lines = 0;
|
||
|
|
for line in txt.lines() {
|
||
|
|
let v: serde_json::Value =
|
||
|
|
serde_json::from_str(line).expect("each line must be valid JSON");
|
||
|
|
let obj = v.as_object().expect("envelope must be an object");
|
||
|
|
for key in ["virtual_time_ns", "host_id", "kind_tag", "event"] {
|
||
|
|
assert!(obj.contains_key(key), "envelope missing {key}: {line}");
|
||
|
|
}
|
||
|
|
assert!(obj["virtual_time_ns"].is_u64());
|
||
|
|
assert!(obj["kind_tag"].is_string());
|
||
|
|
// host_id is string-or-null
|
||
|
|
assert!(obj["host_id"].is_string() || obj["host_id"].is_null());
|
||
|
|
lines += 1;
|
||
|
|
}
|
||
|
|
assert!(lines >= 4, "expected ≥4 event lines, got {lines}");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
// §9.6 Hash integrity
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn every_manifest_hash_matches_the_file_it_names() {
|
||
|
|
use sha2::{Digest, Sha256};
|
||
|
|
|
||
|
|
let tmp = TempDir::new().unwrap();
|
||
|
|
let out = write_bundle(tmp.path(), &sample_records());
|
||
|
|
let manifest: serde_json::Value =
|
||
|
|
serde_json::from_str(&fs::read_to_string(out.join("manifest.json")).unwrap()).unwrap();
|
||
|
|
|
||
|
|
let scenario_hash = manifest["scenario_sha256"].as_str().unwrap();
|
||
|
|
let actual_scenario = hex_sha256_of(&fs::read(out.join("scenario.toml")).unwrap());
|
||
|
|
assert_eq!(scenario_hash, actual_scenario);
|
||
|
|
|
||
|
|
let events_hash = manifest["events_ndjson_sha256"].as_str().unwrap();
|
||
|
|
let actual_events = hex_sha256_of(&fs::read(out.join("events.ndjson")).unwrap());
|
||
|
|
assert_eq!(events_hash, actual_events);
|
||
|
|
|
||
|
|
let snap_hashes = manifest["snapshot_sha256"].as_object().unwrap();
|
||
|
|
for (rel, claimed) in snap_hashes {
|
||
|
|
let claimed = claimed.as_str().unwrap();
|
||
|
|
let actual = hex_sha256_of(&fs::read(out.join(rel)).unwrap());
|
||
|
|
assert_eq!(claimed, actual, "snapshot {rel} hash mismatch");
|
||
|
|
}
|
||
|
|
|
||
|
|
fn hex_sha256_of(bytes: &[u8]) -> String {
|
||
|
|
let mut h = Sha256::new();
|
||
|
|
h.update(bytes);
|
||
|
|
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
// §9.6 Snapshot organization
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn snapshots_are_one_file_per_record_with_monotonic_seq_per_host() {
|
||
|
|
let tmp = TempDir::new().unwrap();
|
||
|
|
let records = sample_records();
|
||
|
|
let snap_count_a = records
|
||
|
|
.iter()
|
||
|
|
.filter(|r| matches!(r, BundleRecord::Snapshot(s) if s.host_id == "a"))
|
||
|
|
.count();
|
||
|
|
let snap_count_b = records
|
||
|
|
.iter()
|
||
|
|
.filter(|r| matches!(r, BundleRecord::Snapshot(s) if s.host_id == "b"))
|
||
|
|
.count();
|
||
|
|
|
||
|
|
let out = write_bundle(tmp.path(), &records);
|
||
|
|
|
||
|
|
for host in ["a", "b"] {
|
||
|
|
let dir = out.join("snapshots").join(host);
|
||
|
|
let mut names: Vec<String> = fs::read_dir(&dir)
|
||
|
|
.unwrap()
|
||
|
|
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||
|
|
.collect();
|
||
|
|
names.sort();
|
||
|
|
let expected = if host == "a" { snap_count_a } else { snap_count_b };
|
||
|
|
assert_eq!(names.len(), expected, "host {host} file count");
|
||
|
|
let expected_names: Vec<String> = (0..expected).map(|i| format!("{i}.json")).collect();
|
||
|
|
assert_eq!(names, expected_names);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
// §9.6 Ordering is deterministic + arrival-order independence
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn identical_record_streams_produce_byte_identical_bundles() {
|
||
|
|
let tmp = TempDir::new().unwrap();
|
||
|
|
let recs = sample_records();
|
||
|
|
let a = write_bundle(&tmp.path().join("a"), &recs);
|
||
|
|
let b = write_bundle(&tmp.path().join("b"), &recs);
|
||
|
|
assert_eq!(bundle_signature(&a), bundle_signature(&b));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn arrival_order_does_not_affect_produced_bundle() {
|
||
|
|
let tmp = TempDir::new().unwrap();
|
||
|
|
let r1 = sample_records();
|
||
|
|
let mut r2 = r1.clone();
|
||
|
|
// Shuffle deterministically by reversing.
|
||
|
|
r2.reverse();
|
||
|
|
|
||
|
|
let a = write_bundle(&tmp.path().join("a"), &r1);
|
||
|
|
let b = write_bundle(&tmp.path().join("b"), &r2);
|
||
|
|
assert_eq!(bundle_signature(&a), bundle_signature(&b));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn writing_same_stream_twice_to_a_fresh_path_is_byte_identical() {
|
||
|
|
let tmp = TempDir::new().unwrap();
|
||
|
|
let recs = sample_records();
|
||
|
|
let a = write_bundle(&tmp.path().join("a"), &recs);
|
||
|
|
let b = write_bundle(&tmp.path().join("b"), &recs);
|
||
|
|
|
||
|
|
// Compare every regular file under each tree.
|
||
|
|
let a_files = collect_files(&a);
|
||
|
|
let b_files = collect_files(&b);
|
||
|
|
assert_eq!(a_files.keys().collect::<Vec<_>>(), b_files.keys().collect::<Vec<_>>());
|
||
|
|
for (rel, a_bytes) in &a_files {
|
||
|
|
if rel == "manifest.json" {
|
||
|
|
// The manifest may include the architecture string; that's
|
||
|
|
// identical across two runs on the same machine.
|
||
|
|
}
|
||
|
|
assert_eq!(a_bytes, &b_files[rel], "file diff at {rel}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
// Engine-synth events render with structured payloads
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn engine_synth_events_serialise_with_kind_discriminator() {
|
||
|
|
let tmp = TempDir::new().unwrap();
|
||
|
|
let recs = vec![
|
||
|
|
BundleRecord::Event(EventRecord {
|
||
|
|
virtual_time_ns: 1,
|
||
|
|
host_id: Some("a".into()),
|
||
|
|
kind_tag: "engine".into(),
|
||
|
|
event: EventPayload::DropOnDelivery {
|
||
|
|
to: "b".into(),
|
||
|
|
reason: DeliveryDropReason::HostKilled,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
BundleRecord::Event(EventRecord {
|
||
|
|
virtual_time_ns: 2,
|
||
|
|
host_id: Some("a".into()),
|
||
|
|
kind_tag: "engine".into(),
|
||
|
|
event: EventPayload::DialOutcome {
|
||
|
|
from: "a".into(),
|
||
|
|
to: "b".into(),
|
||
|
|
warm: true,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
];
|
||
|
|
let out = write_bundle(tmp.path(), &recs);
|
||
|
|
let txt = fs::read_to_string(out.join("events.ndjson")).unwrap();
|
||
|
|
assert!(txt.contains("\"drop_on_delivery\""));
|
||
|
|
assert!(txt.contains("\"host_killed\""));
|
||
|
|
assert!(txt.contains("\"dial_outcome\""));
|
||
|
|
assert!(txt.contains("\"warm\":true"));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
// Helpers
|
||
|
|
// ──────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Returns a relative-path → sha256 map for every regular file under
|
||
|
|
/// `root`. Used to compare two bundle trees content-by-content without
|
||
|
|
/// caring about path order in a vec.
|
||
|
|
fn bundle_signature(root: &Path) -> BTreeMap<String, String> {
|
||
|
|
use sha2::{Digest, Sha256};
|
||
|
|
let files = collect_files(root);
|
||
|
|
files
|
||
|
|
.into_iter()
|
||
|
|
.map(|(rel, bytes)| {
|
||
|
|
let mut h = Sha256::new();
|
||
|
|
h.update(&bytes);
|
||
|
|
(rel, h.finalize().iter().map(|b| format!("{b:02x}")).collect())
|
||
|
|
})
|
||
|
|
.collect()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn collect_files(root: &Path) -> BTreeMap<String, Vec<u8>> {
|
||
|
|
let mut out = BTreeMap::new();
|
||
|
|
fn walk(root: &Path, prefix: &str, out: &mut BTreeMap<String, Vec<u8>>) {
|
||
|
|
for entry in fs::read_dir(root).unwrap() {
|
||
|
|
let entry = entry.unwrap();
|
||
|
|
let name = entry.file_name().into_string().unwrap();
|
||
|
|
let rel = if prefix.is_empty() {
|
||
|
|
name.clone()
|
||
|
|
} else {
|
||
|
|
format!("{prefix}/{name}")
|
||
|
|
};
|
||
|
|
let ft = entry.file_type().unwrap();
|
||
|
|
if ft.is_dir() {
|
||
|
|
walk(&entry.path(), &rel, out);
|
||
|
|
} else if ft.is_file() {
|
||
|
|
out.insert(rel, fs::read(entry.path()).unwrap());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
walk(root, "", &mut out);
|
||
|
|
out
|
||
|
|
}
|