//! End-to-end tests: every case runs the real `asolaria` binary. use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::sync::atomic::{AtomicU32, Ordering}; const BIN: &str = env!("CARGO_BIN_EXE_asolaria"); const EXAMPLE: &str = include_str!("../examples/envelope.hbp"); /// y = 2 with every other byte zero has no curve point (it fails Edwards decompression). const NON_POINT_KEY: &str = "0200000000000000000000000000000000000000000000000000000000000000"; /// A unique, self-removing directory under `std::env::temp_dir()`. struct TempDir(PathBuf); impl TempDir { fn new(label: &str) -> TempDir { static COUNTER: AtomicU32 = AtomicU32::new(0); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); let path = std::env::temp_dir().join(format!( "asolaria-cli-{label}-{}-{nanos}-{}", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir(&path).expect("create temp dir"); TempDir(path) } fn file(&self, name: &str, contents: &[u8]) -> PathBuf { let path = self.0.join(name); std::fs::write(&path, contents).expect("write temp file"); path } fn path(&self, name: &str) -> PathBuf { self.0.join(name) } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn run(args: &[&str]) -> Output { Command::new(BIN).args(args).output().expect("run asolaria") } fn run_stdin(args: &[&str], input: &[u8]) -> Output { let mut child = Command::new(BIN) .args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("spawn asolaria"); child .stdin .take() .expect("stdin") .write_all(input) .expect("write stdin"); child.wait_with_output().expect("wait asolaria") } fn stdout(out: &Output) -> String { String::from_utf8(out.stdout.clone()).expect("utf8 stdout") } fn code(out: &Output) -> i32 { out.status.code().expect("exit code") } fn p(path: &Path) -> &str { path.to_str().expect("utf8 path") } /// Value of `key` in a single HBP row. fn field(row: &str, key: &str) -> String { row.trim_end() .split('|') .find_map(|part| part.strip_prefix(&format!("{key}="))) .unwrap_or_else(|| panic!("{key} missing from {row}")) .to_string() } struct Keys { dir: TempDir, seed: PathBuf, public: String, } fn keygen(label: &str) -> Keys { let dir = TempDir::new(label); let seed = dir.path("seed.hex"); let out = run(&["keygen", "--out", p(&seed)]); assert_eq!(code(&out), 0, "{out:?}"); let public = field(&stdout(&out), "public"); Keys { dir, seed, public } } fn sign_file(keys: &Keys, file: &Path) -> String { let out = run(&["sign", "--key", p(&keys.seed), p(file)]); assert_eq!(code(&out), 0, "{out:?}"); field(&stdout(&out), "sig") } fn flip_hex_bit(hex: &str, index: usize) -> String { let mut chars: Vec = hex.chars().collect(); let nibble = chars[index].to_digit(16).expect("hex") ^ 1; chars[index] = char::from_digit(nibble, 16).expect("nibble"); chars.into_iter().collect() } fn gate_text(text: &str, extra: &[&str]) -> Output { let dir = TempDir::new("gate"); let env = dir.file("envelope.hbp", text.as_bytes()); let mut args = vec!["gate", p(&env)]; args.extend_from_slice(extra); run(&args) } #[test] fn keygen_pubkey_sign_verify_roundtrip() { let keys = keygen("roundtrip"); assert_eq!(keys.public.len(), 64); let pubkey = run(&["pubkey", "--key", p(&keys.seed)]); assert_eq!(code(&pubkey), 0); assert_eq!(stdout(&pubkey), format!("PUBKEY|public={}|json=0\n", keys.public)); let message = keys.dir.file("msg.bin", b"exact bytes\r\n\0 with a trailing NUL"); let signed = run(&["sign", "--key", p(&keys.seed), p(&message)]); let row = stdout(&signed); assert!(row.starts_with("SIGN|sig="), "{row}"); assert_eq!(field(&row, "public"), keys.public); assert_eq!(field(&row, "bytes"), "34"); assert_eq!(field(&row, "json"), "0"); let sig = field(&row, "sig"); assert_eq!(sig.len(), 128); let verified = run(&["verify", "--pub", &keys.public, "--sig", &sig, p(&message)]); assert_eq!(code(&verified), 0); assert_eq!(stdout(&verified), "VERIFY|ok=1|json=0\n"); // stdin path signs and verifies the same bytes. let from_stdin = run_stdin( &["sign", "--key", p(&keys.seed), "-"], b"exact bytes\r\n\0 with a trailing NUL", ); assert_eq!(field(&stdout(&from_stdin), "sig"), sig, "Ed25519 is deterministic"); let verified_stdin = run_stdin( &["verify", "--pub", &keys.public, "--sig", &sig, "-"], b"exact bytes\r\n\0 with a trailing NUL", ); assert_eq!(code(&verified_stdin), 0); } #[test] fn seed_never_appears_on_stdout_or_stderr() { let keys = keygen("secret"); let seed_text = std::fs::read_to_string(&keys.seed).expect("seed"); assert!(seed_text.ends_with('\n')); let seed_hex = seed_text.trim_end(); assert_eq!(seed_hex.len(), 64); assert!(seed_hex.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())); assert_ne!(seed_hex, keys.public); let message = keys.dir.file("msg.txt", b"hello"); let other = keys.dir.path("seed2.hex"); for out in [ run(&["sign", "--key", p(&keys.seed), p(&message)]), run(&["pubkey", "--key", p(&keys.seed)]), run(&["keygen", "--out", p(&other)]), ] { let all = format!( "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ); assert!(!all.contains(seed_hex), "seed leaked: {all}"); } let other_seed = std::fs::read_to_string(&other).expect("seed2"); let keygen_again = run(&["keygen", "--out", p(&keys.dir.path("seed3.hex"))]); assert!(!stdout(&keygen_again).contains(other_seed.trim_end())); let third = std::fs::read_to_string(keys.dir.path("seed3.hex")).expect("seed3"); assert!(!stdout(&keygen_again).contains(third.trim_end())); assert_ne!(other_seed, third, "fresh randomness per keygen"); } #[test] fn keygen_refuses_to_overwrite() { let keys = keygen("overwrite"); let before = std::fs::read(&keys.seed).expect("seed"); let again = run(&["keygen", "--out", p(&keys.seed)]); assert_eq!(code(&again), 2, "{again:?}"); assert!(again.stdout.is_empty()); assert_eq!(std::fs::read(&keys.seed).expect("seed"), before); let unrelated = keys.dir.file("notes.txt", b"keep me"); assert_eq!(code(&run(&["keygen", "--out", p(&unrelated)])), 2); assert_eq!(std::fs::read(&unrelated).expect("notes"), b"keep me"); } #[cfg(unix)] #[test] fn seed_file_mode_is_0600() { use std::os::unix::fs::PermissionsExt; let keys = keygen("mode"); let mode = std::fs::metadata(&keys.seed).expect("meta").permissions().mode(); assert_eq!(mode & 0o777, 0o600, "mode {mode:o}"); } #[test] fn verify_rejects_tamper_wrong_key_flipped_bit_and_non_point() { let keys = keygen("verify"); let message = keys.dir.file("msg.txt", b"pay 10"); let sig = sign_file(&keys, &message); let tampered = keys.dir.file("tampered.txt", b"pay 99"); let out = run(&["verify", "--pub", &keys.public, "--sig", &sig, p(&tampered)]); assert_eq!(code(&out), 1); assert_eq!(stdout(&out), "VERIFY|ok=0|reason=signature_invalid|json=0\n"); let other = keygen("verify-other"); let out = run(&["verify", "--pub", &other.public, "--sig", &sig, p(&message)]); assert_eq!(code(&out), 1); assert_eq!(field(&stdout(&out), "reason"), "signature_invalid"); for index in [0, 70, 127] { let flipped = flip_hex_bit(&sig, index); let out = run(&["verify", "--pub", &keys.public, "--sig", &flipped, p(&message)]); assert_eq!(code(&out), 1, "nibble {index}"); assert_eq!(field(&stdout(&out), "ok"), "0"); } let out = run(&["verify", "--pub", NON_POINT_KEY, "--sig", &sig, p(&message)]); assert_eq!(code(&out), 1); assert_eq!(stdout(&out), "VERIFY|ok=0|reason=key_malformed|json=0\n"); } #[test] fn malformed_hex_lengths_and_arguments_exit_2() { let keys = keygen("usage"); let message = keys.dir.file("msg.txt", b"m"); let sig = sign_file(&keys, &message); let cases: Vec> = vec![ vec!["verify".into(), "--pub".into(), keys.public[..62].into(), "--sig".into(), sig.clone(), p(&message).into()], vec!["verify".into(), "--pub".into(), format!("{}0", keys.public), "--sig".into(), sig.clone(), p(&message).into()], vec!["verify".into(), "--pub".into(), format!("zz{}", &keys.public[2..]), "--sig".into(), sig.clone(), p(&message).into()], vec!["verify".into(), "--pub".into(), keys.public.clone(), "--sig".into(), sig[..126].into(), p(&message).into()], vec!["verify".into(), "--pub".into(), keys.public.clone(), "--sig".into(), format!("{}g", &sig[..127]), p(&message).into()], vec!["verify".into(), "--pub".into(), keys.public.clone(), p(&message).into()], vec!["verify".into(), "--pub".into(), keys.public.clone(), "--pub".into(), keys.public.clone(), "--sig".into(), sig.clone(), p(&message).into()], vec!["sign".into(), "--key".into(), p(&keys.seed).into()], vec!["sign".into(), "--bogus".into(), "x".into(), p(&message).into()], vec!["keygen".into()], vec!["frobnicate".into()], ]; for args in cases { let refs: Vec<&str> = args.iter().map(String::as_str).collect(); let out = run(&refs); assert_eq!(code(&out), 2, "{args:?}: {out:?}"); assert!(out.stdout.is_empty(), "{args:?}"); let err = String::from_utf8_lossy(&out.stderr); assert!(err.starts_with("ERROR|kind=usage|"), "{err}"); assert!(err.trim_end().ends_with("|json=0"), "{err}"); } let seed_text = std::fs::read_to_string(&keys.seed).expect("seed"); let hex = seed_text.trim_end(); let bad_seeds: Vec> = vec![ hex[..62].as_bytes().to_vec(), format!("{hex}00\n").into_bytes(), format!("{hex}\n\n").into_bytes(), format!(" {hex}").into_bytes(), format!("{}zz\n", &hex[..62]).into_bytes(), Vec::new(), ]; for (i, bad) in bad_seeds.iter().enumerate() { let path = keys.dir.file(&format!("bad{i}.hex"), bad); let out = run(&["pubkey", "--key", p(&path)]); assert_eq!(code(&out), 2, "bad seed {i}"); let err = String::from_utf8_lossy(&out.stderr).to_string(); assert!(!err.contains(&hex[..16]), "error echoed seed material"); } } #[test] fn missing_files_are_io_errors() { let dir = TempDir::new("io"); let out = run(&["pubkey", "--key", p(&dir.path("absent.hex"))]); assert_eq!(code(&out), 1); assert!(String::from_utf8_lossy(&out.stderr).starts_with("ERROR|kind=io|")); let out = run(&["gate", p(&dir.path("absent.hbp"))]); assert_eq!(code(&out), 1); } #[test] fn gate_clean_example_proceeds_unchecked() { let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/envelope.hbp"); let out = run(&["gate", p(&example)]); assert_eq!(code(&out), 0, "{out:?}"); let row = stdout(&out); assert!(row.starts_with("GATE|verdict=PROCEED|hookwall=PROCEED|spawn_gate=PROCEED|tier_gate=PROCEED|fischer=PROCEED|input_guard=PROCEED|signature=unchecked|reason="), "{row}"); assert!(row.ends_with("|json=0\n"), "{row}"); assert_eq!(row.matches('\n').count(), 1); assert!(!row.contains('{')); let piped = run_stdin(&["gate", "-"], EXAMPLE.as_bytes()); assert_eq!(stdout(&piped), row); } #[test] fn gate_with_valid_signature_is_verified_and_same_verdict() { let keys = keygen("gate-valid"); let env = keys.dir.file("envelope.hbp", EXAMPLE.as_bytes()); let sig = sign_file(&keys, &env); let unchecked = run(&["gate", p(&env)]); let out = run(&["gate", p(&env), "--pub", &keys.public, "--sig", &sig]); assert_eq!(code(&out), 0, "{out:?}"); let row = stdout(&out); assert_eq!(field(&row, "signature"), "verified"); assert_eq!(field(&row, "verdict"), "PROCEED"); assert_eq!( row.replace("signature=verified", "signature=unchecked"), stdout(&unchecked) ); } #[test] fn gate_with_forged_signature_blocks_even_a_clean_envelope() { let keys = keygen("gate-forged"); let env = keys.dir.file("envelope.hbp", EXAMPLE.as_bytes()); let sig = sign_file(&keys, &env); let other = keygen("gate-forger"); let forged_cases = [ (keys.public.clone(), flip_hex_bit(&sig, 5)), (other.public.clone(), sig.clone()), (NON_POINT_KEY.to_string(), sig.clone()), ]; for (public, signature) in forged_cases { let out = run(&["gate", p(&env), "--pub", &public, "--sig", &signature]); assert_eq!(code(&out), 4, "{out:?}"); let row = stdout(&out); assert_eq!(field(&row, "verdict"), "BLOCK"); assert_eq!(field(&row, "signature"), "invalid"); for gate in ["hookwall", "spawn_gate", "tier_gate", "fischer", "input_guard"] { assert_eq!(field(&row, gate), "PROCEED", "{gate}: {row}"); } assert!(field(&row, "reason").starts_with("BLOCK by signature"), "{row}"); } // A valid signature over different bytes (one appended comment) does not carry over. let edited = keys.dir.file("edited.hbp", format!("{EXAMPLE}# appended\n").as_bytes()); let out = run(&["gate", p(&edited), "--pub", &keys.public, "--sig", &sig]); assert_eq!(code(&out), 4); // --pub without --sig is a usage error, not a silent unchecked run. let out = run(&["gate", p(&env), "--pub", &keys.public]); assert_eq!(code(&out), 2); } #[test] fn gate_blocks_what_the_composition_blocks() { let cases = [ ("verb=tick", "verb=self_authorize", "fischer"), ("pid=BH.VERDICT.EXAMPLE000000001\n", "", "fischer"), ("pid=BH.VERDICT.EXAMPLE000000001", "pid=UNKNOWN", "input_guard"), ("target_tier=public", "target_tier=secret", "tier_gate"), ("hook_tier=micro", "hook_tier=firmware", "hookwall"), ("slot=0", "slot=64", "hookwall"), ("event_tags=safe-tag", "event_tags=safe-tag,DRIFT", "input_guard"), ("g4_state=UNKNOWN", "g4_state=converged", "input_guard"), ]; for (from, to, deciding) in cases { assert!(EXAMPLE.contains(from), "{from}"); let out = gate_text(&EXAMPLE.replacen(from, to, 1), &[]); assert_eq!(code(&out), 4, "{to}: {out:?}"); let row = stdout(&out); assert_eq!(field(&row, "verdict"), "BLOCK", "{to}"); assert_eq!(field(&row, deciding), "BLOCK", "{to}: {row}"); } } #[test] fn gate_hold_path_exits_3() { let unsealed_write = EXAMPLE .replacen("hbp_path=ledger/example.hbp\n", "", 1) .replacen("verb=tick", "verb=write", 1); for text in [ EXAMPLE.replacen("hook_tier=micro", "hook_tier=cosign", 1), EXAMPLE.replacen("forward_score_q=800", "forward_score_q=100", 1), unsealed_write, ] { let out = gate_text(&text, &[]); assert_eq!(code(&out), 3, "{out:?}"); assert_eq!(field(&stdout(&out), "verdict"), "HOLD"); } } #[test] fn gate_rejects_unknown_duplicate_missing_and_malformed_keys() { let cases = [ format!("{EXAMPLE}colour=blue\n"), format!("{EXAMPLE}slot=0\n"), EXAMPLE.replacen("hook_tier=micro\n", "", 1), EXAMPLE.replacen("caller_pid=43981", "caller_pid=-1", 1), EXAMPLE.replacen("reverse_risk_q=100", "reverse_risk_q=1001", 1), EXAMPLE.replacen("l0_real=1", "l0_real=yes", 1), EXAMPLE.replacen("caller_tier=public", "caller_tier=Public", 1), format!("{EXAMPLE}just some text\n"), ]; for text in cases { let out = gate_text(&text, &[]); assert_eq!(code(&out), 2, "{out:?}"); assert!(out.stdout.is_empty()); assert!(String::from_utf8_lossy(&out.stderr).starts_with("ERROR|kind=usage|reason=envelope")); } let out = run_stdin(&["gate", "-"], &[0xff, 0xfe, b'\n']); assert_eq!(code(&out), 2); } #[test] fn gate_row_has_fixed_fields_for_every_verdict() { for (text, verdict) in [ (EXAMPLE.to_string(), "PROCEED"), (EXAMPLE.replacen("hook_tier=micro", "hook_tier=cosign", 1), "HOLD"), (EXAMPLE.replacen("verb=tick", "verb=bad|verb", 1), "BLOCK"), ] { let row = stdout(&gate_text(&text, &[])); assert_eq!(row.matches('\n').count(), 1, "{row}"); let keys: Vec<&str> = row .trim_end() .split('|') .map(|part| part.split('=').next().unwrap_or("")) .collect(); assert_eq!( keys, ["GATE", "verdict", "hookwall", "spawn_gate", "tier_gate", "fischer", "input_guard", "signature", "reason", "json"], "{row}" ); assert_eq!(field(&row, "verdict"), verdict); } } #[test] fn help_and_version() { for args in [&[][..], &["help"][..], &["--help"][..]] { let out = run(args); assert_eq!(code(&out), 0); let text = stdout(&out); for word in ["keygen", "pubkey", "sign", "verify", "gate", "observe", "EXIT CODES", "SCOPE"] { assert!(text.contains(word), "{word}"); } } let out = run(&["--version"]); assert_eq!(stdout(&out), format!("VERSION|name=asolaria|version={}|json=0\n", env!("CARGO_PKG_VERSION"))); } /// Runs with stdout's read end closed before the binary can write (it first waits on stdin). fn run_with_broken_stdout(args: &[&str], input: &[u8]) -> Output { let mut child = Command::new(BIN) .args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("spawn asolaria"); drop(child.stdout.take()); let mut stdin = child.stdin.take().expect("stdin"); stdin.write_all(input).expect("write stdin"); drop(stdin); child.wait_with_output().expect("wait asolaria") } #[test] fn lost_output_never_exits_0_or_panics_and_block_stays_4() { let block = EXAMPLE.replacen("verb=tick", "verb=self_authorize", 1); for (text, expected) in [(block.as_str(), 4), (EXAMPLE, 1)] { let out = run_with_broken_stdout(&["gate", "-"], text.as_bytes()); assert_eq!(code(&out), expected, "{out:?}"); let err = String::from_utf8_lossy(&out.stderr); assert!(err.starts_with("ERROR|kind=io|reason=writing output"), "{err}"); assert!(!err.contains("panicked"), "{err}"); } } #[cfg(target_os = "linux")] #[test] fn full_disk_stdout_keeps_verdict_code_and_removes_unannounced_seed() { let full = || std::fs::OpenOptions::new().write(true).open("/dev/full").expect("/dev/full"); let dir = TempDir::new("devfull"); let block = dir.file("block.hbp", EXAMPLE.replacen("pid=BH.VERDICT.EXAMPLE000000001\n", "", 1).as_bytes()); let out = Command::new(BIN).args(["gate", p(&block)]).stdout(full()).output().expect("run"); assert_eq!(code(&out), 4, "{out:?}"); let seed = dir.path("lost.seed"); let out = Command::new(BIN).args(["keygen", "--out", p(&seed)]).stdout(full()).output().expect("run"); assert_eq!(code(&out), 1, "{out:?}"); assert!(String::from_utf8_lossy(&out.stderr).contains("seed file removed")); assert!(!seed.exists(), "a seed whose public key was never shown must not be left behind"); let out = Command::new(BIN).arg("--version").stdout(full()).output().expect("run"); assert_eq!(code(&out), 1); let out = Command::new(BIN).args(["verify", "--pub", "00", "--sig", "00", "x"]).stderr(full()).output().expect("run"); assert_eq!(code(&out), 2, "a lost ERROR row keeps the usage exit code"); } #[test] fn control_and_bidi_characters_in_envelopes_exit_2_even_when_signed() { let keys = keygen("spoof"); for (from, to) in [ ("target=fabric", "target=prod\u{1b}[2K\rtarget=fabric"), ("payload=heartbeat", "payload=rm -rf /\rpayload=heartbeat"), ("actor=helm", "actor=he\0lm"), ("actor=helm", "actor=\u{202E}mleh"), ] { let env = keys.dir.file("spoof.hbp", EXAMPLE.replacen(from, to, 1).as_bytes()); let sig = sign_file(&keys, &env); for extra in [&[][..], &["--pub", &keys.public, "--sig", &sig][..]] { let mut args = vec!["gate", p(&env)]; args.extend_from_slice(extra); let out = run(&args); assert_eq!(code(&out), 2, "{to:?}: {out:?}"); assert!(out.stdout.is_empty()); assert!(String::from_utf8_lossy(&out.stderr).contains("control or bidi character")); } } let out = run_stdin(&["gate", "-"], format!("\u{FEFF}{EXAMPLE}").as_bytes()); assert_eq!(code(&out), 2); assert!(String::from_utf8_lossy(&out.stderr).contains("byte order mark")); } #[test] fn forged_signature_over_unparseable_envelope_is_block_4() { let keys = keygen("forged-garbage"); let env = keys.dir.file("envelope.hbp", EXAMPLE.as_bytes()); let sig = sign_file(&keys, &env); for garbage in [&b"garbage\n"[..], b"slot=x\n", &[0xff, 0xfe, b'\n']] { let bad = keys.dir.file("garbage.hbp", garbage); let out = run(&["gate", p(&bad), "--pub", &keys.public, "--sig", &sig]); assert_eq!(code(&out), 4, "{out:?}"); let row = stdout(&out); assert_eq!(field(&row, "verdict"), "BLOCK"); assert_eq!(field(&row, "signature"), "invalid"); for gate in ["hookwall", "spawn_gate", "tier_gate", "fischer", "input_guard"] { assert_eq!(field(&row, gate), "UNEVALUATED", "{row}"); } assert!(field(&row, "reason").contains("envelope unparseable"), "{row}"); // Without a signature check the same envelope is a plain parse error. assert_eq!(code(&run(&["gate", p(&bad)])), 2); } // A correctly signed but unparseable envelope stays a parse error, not a verdict. let bad = keys.dir.file("signed-garbage.hbp", b"garbage\n"); let good_sig = sign_file(&keys, &bad); assert_eq!(code(&run(&["gate", p(&bad), "--pub", &keys.public, "--sig", &good_sig])), 2); } #[test] fn oversized_envelope_is_a_usage_error() { let mut text = EXAMPLE.to_string(); text.push_str(&"#".repeat(1024 * 1024)); let out = run_stdin(&["gate", "-"], text.as_bytes()); assert_eq!(code(&out), 2, "{out:?}"); assert!(String::from_utf8_lossy(&out.stderr).contains("larger than 1048576 bytes")); } #[test] fn subcommand_help_and_help_topics() { for command in ["keygen", "pubkey", "sign", "verify", "gate", "observe"] { for args in [vec![command, "--help"], vec![command, "-h"], vec!["help", command]] { let out = run(&args); assert_eq!(code(&out), 0, "{args:?}: {out:?}"); let text = stdout(&out); assert!(text.starts_with("USAGE\n"), "{text}"); assert!(text.contains(&format!("asolaria {command}")), "{text}"); } } assert_eq!(code(&run(&["help", "frobnicate"])), 2); // `-h` as an option value is a value, not a help request. let dir = TempDir::new("help-value"); let out = run(&["pubkey", "--key", p(&dir.path("-h"))]); assert_eq!(code(&out), 1); } #[test] fn out_of_range_u8_says_maximum() { let out = gate_text(&EXAMPLE.replacen("slot=0", "slot=256", 1), &[]); assert_eq!(code(&out), 2); assert!(String::from_utf8_lossy(&out.stderr).contains("slot: 256 is above the maximum 255")); } const OBSERVE_EXAMPLE: &str = include_str!("../examples/observe-envelope.hbp"); const OBSERVE_PID: &str = "BH.VERDICT.EXAMPLE000000001"; /// The SYNTHETIC events fixture of model/l0-edge. fn fixture_events() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../model/l0-edge/tests/fixtures/events.hbp") } fn observe_text(text: &str, extra: &[&str]) -> Output { let dir = TempDir::new("observe"); let env = dir.file("observe.hbp", text.as_bytes()); let events = fixture_events(); let mut args = vec!["observe", "--events", p(&events), p(&env)]; args.extend_from_slice(extra); run(&args) } /// The observe example with its request edge set to `actor -> target`. fn with_edge(actor: &str, target: &str) -> String { assert!(OBSERVE_EXAMPLE.contains("\nactor=agent:benign\n")); assert!(OBSERVE_EXAMPLE.contains("\ntarget=svc:ledger\n")); OBSERVE_EXAMPLE .replacen("\nactor=agent:benign\n", &format!("\nactor={actor}\n"), 1) .replacen("\ntarget=svc:ledger\n", &format!("\ntarget={target}\n"), 1) } fn rows(out: &Output) -> Vec { stdout(out).lines().map(str::to_string).collect() } #[test] fn observe_example_prints_the_row_in_the_readme() { let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/observe-envelope.hbp"); let events = fixture_events(); let out = run(&["observe", "--events", p(&events), p(&example)]); assert_eq!(code(&out), 3, "{out:?}"); let printed = rows(&out); assert_eq!(printed.len(), 1, "{printed:?}"); let readme = include_str!("../README.md"); assert!( readme.contains(printed[0].as_str()), "README lacks {}", printed[0] ); } #[test] fn observe_scores_each_fixture_request_pair_from_the_events() { let cases = [ ( "agent:benign", "svc:ledger", "GATE|verdict=HOLD|score=0.2139|l0_real=true|mark=REVERSE_GAIN_MARK_MISTAKE|reverse_risk=0.7861|", ("HOLD", 3), ), ( "agent:rogue", "svc:vault", "GATE|verdict=PROCEED|score=0.7593|l0_real=true|mark=FORWARD_GNN_MARK_GENIUS|reverse_risk=0.2407|", ("PROCEED", 0), ), ( "agent:ghost", "svc:ledger", "GATE|verdict=HOLD|score=0.4705|l0_real=false|mark=REVERSE_GAIN_MARK_MISTAKE|reverse_risk=0.5295|", ("HOLD", 3), ), ]; for (actor, target, start, (spawn, exit)) in cases { let out = observe_text(&with_edge(actor, target), &[]); assert_eq!(code(&out), exit, "{out:?}"); let printed = rows(&out); assert_eq!(printed.len(), 1, "{printed:?}"); let gate = &printed[0]; assert!(gate.starts_with(start), "{gate}"); assert_eq!(field(gate, "spawn_gate"), spawn, "{gate}"); for other in ["hookwall", "tier_gate", "fischer", "input_guard"] { assert_eq!(field(gate, other), "PROCEED", "{gate}"); } assert_eq!(field(gate, "signature"), "unchecked"); let has_gnn_flag = field(gate, "reason").contains("gnn_observable"); assert_eq!(has_gnn_flag, field(gate, "l0_real") == "true", "{gate}"); } } #[test] fn observe_rows_are_what_the_library_computes() { use asolaria_hookwall::hookwall::HookTier; use asolaria_hookwall::syscall::AccessTier; use asolaria_observed::{observe_and_compose, PolicyInputs}; use asolaria_server_fischer_eval::FischerEnvelope; let text = std::fs::read_to_string(fixture_events()).expect("events fixture"); let events = l0_edge::event::parse_events(&text).expect("events parse"); for (actor, target) in [ ("agent:benign", "svc:ledger"), ("agent:rogue", "svc:vault"), ("agent:ghost", "svc:ledger"), ("agent:benign", "agent:ghost"), ] { let policy = PolicyInputs { slot: 0, syscall_no: 1, caller_pid: 43981, hook_tier: HookTier::Micro, caller_tier: AccessTier::Public, target_tier: AccessTier::Public, event_tags: vec!["safe-tag".to_string()], fischer_envelope: FischerEnvelope { pid: Some(OBSERVE_PID.to_string()), actor: actor.to_string(), verb: "tick".to_string(), target: target.to_string(), payload: "heartbeat".to_string(), payload_json_true: false, hbp_path: Some("ledger/example.hbp".to_string()), sidecar_plan: None, ledger_path: None, tuple: None, cube_47d: None, cosign: None, halt_path: Some("/halt".to_string()), authority_jump: false, recursive_consent: false, operator_witness_required: false, operator_witness: false, }, fischer_strict: false, }; let (observed, composed) = observe_and_compose(&events, policy); let out = observe_text(&with_edge(actor, target), &[]); let printed = rows(&out); assert_eq!(printed.len(), 1, "{printed:?}"); let gate = &printed[0]; let js_number = |e4: u32| { let text = format!("{}.{:04}", e4 / 10_000, e4 % 10_000); text.trim_end_matches('0').trim_end_matches('.').to_string() }; assert_eq!(field(gate, "score"), js_number(observed.composite_e4)); assert_eq!( field(gate, "score"), observed.envelope.fischer_score.composite ); assert_eq!(field(gate, "l0_real"), observed.l0.is_some().to_string()); let reverse = &observed.reverse_gain; let mark = if reverse.promoted { "FORWARD_GNN_MARK_GENIUS" } else { "REVERSE_GAIN_MARK_MISTAKE" }; assert_eq!(field(gate, "mark"), mark); assert_eq!( field(gate, "reverse_risk"), js_number(reverse.reverse_risk_e4) ); let signals = &observed.signals; assert_eq!( (signals.l4, signals.g1, signals.g2, signals.g3), (None, None, None, None) ); assert_eq!(field(gate, "verdict"), composed.verdict.as_str()); for (name, severity) in composed.per_gate { assert_eq!(field(gate, name), severity.as_str(), "{name}"); } assert_eq!(field(gate, "reason"), composed.reason); let exit = match composed.verdict.as_str() { "PROCEED" => 0, "HOLD" => 3, _ => 4, }; assert_eq!(code(&out), exit); } } #[test] fn observe_rejects_every_score_key_with_exit_2() { let keys = keygen("observe-score-key"); for line in [ "forward_score_q=800", "reverse_risk_q=100", "composite=0.85", "l0_real=1", "shannon_q=550", "shannon_above=0", "g4_state=UNKNOWN", ] { let key = line.split('=').next().unwrap_or_default(); let env = keys.dir.file( "scored.hbp", format!("{OBSERVE_EXAMPLE}{line}\n").as_bytes(), ); let sig = sign_file(&keys, &env); let events = fixture_events(); for extra in [&[][..], &["--pub", &keys.public, "--sig", &sig][..]] { let mut args = vec!["observe", "--events", p(&events), p(&env)]; args.extend_from_slice(extra); let out = run(&args); assert_eq!(code(&out), 2, "{line}: {out:?}"); assert!(out.stdout.is_empty(), "{line}"); let err = String::from_utf8_lossy(&out.stderr); assert!( err.starts_with("ERROR|kind=usage|reason=envelope: line "), "{err}" ); assert!( err.contains(&format!("score key {key} is not accepted")), "{err}" ); } // A failed signature over a refused envelope is BLOCK, as `gate` does for any unparseable one. let forged = flip_hex_bit(&sig, 9); let mut args = vec!["observe", "--events", p(&events), p(&env)]; args.extend_from_slice(&["--pub", &keys.public, "--sig", &forged]); let out = run(&args); assert_eq!(code(&out), 4, "{line}: {out:?}"); let row = stdout(&out); assert_eq!(field(&row, "verdict"), "BLOCK"); assert_eq!(field(&row, "spawn_gate"), "UNEVALUATED"); assert!( field(&row, "reason").contains("envelope unparseable"), "{row}" ); } // The gate example carries every score key, so observe refuses it. let out = observe_text(EXAMPLE, &[]); assert_eq!(code(&out), 2, "{out:?}"); } #[test] fn observe_signature_covers_the_envelope_and_only_tightens() { let keys = keygen("observe-sig"); let env = keys.dir.file( "observe.hbp", with_edge("agent:rogue", "svc:vault").as_bytes(), ); let sig = sign_file(&keys, &env); let events = fixture_events(); let base = ["observe", "--events", p(&events), p(&env)]; let unchecked = run(&base); assert_eq!(code(&unchecked), 0, "{unchecked:?}"); let mut args = base.to_vec(); args.extend_from_slice(&["--pub", &keys.public, "--sig", &sig]); let verified = run(&args); assert_eq!(code(&verified), 0, "{verified:?}"); assert_eq!( stdout(&verified).replace("signature=verified", "signature=unchecked"), stdout(&unchecked) ); let forged = flip_hex_bit(&sig, 3); let mut args = base.to_vec(); args.extend_from_slice(&["--pub", &keys.public, "--sig", &forged]); let blocked = run(&args); assert_eq!(code(&blocked), 4, "{blocked:?}"); let (forged, clean) = (stdout(&blocked), stdout(&unchecked)); for key in ["score", "l0_real", "mark", "reverse_risk"] { assert_eq!(field(&forged, key), field(&clean, key), "{key}"); } assert_eq!(field(&forged, "verdict"), "BLOCK"); assert_eq!(field(&forged, "signature"), "invalid"); assert_eq!(field(&forged, "spawn_gate"), "PROCEED"); // The events file is not signed: a different events file keeps signature=verified. let other_events = keys.dir.file( "events.hbp", b"EVENT|actor=agent:rogue|target=svc:vault|risk=0\n", ); let out = run(&[ "observe", "--events", p(&other_events), p(&env), "--pub", &keys.public, "--sig", &sig, ]); assert_eq!(field(&stdout(&out), "signature"), "verified"); assert_ne!(field(&stdout(&out), "score"), field(&clean, "score")); } #[test] fn observe_reads_events_or_envelope_from_stdin() { let events = fixture_events(); let events_text = std::fs::read(&events).expect("events fixture"); let file_run = observe_text(OBSERVE_EXAMPLE, &[]); let dir = TempDir::new("observe-stdin"); let env = dir.file("observe.hbp", OBSERVE_EXAMPLE.as_bytes()); let events_stdin = run_stdin(&["observe", "--events", "-", p(&env)], &events_text); assert_eq!(stdout(&events_stdin), stdout(&file_run)); let envelope_stdin = run_stdin( &["observe", "--events", p(&events), "-"], OBSERVE_EXAMPLE.as_bytes(), ); assert_eq!(stdout(&envelope_stdin), stdout(&file_run)); let both = run(&["observe", "--events", "-", "-"]); assert_eq!(code(&both), 2, "{both:?}"); assert!(String::from_utf8_lossy(&both.stderr).contains("cannot both be read from stdin")); } #[test] fn observe_usage_and_io_errors() { let dir = TempDir::new("observe-errors"); let env = dir.file("observe.hbp", OBSERVE_EXAMPLE.as_bytes()); let events = fixture_events(); let bad_events = dir.file("bad-events.hbp", b"EVENT|actor=a|risk=x\n"); let no_payload = dir.file( "no-payload.hbp", OBSERVE_EXAMPLE .replace("payload=heartbeat\n", "") .as_bytes(), ); let not_utf8 = dir.file( "latin1.hbp", &[b'E', b'V', b'E', b'N', b'T', b'|', 0xff, b'\n'], ); let usage_cases: Vec> = vec![ vec!["observe", p(&env)], vec!["observe", "--events", p(&events)], vec!["observe", "--events", p(&events), p(&env), p(&env)], vec![ "observe", "--events", p(&events), "--forward_score_q", "800", p(&env), ], vec![ "observe", "--events", p(&events), "--pub", NON_POINT_KEY, p(&env), ], vec!["observe", "--events", p(&bad_events), p(&env)], vec!["observe", "--events", p(¬_utf8), p(&env)], vec!["observe", "--events", p(&events), p(&no_payload)], ]; for args in usage_cases { let out = run(&args); assert_eq!(code(&out), 2, "{args:?}: {out:?}"); assert!(out.stdout.is_empty(), "{args:?}"); assert!(String::from_utf8_lossy(&out.stderr).starts_with("ERROR|kind=usage|")); } let out = run(&["observe", "--events", p(&bad_events), p(&env)]); assert!(String::from_utf8_lossy(&out.stderr) .contains("reason=events: line 1: risk must be decimal digits")); let out = run(&["observe", "--events", p(&events), p(&no_payload)]); assert_eq!( String::from_utf8_lossy(&out.stderr), "ERROR|kind=usage|reason=envelope: missing required key payload|json=0\n" ); for args in [ vec![ "observe", "--events", p(&dir.path("absent-events.hbp")), p(&env), ], vec![ "observe", "--events", p(&events), p(&dir.path("absent.hbp")), ], ] { let out = run(&args); assert_eq!(code(&out), 1, "{args:?}: {out:?}"); assert!(String::from_utf8_lossy(&out.stderr).starts_with("ERROR|kind=io|")); } }