f451406058
Mandatory OpenFUT architecture audit. Two real defects found and fixed, plus
the config surface tightened so neither class can recur.
DEFECT 1 -- hidden localhost fallback. The Rust host defaulted POW hosts to
127.0.0.1 while every other URL followed OPENFUT_ADVERTISE, so a remote
deployment would emit loopback POW URLs and fail far from the cause. It also
diverged from the deployed Python entrypoint, which derives them
(POW_HOST="${POW_HOST:-$ADV:8094}"). POW endpoints now derive from the
advertised address; explicit overrides still win.
DEFECT 2 -- Default gave loopback silently. `Endpoints::default()` and
`AdapterConfig::default()` supplied 127.0.0.1, so anything constructing a
config by omission got loopback with no signal. Both `Default` impls are
REMOVED. Loopback is now `Endpoints::loopback()` / `AdapterConfig::loopback()`:
an explicit, greppable decision. Production uses `advertising(host)`.
CONFIGURABILITY. `blaze_port` and `utas_port` are now config, not literals.
The advertised Blaze port is our choice -- the client goes wherever
<serverinstanceinfo> sends it -- and 8099 is the client's own built-in default
but still deployment config. A bad port value is an error, not a silent
fallback to the previous one.
TEST-NET EVERYWHERE. Committed fixtures and tests used the lab's real LAN
address; a test that passes because its constant matches the current lab
proves nothing about relocatability. Redirector fixtures regenerated on
RFC 5737 TEST-NET-1/2/3 plus loopback. Harness scripts no longer default the
client IP to the lab address -- client-state.sh now requires it.
SEVEN REQUIRED TESTS in tests/deployment_config.rs plus host-side coverage:
remote config never silently becomes localhost; missing advertise fails
clearly; bind may differ from advertise; changing the Blaze port changes the
redirect; changing the host updates all 200+ generated URLs with no
stragglers; no helper bypasses central config; mutations are detectable.
MUTATION TESTED, and it found a hole in the audit tests themselves. Hardcoding
utas_base, reverting the POW derivation and re-hardcoding the Blaze port were
all caught. Making the redirector read `bind` instead of `advertise` was NOT:
`advertising()` sets bind == advertise, so the two sources were
indistinguishable. That is the single most likely bypass -- the oracle really
does read bind for nucleusConnect -- so the test now forces bind != advertise
and asserts the bind address never reaches the wire. Re-mutated: caught.
Wire behaviour unchanged: oracle fixtures still current, 153 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
751 lines
25 KiB
Rust
751 lines
25 KiB
Rust
//! Transport parity: the recorded conversations over a real TCP socket.
|
|
//!
|
|
//! The adapter's own suite calls `dispatch()` directly. That proves what the
|
|
//! adapter decides, and nothing about what a socket does. This suite runs the
|
|
//! actual host process-internally, connects over real TCP, and replays the same
|
|
//! recorded conversations — which is what exercises the things fixtures cannot:
|
|
//!
|
|
//! * a stream that delivers bytes, not frames
|
|
//! * requests split across reads and coalesced into one
|
|
//! * many frames per connection, and session state surviving between them
|
|
//! * multiple frames written back in order for a single request
|
|
//! * connection lifecycle and close reasons
|
|
//!
|
|
//! Byte-exactness is only achievable because `Hooks` injects the session key
|
|
//! and clock; with the real ones, no live run could reproduce a recording, and
|
|
//! the guarantee would drop to structural.
|
|
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpStream;
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
|
|
use openfut_adapter_fifa17::blaze::{AdapterConfig, Endpoints, Identity};
|
|
use openfut_blaze_host::{bind, capture, config::HostConfig, sanitize, Hooks};
|
|
use openfut_protocol_blaze::fire2::{Frame, Header, HEADER_LEN};
|
|
use serde_json::Value as J;
|
|
|
|
fn records() -> Vec<J> {
|
|
let path = format!(
|
|
"{}/../openfut-adapter-fifa17/fixtures/blaze_transactions.jsonl",
|
|
env!("CARGO_MANIFEST_DIR")
|
|
);
|
|
std::fs::read_to_string(&path)
|
|
.unwrap_or_else(|e| panic!("cannot read {path}: {e}"))
|
|
.lines()
|
|
.filter(|l| !l.trim().is_empty())
|
|
.map(|l| serde_json::from_str(l).expect("valid JSON"))
|
|
.collect()
|
|
}
|
|
|
|
fn unhex(s: &str) -> Vec<u8> {
|
|
(0..s.len())
|
|
.step_by(2)
|
|
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
|
|
.collect()
|
|
}
|
|
|
|
fn hex(b: &[u8]) -> String {
|
|
b.iter().map(|x| format!("{x:02x}")).collect()
|
|
}
|
|
|
|
fn st(j: &J, k: &str) -> String {
|
|
j[k].as_str().expect("string").to_string()
|
|
}
|
|
|
|
fn num(j: &J, k: &str) -> i64 {
|
|
j[k].as_i64().expect("number")
|
|
}
|
|
|
|
struct Harness {
|
|
addr: String,
|
|
records: Vec<J>,
|
|
}
|
|
|
|
/// Start the real host on an ephemeral port with the recording's config.
|
|
///
|
|
/// Session keys are handed out in the order the recording declares them, so the
|
|
/// Nth connection this test opens gets the Nth recorded key. That is why the
|
|
/// connection order below must match the fixture's session order.
|
|
fn start() -> Harness {
|
|
start_with_capture(None)
|
|
}
|
|
|
|
fn start_with_capture(capture_path: Option<String>) -> Harness {
|
|
let records = records();
|
|
let cfg_rec = records
|
|
.iter()
|
|
.find(|r| r["kind"] == "config")
|
|
.expect("config record")
|
|
.clone();
|
|
let id = &cfg_rec["identity"];
|
|
|
|
let adapter_cfg = AdapterConfig {
|
|
identity: Identity {
|
|
persona_id: num(id, "persona_id"),
|
|
persona_name: st(id, "persona_name"),
|
|
user_id: num(id, "user_id"),
|
|
ext_id: num(id, "ext_id"),
|
|
email: st(id, "email"),
|
|
namespace: st(id, "namespace"),
|
|
client_platform: num(id, "client_platform"),
|
|
persona_status: num(id, "persona_status"),
|
|
user_session_type: num(id, "user_session_type"),
|
|
account_locale: num(id, "account_locale_int"),
|
|
locale: st(id, "locale"),
|
|
content_id: st(id, "content_id"),
|
|
entitlement_tag: st(id, "entitlement_tag"),
|
|
entitlement_group: st(id, "entitlement_group"),
|
|
title_id: st(id, "title_id"),
|
|
client_id: st(id, "client_id"),
|
|
platform: st(id, "platform"),
|
|
},
|
|
endpoints: Endpoints {
|
|
advertise: st(&cfg_rec, "advertise"),
|
|
bind: st(&cfg_rec, "bind"),
|
|
pow_content_host: st(&cfg_rec, "pow_content_host"),
|
|
pow_host: st(&cfg_rec, "pow_host"),
|
|
..Endpoints::loopback()
|
|
},
|
|
server_version: st(id, "server_version"),
|
|
};
|
|
|
|
let host_cfg = HostConfig {
|
|
// Port 0: the OS picks a free one, so this can never collide with the
|
|
// running Python backend or with a parallel test.
|
|
listen_addr: "127.0.0.1".into(),
|
|
listen_port: 0,
|
|
idle_timeout_secs: 10,
|
|
max_payload_bytes: 4 * 1024 * 1024,
|
|
trace_path: None,
|
|
capture_path,
|
|
adapter: adapter_cfg,
|
|
};
|
|
|
|
let keys: Vec<String> = records
|
|
.iter()
|
|
.filter(|r| r["kind"] == "session")
|
|
.map(|r| st(r, "session_key"))
|
|
.collect();
|
|
let queue = Mutex::new(keys.into_iter().collect::<std::collections::VecDeque<_>>());
|
|
let now = num(&cfg_rec, "now");
|
|
|
|
let hooks = Hooks {
|
|
session_key: Box::new(move || {
|
|
queue
|
|
.lock()
|
|
.expect("key queue")
|
|
.pop_front()
|
|
.expect("a recorded session key for each connection")
|
|
}),
|
|
now: Box::new(move || now),
|
|
};
|
|
|
|
let server = bind(host_cfg, hooks).expect("bind ephemeral port");
|
|
let addr = server.local_addr.to_string();
|
|
std::thread::spawn(move || {
|
|
let _ = server.run();
|
|
});
|
|
|
|
Harness { addr, records }
|
|
}
|
|
|
|
fn connect(addr: &str) -> TcpStream {
|
|
let s = TcpStream::connect(addr).expect("connect to host");
|
|
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
|
s.set_nodelay(true).unwrap();
|
|
s
|
|
}
|
|
|
|
fn read_frame(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Option<Frame> {
|
|
let mut chunk = [0u8; 65536];
|
|
while buf.len() < HEADER_LEN {
|
|
match stream.read(&mut chunk) {
|
|
Ok(0) | Err(_) => return None,
|
|
Ok(got) => buf.extend_from_slice(&chunk[..got]),
|
|
}
|
|
}
|
|
let total = Header::parse(&buf[..HEADER_LEN]).ok()?.frame_len();
|
|
while buf.len() < total {
|
|
match stream.read(&mut chunk) {
|
|
Ok(0) | Err(_) => return None,
|
|
Ok(got) => buf.extend_from_slice(&chunk[..got]),
|
|
}
|
|
}
|
|
let (frame, used) = Frame::parse(&buf[..total]).ok()?;
|
|
buf.drain(..used);
|
|
Some(frame)
|
|
}
|
|
|
|
/// Transactions for one recorded session, in order.
|
|
fn transactions<'a>(records: &'a [J], session: &str) -> Vec<&'a J> {
|
|
records
|
|
.iter()
|
|
.filter(|r| r["kind"] == "tx" && r["session"] == session)
|
|
.collect()
|
|
}
|
|
|
|
fn session_ids(records: &[J]) -> Vec<String> {
|
|
records
|
|
.iter()
|
|
.filter(|r| r["kind"] == "session")
|
|
.map(|r| st(r, "id"))
|
|
.collect()
|
|
}
|
|
|
|
/// The headline transport test: every recorded conversation, over TCP,
|
|
/// byte-for-byte, one connection per recorded session.
|
|
#[test]
|
|
fn recorded_conversations_replay_byte_for_byte_over_tcp() {
|
|
let h = start();
|
|
let mut total_frames = 0usize;
|
|
|
|
for sid in session_ids(&h.records) {
|
|
let mut stream = connect(&h.addr);
|
|
let mut buf = Vec::new();
|
|
|
|
for tx in transactions(&h.records, &sid) {
|
|
let name = st(tx, "name");
|
|
let request = unhex(&st(tx, "request_hex"));
|
|
let expected: Vec<String> = tx["responses"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_str().unwrap().to_string())
|
|
.collect();
|
|
|
|
stream.write_all(&request).expect("send request");
|
|
stream.flush().unwrap();
|
|
|
|
for (i, want) in expected.iter().enumerate() {
|
|
let frame = read_frame(&mut stream, &mut buf)
|
|
.unwrap_or_else(|| panic!("{sid}/{name}: no frame {i}, expected one"));
|
|
assert_eq!(
|
|
hex(&frame.encode()),
|
|
*want,
|
|
"\n{sid}/{name}: frame {i} differs over the wire"
|
|
);
|
|
total_frames += 1;
|
|
}
|
|
}
|
|
|
|
// A clean close between frames must be recognised as such.
|
|
drop(stream);
|
|
}
|
|
|
|
assert!(
|
|
total_frames >= 50,
|
|
"expected the full recorded conversation, saw {total_frames} frames"
|
|
);
|
|
}
|
|
|
|
/// The same conversation with every request written ONE BYTE AT A TIME.
|
|
///
|
|
/// This is the fragmentation case: a real client's frames arrive split across
|
|
/// reads, and a host that assumed one read per frame would pass every fixture
|
|
/// test and then fail against FIFA.
|
|
#[test]
|
|
fn requests_split_across_reads_are_reassembled() {
|
|
let h = start();
|
|
let sid = session_ids(&h.records).into_iter().next().unwrap();
|
|
let mut stream = connect(&h.addr);
|
|
let mut buf = Vec::new();
|
|
|
|
for tx in transactions(&h.records, &sid).into_iter().take(6) {
|
|
let name = st(tx, "name");
|
|
let request = unhex(&st(tx, "request_hex"));
|
|
for byte in &request {
|
|
stream.write_all(&[*byte]).expect("dribble a byte");
|
|
stream.flush().unwrap();
|
|
}
|
|
|
|
for (i, want) in tx["responses"].as_array().unwrap().iter().enumerate() {
|
|
let frame = read_frame(&mut stream, &mut buf)
|
|
.unwrap_or_else(|| panic!("{name}: no frame {i} after fragmented send"));
|
|
assert_eq!(
|
|
hex(&frame.encode()),
|
|
want.as_str().unwrap(),
|
|
"{name} frame {i}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Several requests written as ONE write, so the host sees them coalesced in a
|
|
/// single read and must split them itself.
|
|
#[test]
|
|
fn coalesced_requests_in_one_write_are_split() {
|
|
let h = start();
|
|
let sid = session_ids(&h.records).into_iter().next().unwrap();
|
|
let txs: Vec<&J> = transactions(&h.records, &sid).into_iter().take(4).collect();
|
|
|
|
let mut stream = connect(&h.addr);
|
|
let mut buf = Vec::new();
|
|
|
|
let mut blob = Vec::new();
|
|
for tx in &txs {
|
|
blob.extend_from_slice(&unhex(&st(tx, "request_hex")));
|
|
}
|
|
stream.write_all(&blob).expect("one big write");
|
|
stream.flush().unwrap();
|
|
|
|
for tx in &txs {
|
|
let name = st(tx, "name");
|
|
for (i, want) in tx["responses"].as_array().unwrap().iter().enumerate() {
|
|
let frame = read_frame(&mut stream, &mut buf)
|
|
.unwrap_or_else(|| panic!("{name}: no frame {i} after coalesced send"));
|
|
assert_eq!(
|
|
hex(&frame.encode()),
|
|
want.as_str().unwrap(),
|
|
"{name} frame {i}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Login must deliver four frames in order on a real socket, not just from
|
|
/// `dispatch()`. This is the ordering guarantee the client depends on.
|
|
#[test]
|
|
fn login_burst_arrives_in_order_over_the_wire() {
|
|
let h = start();
|
|
let sid = session_ids(&h.records).into_iter().next().unwrap();
|
|
let mut stream = connect(&h.addr);
|
|
let mut buf = Vec::new();
|
|
|
|
for tx in transactions(&h.records, &sid) {
|
|
let request = unhex(&st(tx, "request_hex"));
|
|
stream.write_all(&request).unwrap();
|
|
stream.flush().unwrap();
|
|
|
|
let expected = tx["responses"].as_array().unwrap().len();
|
|
let mut got = Vec::new();
|
|
for _ in 0..expected {
|
|
got.push(read_frame(&mut stream, &mut buf).expect("frame"));
|
|
}
|
|
|
|
if st(tx, "name") == "login" {
|
|
assert_eq!(got.len(), 4, "reply + three pushes");
|
|
let routes: Vec<(u16, u16)> = got
|
|
.iter()
|
|
.map(|f| (f.header.component, f.header.command))
|
|
.collect();
|
|
assert_eq!(
|
|
routes,
|
|
vec![
|
|
(0x0001, 0x000A), // Authentication::login REPLY
|
|
(0x7802, 0x0008), // UserAuthenticated
|
|
(0x7802, 0x0001), // UserSessionExtendedDataUpdate
|
|
(0x7802, 0x0002), // UserAdded
|
|
]
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
panic!("login transaction never reached");
|
|
}
|
|
|
|
/// Session state must persist across frames on one connection: the auth code
|
|
/// login records has to still be there when getAuthToken asks for it later.
|
|
#[test]
|
|
fn session_state_persists_across_frames_on_one_connection() {
|
|
let h = start();
|
|
let sid = session_ids(&h.records).into_iter().next().unwrap();
|
|
let mut stream = connect(&h.addr);
|
|
let mut buf = Vec::new();
|
|
|
|
let txs = transactions(&h.records, &sid);
|
|
let mut saw_before = false;
|
|
let mut saw_after = false;
|
|
|
|
for tx in txs {
|
|
let name = st(tx, "name");
|
|
stream.write_all(&unhex(&st(tx, "request_hex"))).unwrap();
|
|
stream.flush().unwrap();
|
|
|
|
for want in tx["responses"].as_array().unwrap() {
|
|
let frame = read_frame(&mut stream, &mut buf).expect("frame");
|
|
let got = hex(&frame.encode());
|
|
assert_eq!(got, want.as_str().unwrap(), "{name}");
|
|
|
|
if name == "get_auth_token_before_login" {
|
|
let body = openfut_protocol_blaze::heat2::decode(&frame.payload).unwrap();
|
|
let tok = body.get("AUTH").and_then(|v| v.as_str()).unwrap();
|
|
assert!(
|
|
tok.starts_with("OPENFUT-"),
|
|
"synthesised before login: {tok}"
|
|
);
|
|
saw_before = true;
|
|
}
|
|
if name == "get_auth_token_after_login" {
|
|
let body = openfut_protocol_blaze::heat2::decode(&frame.payload).unwrap();
|
|
let tok = body.get("AUTH").and_then(|v| v.as_str()).unwrap();
|
|
assert_eq!(tok, "OPENFUT-TEST-AUTHCODE", "echoes the login's code");
|
|
saw_after = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
saw_before && saw_after,
|
|
"both getAuthToken phases must be exercised"
|
|
);
|
|
}
|
|
|
|
/// A separate connection must get a separate session — no leakage.
|
|
#[test]
|
|
fn a_second_connection_gets_a_fresh_session() {
|
|
let h = start();
|
|
let ids = session_ids(&h.records);
|
|
|
|
// Connection 1: log in.
|
|
let mut c1 = connect(&h.addr);
|
|
let mut b1 = Vec::new();
|
|
for tx in transactions(&h.records, &ids[0]) {
|
|
c1.write_all(&unhex(&st(tx, "request_hex"))).unwrap();
|
|
c1.flush().unwrap();
|
|
for _ in 0..tx["responses"].as_array().unwrap().len() {
|
|
read_frame(&mut c1, &mut b1).expect("frame");
|
|
}
|
|
}
|
|
|
|
// Connection 2 asks for an auth token without logging in. If session state
|
|
// leaked it would echo connection 1's code instead of synthesising one.
|
|
let mut c2 = connect(&h.addr);
|
|
let mut b2 = Vec::new();
|
|
let get_token = transactions(&h.records, &ids[0])
|
|
.into_iter()
|
|
.find(|t| st(t, "name") == "get_auth_token_before_login")
|
|
.expect("fixture present");
|
|
c2.write_all(&unhex(&st(get_token, "request_hex"))).unwrap();
|
|
c2.flush().unwrap();
|
|
|
|
let frame = read_frame(&mut c2, &mut b2).expect("frame");
|
|
let body = openfut_protocol_blaze::heat2::decode(&frame.payload).unwrap();
|
|
let tok = body.get("AUTH").and_then(|v| v.as_str()).unwrap();
|
|
assert!(
|
|
tok.starts_with("OPENFUT-"),
|
|
"a fresh connection must not inherit a login: {tok}"
|
|
);
|
|
assert_ne!(tok, "OPENFUT-TEST-AUTHCODE");
|
|
}
|
|
|
|
/// A header claiming an absurd payload must drop the connection rather than
|
|
/// try to allocate for it.
|
|
#[test]
|
|
fn an_absurd_payload_length_drops_the_connection() {
|
|
let h = start();
|
|
let mut stream = connect(&h.addr);
|
|
|
|
let mut header = [0u8; HEADER_LEN];
|
|
header[0..4].copy_from_slice(&0x7FFF_FFFFu32.to_be_bytes()); // ~2 GiB
|
|
header[6..8].copy_from_slice(&0x0009u16.to_be_bytes());
|
|
header[8..10].copy_from_slice(&0x0002u16.to_be_bytes());
|
|
stream.write_all(&header).unwrap();
|
|
stream.flush().unwrap();
|
|
|
|
let mut buf = Vec::new();
|
|
assert!(
|
|
read_frame(&mut stream, &mut buf).is_none(),
|
|
"the host must close, not answer, an absurd frame"
|
|
);
|
|
}
|
|
|
|
/// A body that will not decode as TDF must not kill the connection: the oracle
|
|
/// logs it and dispatches with no fields.
|
|
#[test]
|
|
fn an_undecodable_body_still_gets_a_reply() {
|
|
let h = start();
|
|
let mut stream = connect(&h.addr);
|
|
|
|
// Util::ping with a garbage payload. ping ignores its body, so the reply
|
|
// must arrive exactly as if the body had been empty.
|
|
let mut frame = Frame::new(
|
|
0x0009,
|
|
0x0002,
|
|
1,
|
|
openfut_protocol_blaze::fire2::MsgType::Message,
|
|
vec![0xFF; 8],
|
|
);
|
|
frame.header.payload_len = 8;
|
|
stream.write_all(&frame.encode()).unwrap();
|
|
stream.flush().unwrap();
|
|
|
|
let mut buf = Vec::new();
|
|
let reply = read_frame(&mut stream, &mut buf).expect("a reply despite the bad body");
|
|
assert_eq!(reply.header.component, 0x0009);
|
|
assert_eq!(reply.header.command, 0x0002);
|
|
assert!(!reply.payload.is_empty(), "ping still answers with STIM");
|
|
}
|
|
|
|
// ───────────────────────────── raw frame capture ─────────────────────────────
|
|
//
|
|
// Capture is evidence infrastructure. These tests exist because the value of a
|
|
// capture is entirely in its fidelity: a capture that quietly drops, reorders
|
|
// or alters frames is worse than none, since it would be trusted.
|
|
|
|
fn cap_path(name: &str) -> String {
|
|
let dir = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into());
|
|
format!("{dir}/ofcap-it-{}-{name}.ofcap", std::process::id())
|
|
}
|
|
|
|
/// Drive a conversation over TCP and return (captured records, wire responses).
|
|
fn converse(h: &Harness, take: usize) -> Vec<Vec<u8>> {
|
|
let sid = session_ids(&h.records).into_iter().next().unwrap();
|
|
let mut stream = connect(&h.addr);
|
|
let mut buf = Vec::new();
|
|
let mut got = Vec::new();
|
|
for tx in transactions(&h.records, &sid).into_iter().take(take) {
|
|
stream.write_all(&unhex(&st(tx, "request_hex"))).unwrap();
|
|
stream.flush().unwrap();
|
|
for _ in 0..tx["responses"].as_array().unwrap().len() {
|
|
got.push(read_frame(&mut stream, &mut buf).expect("frame").encode());
|
|
}
|
|
}
|
|
got
|
|
}
|
|
|
|
#[test]
|
|
fn capture_disabled_produces_no_artefact() {
|
|
let path = cap_path("disabled");
|
|
let _ = std::fs::remove_file(&path);
|
|
|
|
let h = start(); // capture_path: None
|
|
converse(&h, 4);
|
|
|
|
assert!(
|
|
!std::path::Path::new(&path).exists(),
|
|
"no capture file may appear when capture is off"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn capture_records_rx_and_tx_frames_exactly_and_in_order() {
|
|
let path = cap_path("exact");
|
|
let _ = std::fs::remove_file(&path);
|
|
|
|
let h = start_with_capture(Some(path.clone()));
|
|
let sid = session_ids(&h.records).into_iter().next().unwrap();
|
|
let txs: Vec<&J> = transactions(&h.records, &sid).into_iter().take(6).collect();
|
|
|
|
let mut stream = connect(&h.addr);
|
|
let mut buf = Vec::new();
|
|
let mut sent = Vec::new();
|
|
let mut received = Vec::new();
|
|
for tx in &txs {
|
|
let req = unhex(&st(tx, "request_hex"));
|
|
stream.write_all(&req).unwrap();
|
|
stream.flush().unwrap();
|
|
sent.push(req);
|
|
for _ in 0..tx["responses"].as_array().unwrap().len() {
|
|
received.push(read_frame(&mut stream, &mut buf).expect("frame").encode());
|
|
}
|
|
}
|
|
drop(stream);
|
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
|
|
let recs = capture::read_file(&path).expect("capture parses");
|
|
|
|
let rx: Vec<Vec<u8>> = recs
|
|
.iter()
|
|
.filter(|r| r.dir == capture::Direction::Rx)
|
|
.map(|r| r.frame.clone())
|
|
.collect();
|
|
let tx_caps: Vec<Vec<u8>> = recs
|
|
.iter()
|
|
.filter(|r| r.dir == capture::Direction::Tx)
|
|
.map(|r| r.frame.clone())
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
rx, sent,
|
|
"captured RX must equal the exact bytes sent by the client"
|
|
);
|
|
assert_eq!(
|
|
tx_caps, received,
|
|
"captured TX must equal the exact bytes the client received"
|
|
);
|
|
|
|
// Total order is monotonic, and RX precedes its own TX.
|
|
let seqs: Vec<u64> = recs.iter().map(|r| r.seq).collect();
|
|
let mut sorted = seqs.clone();
|
|
sorted.sort();
|
|
assert_eq!(seqs, sorted, "records are written in order");
|
|
assert_eq!(recs[0].dir, capture::Direction::Rx);
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn capture_does_not_alter_the_wire_output() {
|
|
// The same conversation with and without capture must produce identical
|
|
// response bytes. Capture must be observation, not participation.
|
|
let without = converse(&start(), 6);
|
|
|
|
let path = cap_path("noalter");
|
|
let _ = std::fs::remove_file(&path);
|
|
let with = converse(&start_with_capture(Some(path.clone())), 6);
|
|
|
|
assert_eq!(
|
|
with.iter().map(|f| hex(f)).collect::<Vec<_>>(),
|
|
without.iter().map(|f| hex(f)).collect::<Vec<_>>(),
|
|
"enabling capture changed the wire output"
|
|
);
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn fragmented_input_reconstructs_the_same_captured_frames() {
|
|
// A frame dribbled one byte at a time must be captured as ONE whole frame,
|
|
// identical to the same frame sent in a single write.
|
|
let sid_take = 3;
|
|
|
|
let whole_path = cap_path("whole");
|
|
let _ = std::fs::remove_file(&whole_path);
|
|
converse(&start_with_capture(Some(whole_path.clone())), sid_take);
|
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
|
let whole = capture::read_file(&whole_path).unwrap();
|
|
|
|
let frag_path = cap_path("frag");
|
|
let _ = std::fs::remove_file(&frag_path);
|
|
let h = start_with_capture(Some(frag_path.clone()));
|
|
let sid = session_ids(&h.records).into_iter().next().unwrap();
|
|
let mut stream = connect(&h.addr);
|
|
let mut buf = Vec::new();
|
|
for tx in transactions(&h.records, &sid).into_iter().take(sid_take) {
|
|
for byte in unhex(&st(tx, "request_hex")) {
|
|
stream.write_all(&[byte]).unwrap();
|
|
stream.flush().unwrap();
|
|
}
|
|
for _ in 0..tx["responses"].as_array().unwrap().len() {
|
|
read_frame(&mut stream, &mut buf).expect("frame");
|
|
}
|
|
}
|
|
drop(stream);
|
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
let frag = capture::read_file(&frag_path).unwrap();
|
|
|
|
let f_whole: Vec<String> = whole.iter().map(|r| hex(&r.frame)).collect();
|
|
let f_frag: Vec<String> = frag.iter().map(|r| hex(&r.frame)).collect();
|
|
assert_eq!(
|
|
f_frag, f_whole,
|
|
"fragmentation must not change captured frames"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&whole_path);
|
|
let _ = std::fs::remove_file(&frag_path);
|
|
}
|
|
|
|
#[test]
|
|
fn coalesced_input_is_captured_as_separate_frames() {
|
|
let path = cap_path("coalesced");
|
|
let _ = std::fs::remove_file(&path);
|
|
|
|
let h = start_with_capture(Some(path.clone()));
|
|
let sid = session_ids(&h.records).into_iter().next().unwrap();
|
|
let txs: Vec<&J> = transactions(&h.records, &sid).into_iter().take(4).collect();
|
|
|
|
let mut stream = connect(&h.addr);
|
|
let mut buf = Vec::new();
|
|
let mut blob = Vec::new();
|
|
for tx in &txs {
|
|
blob.extend_from_slice(&unhex(&st(tx, "request_hex")));
|
|
}
|
|
stream.write_all(&blob).unwrap(); // ONE write
|
|
stream.flush().unwrap();
|
|
for tx in &txs {
|
|
for _ in 0..tx["responses"].as_array().unwrap().len() {
|
|
read_frame(&mut stream, &mut buf).expect("frame");
|
|
}
|
|
}
|
|
drop(stream);
|
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
|
|
let recs = capture::read_file(&path).unwrap();
|
|
let rx: Vec<Vec<u8>> = recs
|
|
.iter()
|
|
.filter(|r| r.dir == capture::Direction::Rx)
|
|
.map(|r| r.frame.clone())
|
|
.collect();
|
|
|
|
assert_eq!(rx.len(), txs.len(), "one record per frame, not per read");
|
|
for (got, tx) in rx.iter().zip(txs.iter()) {
|
|
assert_eq!(
|
|
hex(got),
|
|
st(tx, "request_hex"),
|
|
"frame boundaries preserved"
|
|
);
|
|
}
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn sanitization_removes_the_session_key_from_a_real_captured_login() {
|
|
let path = cap_path("sanitize");
|
|
let _ = std::fs::remove_file(&path);
|
|
|
|
let h = start_with_capture(Some(path.clone()));
|
|
let sid = session_ids(&h.records).into_iter().next().unwrap();
|
|
let mut stream = connect(&h.addr);
|
|
let mut buf = Vec::new();
|
|
for tx in transactions(&h.records, &sid) {
|
|
stream.write_all(&unhex(&st(tx, "request_hex"))).unwrap();
|
|
stream.flush().unwrap();
|
|
for _ in 0..tx["responses"].as_array().unwrap().len() {
|
|
read_frame(&mut stream, &mut buf).expect("frame");
|
|
}
|
|
if st(tx, "name") == "login" {
|
|
break;
|
|
}
|
|
}
|
|
drop(stream);
|
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
|
|
let recs = capture::read_file(&path).unwrap();
|
|
let session_key = h
|
|
.records
|
|
.iter()
|
|
.find(|r| r["kind"] == "session" && r["id"] == sid.as_str())
|
|
.map(|r| st(r, "session_key"))
|
|
.expect("recorded session key");
|
|
|
|
// The raw capture DOES contain the key — that is what makes it forensic
|
|
// evidence, and why it is gitignored.
|
|
let raw_has_key = recs
|
|
.iter()
|
|
.any(|r| String::from_utf8_lossy(&r.frame).contains(&session_key));
|
|
assert!(
|
|
raw_has_key,
|
|
"raw capture should contain the real session key"
|
|
);
|
|
|
|
let (out, report) = sanitize::sanitize(&recs).expect("sanitizes");
|
|
assert!(
|
|
report.frames_redacted > 0,
|
|
"login frames carry a session key"
|
|
);
|
|
|
|
for (_, bytes) in &out {
|
|
assert!(
|
|
!String::from_utf8_lossy(bytes).contains(&session_key),
|
|
"sanitized output still contains the session key"
|
|
);
|
|
}
|
|
// Structure is untouched: same frame count, same sizes, same order.
|
|
assert_eq!(out.len(), recs.len());
|
|
for ((rec, bytes), orig) in out.iter().zip(recs.iter()) {
|
|
assert_eq!(
|
|
bytes.len(),
|
|
orig.frame.len(),
|
|
"frame size must be preserved"
|
|
);
|
|
assert_eq!(rec.seq, orig.seq, "order must be preserved");
|
|
}
|
|
// And every redaction is reported, not silent.
|
|
assert!(
|
|
report.redactions.iter().any(|r| r.path.ends_with("KEY")),
|
|
"{:?}",
|
|
report.redactions
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|