openfut-blaze-host: thin Blaze sidecar, live-parity with Python
Third migration step, and the one that turns fixture parity into transport parity. A TCP host that frames a Fire2 stream, keeps one Session per connection, calls openfut-adapter-fifa17::dispatch(), and writes the returned frames in order. It owns a socket, a buffer, a session and diagnostics -- that is the complete list. No coins, club, packs, profiles or UTAS logic: those belong to Core, reached through the adapter later. NO TLS, and that is evidence-based rather than an omission. The Blaze main port is plaintext: sending a raw Fire2 Util::ping to the running backend returns a plaintext PingResponse, blaze_handle uses the raw socket, and only redir_handle wraps ssl. TLS belongs to the redirector phase. LIVE A/B AGAINST THE RUNNING PYTHON BACKEND: 101 frames across three conversations, identical normalized traces. This is the first result in the migration that is not purely offline. check-live-parity.sh replays the recorded conversations against both endpoints over real sockets and diffs volatile-masked traces; session keys and clocks are masked, so anything that differs is behavioural. Transport tests cover what fixtures cannot: byte-for-byte replay over a socket, requests dribbled one byte at a time, several requests in one write, the four-frame login burst ordered on the wire, session state persisting across frames and NOT leaking between connections, an absurd payload length closing the connection instead of allocating, and an undecodable body still getting a reply. 18 tests here, 116 across the three migration crates. MUTATION TESTED, including the comparison itself. Dropping a post-login notification is caught by the probe (frame count) AND the diff; a same-length content change deep inside a notification body (CTY "US"->"GB", payload 116 both sides) is caught ONLY by the trace digest. So the probe's exit code is not the test -- the diff is, and the README says so. check-live-parity.sh was itself verified to exit 1 under mutation. The listen port is required configuration with no default, so the sidecar cannot silently collide with the working container. OPENFUT_BIND stays the advertised-config bind (the adapter derives nucleusConnect from it, reproducing the oracle) and the listener gets its own setting, so the two are not conflated. Gates 1-4 pass and are re-runnable. Gates 5-10 need a FIFA client and are listed in the README, including the Python -> Rust -> Python -> Rust back-and-forth that proves the rollback path rather than asserting it. Python backend untouched and still the live runtime; contract suite 446/446 after this work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
//! Replay a recorded Blaze conversation against a LIVE endpoint and emit a
|
||||
//! normalized trace.
|
||||
//!
|
||||
//! This is the A/B instrument. Point it at the Python backend, then at the Rust
|
||||
//! sidecar, and diff the two traces:
|
||||
//!
|
||||
//! ```text
|
||||
//! blaze-probe 127.0.0.1:42130 > /tmp/python.trace
|
||||
//! blaze-probe 127.0.0.1:42230 > /tmp/rust.trace
|
||||
//! diff /tmp/python.trace /tmp/rust.trace
|
||||
//! ```
|
||||
//!
|
||||
//! Byte comparison is impossible across two live servers — session keys and
|
||||
//! server timestamps differ by design — so the trace masks known-volatile
|
||||
//! values while preserving their tag, type and length. Everything else must
|
||||
//! match exactly, including frame counts and notification ordering.
|
||||
//!
|
||||
//! The requests come from `openfut-adapter-fifa17/fixtures/blaze_transactions.jsonl`,
|
||||
//! so this exercises the same conversation the offline parity suite does, but
|
||||
//! over a real socket against a real server process.
|
||||
//!
|
||||
//! Read-only: it opens a client connection and sends recorded requests. It
|
||||
//! writes nothing and mutates no state beyond the server's own per-connection
|
||||
//! session.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
|
||||
use openfut_blaze_host::trace;
|
||||
use openfut_protocol_blaze::fire2::{Frame, Header, HEADER_LEN};
|
||||
|
||||
fn usage() -> ! {
|
||||
eprintln!("usage: blaze-probe <host:port> [--verbose] [--session main|fallbacks|locale]");
|
||||
eprintln!();
|
||||
eprintln!("Replays the recorded Blaze conversation against a live endpoint and");
|
||||
eprintln!("prints a normalized, volatile-masked trace on stdout.");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
fn fixtures_path() -> String {
|
||||
format!(
|
||||
"{}/../openfut-adapter-fifa17/fixtures/blaze_transactions.jsonl",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.is_empty() || args[0].starts_with("--") {
|
||||
usage();
|
||||
}
|
||||
let endpoint = args[0].clone();
|
||||
let verbose = args.iter().any(|a| a == "--verbose");
|
||||
let want_session = args
|
||||
.windows(2)
|
||||
.find(|w| w[0] == "--session")
|
||||
.map(|w| w[1].clone())
|
||||
.unwrap_or_else(|| "main".to_string());
|
||||
|
||||
let path = fixtures_path();
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| {
|
||||
eprintln!("cannot read {path}: {e}");
|
||||
std::process::exit(1)
|
||||
});
|
||||
|
||||
let records: Vec<serde_json::Value> = text
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| serde_json::from_str(l).expect("fixture line is valid JSON"))
|
||||
.collect();
|
||||
|
||||
let requests: Vec<(String, Vec<u8>)> = records
|
||||
.iter()
|
||||
.filter(|r| r["kind"] == "tx" && r["session"] == want_session.as_str())
|
||||
.map(|r| {
|
||||
(
|
||||
r["name"].as_str().unwrap().to_string(),
|
||||
unhex(r["request_hex"].as_str().unwrap()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if requests.is_empty() {
|
||||
eprintln!("no transactions for session {want_session:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"blaze-probe: {} requests from session {want_session:?} -> {endpoint}",
|
||||
requests.len()
|
||||
);
|
||||
|
||||
let mut stream = TcpStream::connect(&endpoint).unwrap_or_else(|e| {
|
||||
eprintln!("cannot connect to {endpoint}: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let _ = stream.set_nodelay(true);
|
||||
// Generous but finite: a server that answers nothing must not hang the probe.
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
|
||||
|
||||
println!("# blaze-probe conversation: session={want_session}");
|
||||
println!("# endpoint intentionally omitted so traces from different hosts diff cleanly");
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut mismatched = 0usize;
|
||||
|
||||
for (n, (name, request)) in requests.iter().enumerate() {
|
||||
let req_frame = Frame::parse(request).expect("fixture request parses").0;
|
||||
println!("--- {:02} {name}", n + 1);
|
||||
println!(
|
||||
"{}",
|
||||
trace::trace_line(0, "TX", &format!("{}", n + 1), &req_frame)
|
||||
);
|
||||
|
||||
if let Err(e) = stream.write_all(request) {
|
||||
println!("!! send failed: {e}");
|
||||
mismatched += 1;
|
||||
break;
|
||||
}
|
||||
let _ = stream.flush();
|
||||
|
||||
// How many frames to expect is recorded; a server that sends fewer is
|
||||
// the failure this probe exists to catch, so read with a timeout rather
|
||||
// than blocking forever.
|
||||
let expected = records
|
||||
.iter()
|
||||
.find(|r| r["kind"] == "tx" && r["name"] == name.as_str())
|
||||
.and_then(|r| r["responses"].as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut got = 0usize;
|
||||
while got < expected {
|
||||
match read_frame(&mut stream, &mut buf) {
|
||||
Ok(Some(frame)) => {
|
||||
println!(
|
||||
"{}",
|
||||
trace::trace_line(0, "RX", &format!("{}.{}", n + 1, got), &frame)
|
||||
);
|
||||
if verbose && !frame.payload.is_empty() {
|
||||
if let Ok(body) = openfut_protocol_blaze::heat2::decode(&frame.payload) {
|
||||
for line in trace::masked_dump(&body).lines() {
|
||||
println!(" {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
got += 1;
|
||||
}
|
||||
Ok(None) => {
|
||||
println!("!! connection closed after {got}/{expected} frame(s)");
|
||||
mismatched += 1;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
println!("!! read failed after {got}/{expected} frame(s): {e}");
|
||||
mismatched += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if got != expected {
|
||||
println!("!! frame count {got}, recorded {expected}");
|
||||
mismatched += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"# done: {} request(s), {mismatched} anomaly/anomalies",
|
||||
requests.len()
|
||||
);
|
||||
if mismatched > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_frame(stream: &mut TcpStream, buf: &mut Vec<u8>) -> std::io::Result<Option<Frame>> {
|
||||
let mut chunk = [0u8; 65536];
|
||||
while buf.len() < HEADER_LEN {
|
||||
match stream.read(&mut chunk)? {
|
||||
0 => return Ok(None),
|
||||
got => buf.extend_from_slice(&chunk[..got]),
|
||||
}
|
||||
}
|
||||
let header =
|
||||
Header::parse(&buf[..HEADER_LEN]).map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
let total = header.frame_len();
|
||||
while buf.len() < total {
|
||||
match stream.read(&mut chunk)? {
|
||||
0 => return Ok(None),
|
||||
got => buf.extend_from_slice(&chunk[..got]),
|
||||
}
|
||||
}
|
||||
let (frame, used) =
|
||||
Frame::parse(&buf[..total]).map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
buf.drain(..used);
|
||||
Ok(Some(frame))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Host configuration, entirely from the environment.
|
||||
//!
|
||||
//! Two rules carried over from the Python deployment:
|
||||
//!
|
||||
//! * **No silent loopback.** `OPENFUT_ADVERTISE` is required, exactly as the
|
||||
//! Python entrypoint requires it. A backend that guesses its own reachable
|
||||
//! address is the bug the client/server split removed.
|
||||
//! * **No default port.** The sidecar runs beside the working Python container
|
||||
//! and must never collide with it, so the listen port is explicit. There is
|
||||
//! no "test port" constant anywhere in this crate.
|
||||
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
|
||||
use openfut_adapter_fifa17::blaze::{AdapterConfig, Endpoints, Identity};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ConfigError(String);
|
||||
|
||||
impl fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostConfig {
|
||||
/// Address the listener binds.
|
||||
pub listen_addr: String,
|
||||
/// Port the listener binds. Required; no default.
|
||||
pub listen_port: u16,
|
||||
/// Seconds of inactivity before a connection is dropped. Matches the
|
||||
/// oracle's 300s socket timeout.
|
||||
pub idle_timeout_secs: u64,
|
||||
/// Reject a frame claiming a larger payload than this, as the oracle does.
|
||||
pub max_payload_bytes: u32,
|
||||
/// Optional path for the normalized structural trace.
|
||||
pub trace_path: Option<String>,
|
||||
/// What the adapter answers with.
|
||||
pub adapter: AdapterConfig,
|
||||
}
|
||||
|
||||
fn required(key: &str, why: &str) -> Result<String, ConfigError> {
|
||||
match env::var(key) {
|
||||
Ok(v) if !v.trim().is_empty() => Ok(v),
|
||||
_ => Err(ConfigError(format!("{key} must be set — {why}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional(key: &str, default: &str) -> String {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
impl HostConfig {
|
||||
pub fn from_env() -> Result<HostConfig, ConfigError> {
|
||||
// The address handed to the CLIENT for every next hop.
|
||||
let advertise = required(
|
||||
"OPENFUT_ADVERTISE",
|
||||
"it is the address the game machine uses to reach this host; \
|
||||
there is no loopback fallback in remote mode",
|
||||
)?;
|
||||
|
||||
// NOTE: this is the *advertised-config* bind, not the listener bind.
|
||||
// The adapter derives nucleusConnect from it, reproducing the oracle
|
||||
// (see the adapter's config docs and the vault's known-issue entry), so
|
||||
// it must mirror whatever the Python container runs with if the two are
|
||||
// to be compared. The listener has its own setting below.
|
||||
let config_bind = optional("OPENFUT_BIND", "127.0.0.1");
|
||||
|
||||
let listen_port_raw = required(
|
||||
"OPENFUT_BLAZE_HOST_PORT",
|
||||
"the sidecar runs beside the working Python backend and must not \
|
||||
collide with it, so the port is explicit and has no default",
|
||||
)?;
|
||||
let listen_port: u16 = listen_port_raw.trim().parse().map_err(|_| {
|
||||
ConfigError(format!(
|
||||
"OPENFUT_BLAZE_HOST_PORT is not a valid port: {listen_port_raw:?}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let listen_addr = optional("OPENFUT_BLAZE_HOST_BIND", &config_bind);
|
||||
|
||||
let endpoints = Endpoints {
|
||||
advertise,
|
||||
bind: config_bind,
|
||||
pow_content_host: optional("POW_CONTENT_HOST", "127.0.0.1:8080"),
|
||||
pow_host: optional("POW_HOST", "127.0.0.1:8094"),
|
||||
..Endpoints::default()
|
||||
};
|
||||
|
||||
Ok(HostConfig {
|
||||
listen_addr,
|
||||
listen_port,
|
||||
idle_timeout_secs: optional("OPENFUT_BLAZE_IDLE_TIMEOUT", "300")
|
||||
.parse()
|
||||
.unwrap_or(300),
|
||||
max_payload_bytes: 4 * 1024 * 1024,
|
||||
trace_path: env::var("OPENFUT_BLAZE_TRACE")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
adapter: AdapterConfig {
|
||||
identity: Identity::default(),
|
||||
endpoints,
|
||||
server_version: AdapterConfig::default().server_version,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn listen_on(&self) -> String {
|
||||
format!("{}:{}", self.listen_addr, self.listen_port)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Env is process-global, so these run under one lock rather than as
|
||||
/// separate tests that would race each other.
|
||||
#[test]
|
||||
fn env_contract() {
|
||||
let keys = [
|
||||
"OPENFUT_ADVERTISE",
|
||||
"OPENFUT_BIND",
|
||||
"OPENFUT_BLAZE_HOST_PORT",
|
||||
"OPENFUT_BLAZE_HOST_BIND",
|
||||
];
|
||||
let saved: Vec<_> = keys.iter().map(|k| (*k, env::var(k).ok())).collect();
|
||||
for k in keys {
|
||||
env::remove_var(k);
|
||||
}
|
||||
|
||||
// Missing advertise is refused, not defaulted.
|
||||
let err = HostConfig::from_env().unwrap_err().to_string();
|
||||
assert!(err.contains("OPENFUT_ADVERTISE"), "{err}");
|
||||
|
||||
// Missing port is refused too — no default that could collide.
|
||||
env::set_var("OPENFUT_ADVERTISE", "10.0.0.5");
|
||||
let err = HostConfig::from_env().unwrap_err().to_string();
|
||||
assert!(err.contains("OPENFUT_BLAZE_HOST_PORT"), "{err}");
|
||||
|
||||
// A non-numeric port is a clear error, not a silent fallback.
|
||||
env::set_var("OPENFUT_BLAZE_HOST_PORT", "not-a-port");
|
||||
let err = HostConfig::from_env().unwrap_err().to_string();
|
||||
assert!(err.contains("not a valid port"), "{err}");
|
||||
|
||||
// Happy path: listener bind defaults to the config bind.
|
||||
env::set_var("OPENFUT_BLAZE_HOST_PORT", "42230");
|
||||
env::set_var("OPENFUT_BIND", "0.0.0.0");
|
||||
let cfg = HostConfig::from_env().expect("configured");
|
||||
assert_eq!(cfg.listen_on(), "0.0.0.0:42230");
|
||||
assert_eq!(cfg.adapter.endpoints.advertise, "10.0.0.5");
|
||||
// The adapter's nucleus URL follows the CONFIG bind, reproducing the
|
||||
// oracle's behaviour rather than the listener's address.
|
||||
assert_eq!(cfg.adapter.nucleus_base(), "http://0.0.0.0:42131");
|
||||
|
||||
// The listener bind can differ from the advertised-config bind.
|
||||
env::set_var("OPENFUT_BLAZE_HOST_BIND", "127.0.0.1");
|
||||
let cfg = HostConfig::from_env().expect("configured");
|
||||
assert_eq!(cfg.listen_on(), "127.0.0.1:42230");
|
||||
assert_eq!(cfg.adapter.nucleus_base(), "http://0.0.0.0:42131");
|
||||
|
||||
for (k, v) in saved {
|
||||
match v {
|
||||
Some(v) => env::set_var(k, v),
|
||||
None => env::remove_var(k),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Per-connection Fire2 stream handling.
|
||||
//!
|
||||
//! This is the layer fixtures cannot test: a socket delivers bytes, not frames.
|
||||
//! Frames arrive split across reads and coalesced into one, several arrive per
|
||||
//! connection, and the connection outlives every individual RPC.
|
||||
//!
|
||||
//! The loop mirrors the Python oracle's exactly — read a 16-byte header, derive
|
||||
//! the total length from it, read the rest, consume, dispatch, write every
|
||||
//! returned frame in order — because that behaviour is part of what was proven
|
||||
//! against the real client.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use openfut_adapter_fifa17::blaze::{Adapter, Session};
|
||||
use openfut_protocol_blaze::fire2::{Frame, Header, HEADER_LEN};
|
||||
use openfut_protocol_blaze::heat2::{self, Struct};
|
||||
|
||||
use crate::config::HostConfig;
|
||||
use crate::trace::{self, Tracer};
|
||||
use crate::Hooks;
|
||||
|
||||
/// Why a connection ended. Logged so a live run can be compared with Python's.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum CloseReason {
|
||||
/// Client closed cleanly between frames.
|
||||
ClientClosed,
|
||||
/// Stream ended part-way through a frame.
|
||||
EofMidFrame { want: usize, have: usize },
|
||||
/// A header claimed a payload larger than the configured ceiling.
|
||||
AbsurdPayload { claimed: u32 },
|
||||
/// No bytes within the idle timeout.
|
||||
IdleTimeout,
|
||||
/// Socket error.
|
||||
Io(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CloseReason {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CloseReason::ClientClosed => write!(f, "client closed"),
|
||||
CloseReason::EofMidFrame { want, have } => {
|
||||
write!(f, "EOF mid-frame (want {want}, have {have})")
|
||||
}
|
||||
CloseReason::AbsurdPayload { claimed } => {
|
||||
write!(f, "absurd payload_len {claimed}, dropping connection")
|
||||
}
|
||||
CloseReason::IdleTimeout => write!(f, "idle timeout"),
|
||||
CloseReason::Io(e) => write!(f, "io error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unix_now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Mint a Blaze-shaped session key.
|
||||
///
|
||||
/// The client never validates the format, so this only has to be stable within
|
||||
/// a connection and distinct between them.
|
||||
pub(crate) fn mint_session_key() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
const ALPHA: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$*";
|
||||
let tail: String = (0..44)
|
||||
.map(|_| ALPHA[rng.gen_range(0..ALPHA.len())] as char)
|
||||
.collect();
|
||||
openfut_adapter_fifa17::blaze::session::format_session_key(rng.gen::<u64>(), &tail)
|
||||
}
|
||||
|
||||
/// Serve one connection until it closes.
|
||||
pub fn handle(
|
||||
mut stream: TcpStream,
|
||||
conn_id: u64,
|
||||
cfg: &HostConfig,
|
||||
adapter: &Adapter,
|
||||
tracer: &Tracer,
|
||||
hooks: &Hooks,
|
||||
) -> CloseReason {
|
||||
let peer = stream
|
||||
.peer_addr()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|_| "<unknown>".into());
|
||||
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(cfg.idle_timeout_secs)));
|
||||
// Blaze is request/response with server pushes; Nagle would add latency to
|
||||
// every burst for no benefit.
|
||||
let _ = stream.set_nodelay(true);
|
||||
|
||||
let mut session = Session::new((hooks.session_key)(), cfg.adapter.identity.account_locale);
|
||||
trace::log(&format!(
|
||||
"conn-{conn_id:04} CONNECT from {peer} (session key minted: [REDACTED])"
|
||||
));
|
||||
tracer.write_line(&format!("conn-{conn_id:04} OPEN"));
|
||||
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(16 * 1024);
|
||||
let mut frame_no: u64 = 0;
|
||||
|
||||
let reason = loop {
|
||||
// ---- header
|
||||
match fill_to(&mut stream, &mut buf, HEADER_LEN) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
break if buf.is_empty() {
|
||||
CloseReason::ClientClosed
|
||||
} else {
|
||||
CloseReason::EofMidFrame {
|
||||
want: HEADER_LEN,
|
||||
have: buf.len(),
|
||||
}
|
||||
};
|
||||
}
|
||||
Err(e) => break classify(e),
|
||||
}
|
||||
|
||||
let header = match Header::parse(&buf[..HEADER_LEN]) {
|
||||
Ok(h) => h,
|
||||
// Unreachable: fill_to guaranteed 16 bytes. Treated as a close
|
||||
// rather than a panic because this is a network path.
|
||||
Err(e) => break CloseReason::Io(e.to_string()),
|
||||
};
|
||||
|
||||
if header.payload_len > cfg.max_payload_bytes {
|
||||
trace::log(&format!(
|
||||
"conn-{conn_id:04} REJECT payload_len {} exceeds {} — {}",
|
||||
header.payload_len,
|
||||
cfg.max_payload_bytes,
|
||||
hex_prefix(&buf)
|
||||
));
|
||||
break CloseReason::AbsurdPayload {
|
||||
claimed: header.payload_len,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- body
|
||||
let total = header.frame_len();
|
||||
match fill_to(&mut stream, &mut buf, total) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
break CloseReason::EofMidFrame {
|
||||
want: total,
|
||||
have: buf.len(),
|
||||
}
|
||||
}
|
||||
Err(e) => break classify(e),
|
||||
}
|
||||
|
||||
let (frame, used) = match Frame::parse(&buf[..total]) {
|
||||
Ok(v) => v,
|
||||
Err(e) => break CloseReason::Io(e.to_string()),
|
||||
};
|
||||
buf.drain(..used);
|
||||
|
||||
frame_no += 1;
|
||||
trace::log(&format!(
|
||||
"conn-{conn_id:04} RX #{frame_no} {}",
|
||||
trace::describe(&frame.header)
|
||||
));
|
||||
tracer.frame(conn_id, "RX", &frame_no.to_string(), &frame);
|
||||
|
||||
// A body that will not decode is NOT fatal: the oracle logs it and
|
||||
// dispatches with no fields, letting the RPC fall back to defaults.
|
||||
// An empty Struct is equivalent to the oracle's `None` on every path
|
||||
// dispatch takes (verified: every read is guarded or defaulted).
|
||||
let body = if frame.payload.is_empty() {
|
||||
Struct::new()
|
||||
} else {
|
||||
match heat2::decode(&frame.payload) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
trace::log(&format!(
|
||||
"conn-{conn_id:04} RX #{frame_no} TDF DECODE FAILED: {e}"
|
||||
));
|
||||
Struct::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let out = adapter.dispatch(&frame.header, &body, &mut session, (hooks.now)());
|
||||
|
||||
for (k, resp) in out.iter().enumerate() {
|
||||
let bytes = resp.encode();
|
||||
if let Err(e) = stream.write_all(&bytes) {
|
||||
trace::log(&format!("conn-{conn_id:04} TX #{frame_no}.{k} FAILED: {e}"));
|
||||
break;
|
||||
}
|
||||
trace::log(&format!(
|
||||
"conn-{conn_id:04} TX #{frame_no}.{k} {} ({}B total)",
|
||||
trace::describe(&resp.header),
|
||||
bytes.len()
|
||||
));
|
||||
tracer.frame(conn_id, "TX", &format!("{frame_no}.{k}"), resp);
|
||||
}
|
||||
if let Err(e) = stream.flush() {
|
||||
break CloseReason::Io(e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
trace::log(&format!(
|
||||
"conn-{conn_id:04} CLOSE after {frame_no} frame(s): {reason}"
|
||||
));
|
||||
tracer.write_line(&format!("conn-{conn_id:04} CLOSE frames={frame_no}"));
|
||||
reason
|
||||
}
|
||||
|
||||
/// Read until `buf` holds at least `n` bytes. `Ok(false)` on clean EOF.
|
||||
fn fill_to(stream: &mut TcpStream, buf: &mut Vec<u8>, n: usize) -> io::Result<bool> {
|
||||
let mut chunk = [0u8; 65536];
|
||||
while buf.len() < n {
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) => return Ok(false),
|
||||
Ok(got) => buf.extend_from_slice(&chunk[..got]),
|
||||
Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn classify(e: io::Error) -> CloseReason {
|
||||
match e.kind() {
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut => CloseReason::IdleTimeout,
|
||||
_ => CloseReason::Io(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_prefix(buf: &[u8]) -> String {
|
||||
buf.iter()
|
||||
.take(16)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minted_keys_have_the_blaze_shape_and_differ() {
|
||||
let a = mint_session_key();
|
||||
let b = mint_session_key();
|
||||
assert_eq!(a.len(), 16 + 1 + 44);
|
||||
assert_ne!(a, b, "a session key must be distinct per connection");
|
||||
assert!(a[..16].chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_eq!(&a[16..17], "_");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_reasons_render_readably() {
|
||||
assert!(CloseReason::ClientClosed.to_string().contains("closed"));
|
||||
assert!(CloseReason::EofMidFrame { want: 20, have: 5 }
|
||||
.to_string()
|
||||
.contains("want 20"));
|
||||
assert!(CloseReason::AbsurdPayload { claimed: 99 }
|
||||
.to_string()
|
||||
.contains("99"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! # openfut-blaze-host
|
||||
//!
|
||||
//! A deliberately thin TCP host for the FIFA 17 Blaze RPC surface.
|
||||
//!
|
||||
//! ```text
|
||||
//! listener → Fire2 stream framing → per-connection Session
|
||||
//! │
|
||||
//! openfut-adapter-fifa17::dispatch
|
||||
//! │
|
||||
//! write returned frames, in order
|
||||
//! ```
|
||||
//!
|
||||
//! ## What it owns
|
||||
//!
|
||||
//! A socket, a read buffer, one `Session` per connection, and diagnostics.
|
||||
//! That is the complete list.
|
||||
//!
|
||||
//! ## What it must never acquire
|
||||
//!
|
||||
//! Coins, club state, packs, profiles, market state, UTAS logic. Those belong
|
||||
//! to OpenFUT Core, reached later through the adapter. A transport host that
|
||||
//! starts holding game state becomes a second backend, which is exactly the
|
||||
//! architecture this migration exists to avoid.
|
||||
//!
|
||||
//! ## No TLS
|
||||
//!
|
||||
//! The Blaze main port is **plaintext**. Verified against the running Python
|
||||
//! backend by sending a raw Fire2 `Util::ping` and receiving a plaintext
|
||||
//! `PingResponse`; `blaze_responder_v3b.py::blaze_handle` uses the raw socket,
|
||||
//! and only `redir_handle` wraps `ssl`. TLS belongs to the redirector phase.
|
||||
//!
|
||||
//! ## Running beside Python, never instead of it
|
||||
//!
|
||||
//! The listen port is required configuration with no default, so this cannot
|
||||
//! silently collide with the working container. See the crate README for the
|
||||
//! A/B procedure and the gate list.
|
||||
|
||||
pub mod config;
|
||||
pub mod conn;
|
||||
pub mod trace;
|
||||
|
||||
pub use config::HostConfig;
|
||||
pub use conn::CloseReason;
|
||||
|
||||
use std::io;
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use openfut_adapter_fifa17::blaze::Adapter;
|
||||
|
||||
/// The two values a live host reads from the outside world.
|
||||
///
|
||||
/// Injectable so a test can replay a recorded conversation byte-for-byte: the
|
||||
/// session key and the server clock appear inside response bodies, so with the
|
||||
/// real ones no live run could ever reproduce a fixture exactly. This is the
|
||||
/// only test seam in the crate, and it exists because the alternative is to
|
||||
/// verify transport *structurally only* and lose the byte-level guarantee.
|
||||
pub struct Hooks {
|
||||
pub session_key: Box<dyn Fn() -> String + Send + Sync>,
|
||||
pub now: Box<dyn Fn() -> i64 + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Default for Hooks {
|
||||
fn default() -> Hooks {
|
||||
Hooks {
|
||||
session_key: Box::new(conn::mint_session_key),
|
||||
now: Box::new(conn::unix_now),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Hooks {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Hooks { .. }")
|
||||
}
|
||||
}
|
||||
|
||||
/// A bound listener plus everything a connection needs.
|
||||
pub struct Server {
|
||||
pub local_addr: SocketAddr,
|
||||
listener: TcpListener,
|
||||
cfg: Arc<HostConfig>,
|
||||
adapter: Arc<Adapter>,
|
||||
tracer: Arc<trace::Tracer>,
|
||||
hooks: Arc<Hooks>,
|
||||
}
|
||||
|
||||
/// Bind without accepting, so a caller can learn the real port first (useful
|
||||
/// when binding port 0).
|
||||
pub fn bind(cfg: HostConfig, hooks: Hooks) -> io::Result<Server> {
|
||||
let listener = TcpListener::bind(cfg.listen_on())?;
|
||||
let local_addr = listener.local_addr()?;
|
||||
let tracer = Arc::new(trace::Tracer::new(cfg.trace_path.as_deref())?);
|
||||
let adapter = Arc::new(Adapter::new(cfg.adapter.clone()));
|
||||
Ok(Server {
|
||||
local_addr,
|
||||
listener,
|
||||
cfg: Arc::new(cfg),
|
||||
adapter,
|
||||
tracer,
|
||||
hooks: Arc::new(hooks),
|
||||
})
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Accept forever, one thread per connection.
|
||||
///
|
||||
/// A FIFA client opens a handful of connections, so a thread each mirrors
|
||||
/// the Python oracle and keeps the host readable; a work-stealing runtime
|
||||
/// would be weight without benefit.
|
||||
pub fn run(self) -> io::Result<()> {
|
||||
trace::log(&format!(
|
||||
"blaze-host listening on {} (advertise={}, config-bind={})",
|
||||
self.local_addr, self.cfg.adapter.endpoints.advertise, self.cfg.adapter.endpoints.bind
|
||||
));
|
||||
if self.tracer.enabled() {
|
||||
trace::log(&format!(
|
||||
"structural trace -> {}",
|
||||
self.cfg.trace_path.as_deref().unwrap_or("")
|
||||
));
|
||||
}
|
||||
|
||||
let counter = AtomicU64::new(0);
|
||||
for incoming in self.listener.incoming() {
|
||||
match incoming {
|
||||
Ok(stream) => {
|
||||
let id = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let cfg = self.cfg.clone();
|
||||
let adapter = self.adapter.clone();
|
||||
let tracer = self.tracer.clone();
|
||||
let hooks = self.hooks.clone();
|
||||
std::thread::spawn(move || {
|
||||
conn::handle(stream, id, &cfg, &adapter, &tracer, &hooks);
|
||||
});
|
||||
}
|
||||
Err(e) => trace::log(&format!("accept failed: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind and serve until the process is killed.
|
||||
pub fn serve(cfg: HostConfig) -> io::Result<()> {
|
||||
bind(cfg, Hooks::default())?.run()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Entry point for the Blaze sidecar.
|
||||
//!
|
||||
//! Configuration is entirely environmental and both critical values are
|
||||
//! required, so a misconfigured launch fails immediately and loudly rather than
|
||||
//! binding a default port next to the working Python container.
|
||||
|
||||
use openfut_blaze_host::{serve, HostConfig};
|
||||
|
||||
fn main() {
|
||||
let cfg = match HostConfig::from_env() {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
eprintln!("openfut-blaze-host: {e}\n");
|
||||
eprintln!("Required:");
|
||||
eprintln!(
|
||||
" OPENFUT_ADVERTISE address the game machine uses to reach this host"
|
||||
);
|
||||
eprintln!(
|
||||
" OPENFUT_BLAZE_HOST_PORT port to listen on (no default, to avoid colliding"
|
||||
);
|
||||
eprintln!(" with the Python backend it runs beside)");
|
||||
eprintln!("Optional:");
|
||||
eprintln!(" OPENFUT_BIND advertised-config bind, mirrors the Python");
|
||||
eprintln!(" container's value so nucleusConnect matches");
|
||||
eprintln!(" OPENFUT_BLAZE_HOST_BIND listener bind (defaults to OPENFUT_BIND)");
|
||||
eprintln!(" POW_CONTENT_HOST host:port for POW content");
|
||||
eprintln!(" POW_HOST host:port for the POW/EASFC API");
|
||||
eprintln!(" OPENFUT_BLAZE_TRACE path for the normalized structural trace");
|
||||
eprintln!(" OPENFUT_BLAZE_IDLE_TIMEOUT seconds, default 300");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = serve(cfg) {
|
||||
eprintln!("openfut-blaze-host: fatal: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! Diagnostics and the normalized structural trace.
|
||||
//!
|
||||
//! Two outputs with different jobs:
|
||||
//!
|
||||
//! * **The log** is for a human watching a live run. Free-form, timestamped.
|
||||
//! * **The trace** is for `diff`. One line per frame, deterministic, with every
|
||||
//! volatile value masked so a Python session and a Rust session of the same
|
||||
//! conversation produce identical text.
|
||||
//!
|
||||
//! Masking is what makes the trace useful. A session key, a server timestamp
|
||||
//! and an auth token differ on every run by design, so comparing raw bytes
|
||||
//! across two live servers can only ever fail. Their *presence, tag, type and
|
||||
//! length* are what must match, and that is what the trace records.
|
||||
//!
|
||||
//! Nothing here writes a credential: masked values are replaced before they
|
||||
//! reach the line, not truncated after.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Write as _;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use openfut_adapter_fifa17::blaze::ids;
|
||||
use openfut_protocol_blaze::fire2::{Frame, Header, MsgType};
|
||||
use openfut_protocol_blaze::heat2::{self, Struct, Value};
|
||||
|
||||
/// Tags whose values legitimately differ between two runs of the same
|
||||
/// conversation. Masked in the trace so it stays diffable.
|
||||
///
|
||||
/// Deliberately a denylist of *known-volatile* tags rather than an allowlist:
|
||||
/// a new field appearing in a response should show up as a diff, not be hidden.
|
||||
const VOLATILE_TAGS: &[&str] = &[
|
||||
"KEY", // session key
|
||||
"AUTH", // auth token (also a credential-shaped value)
|
||||
"STIM", // server time
|
||||
"LLOG", "LAST", "LADT", "LATH", // login / auth timestamps
|
||||
"GDAY", "DTCR", // grant/create dates (stable today, timestamp-shaped)
|
||||
"SESS", // telemetry session echo (string form)
|
||||
];
|
||||
|
||||
fn now_millis() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn log(msg: &str) {
|
||||
let ms = now_millis();
|
||||
eprintln!("[{}.{:03}] {msg}", ms / 1000, ms % 1000);
|
||||
}
|
||||
|
||||
/// Describe a frame header the way the Python responder logs it, so the two
|
||||
/// logs can be read side by side.
|
||||
pub fn describe(header: &Header) -> String {
|
||||
let is_notify = header.msg_type == MsgType::Notification;
|
||||
format!(
|
||||
"{} msgType={} msgNum={} userIdx={} opts=0x{:02x} meta={}B payload={}B",
|
||||
ids::describe(header.component, header.command, is_notify),
|
||||
header.msg_type.name(),
|
||||
header.msg_num,
|
||||
header.user_index,
|
||||
header.options,
|
||||
header.metadata_len,
|
||||
header.payload_len
|
||||
)
|
||||
}
|
||||
|
||||
/// A deterministic, volatile-masked rendering of one frame.
|
||||
pub struct Tracer {
|
||||
sink: Option<Mutex<std::fs::File>>,
|
||||
}
|
||||
|
||||
impl Tracer {
|
||||
pub fn new(path: Option<&str>) -> std::io::Result<Tracer> {
|
||||
Ok(Tracer {
|
||||
sink: match path {
|
||||
Some(p) => Some(Mutex::new(std::fs::File::create(p)?)),
|
||||
None => None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn disabled() -> Tracer {
|
||||
Tracer { sink: None }
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.sink.is_some()
|
||||
}
|
||||
|
||||
pub fn write_line(&self, line: &str) {
|
||||
if let Some(sink) = &self.sink {
|
||||
if let Ok(mut f) = sink.lock() {
|
||||
let _ = writeln!(f, "{line}");
|
||||
let _ = f.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frame(&self, conn: u64, direction: &str, index: &str, frame: &Frame) {
|
||||
if !self.enabled() {
|
||||
return;
|
||||
}
|
||||
self.write_line(&trace_line(conn, direction, index, frame));
|
||||
}
|
||||
}
|
||||
|
||||
/// `conn-0001 TX 1.0 Authentication::login REPLY num=12 uidx=0 payload=201 tdf=<digest>`
|
||||
pub fn trace_line(conn: u64, direction: &str, index: &str, frame: &Frame) -> String {
|
||||
let h = &frame.header;
|
||||
let is_notify = h.msg_type == MsgType::Notification;
|
||||
let body = if frame.payload.is_empty() {
|
||||
"(empty)".to_string()
|
||||
} else {
|
||||
match heat2::decode(&frame.payload) {
|
||||
Ok(s) => format!("{:016x}", structure_digest(&s)),
|
||||
Err(_) => "DECODE-FAILED".to_string(),
|
||||
}
|
||||
};
|
||||
format!(
|
||||
"conn-{conn:04} {direction} {index} {} {} num={} uidx={} payload={} tdf={}",
|
||||
ids::describe(h.component, h.command, is_notify),
|
||||
h.msg_type.name(),
|
||||
h.msg_num,
|
||||
h.user_index,
|
||||
frame.payload.len(),
|
||||
body
|
||||
)
|
||||
}
|
||||
|
||||
/// A masked, human-readable dump of a decoded body. Used by the probe's
|
||||
/// verbose mode when a digest mismatch needs explaining.
|
||||
pub fn masked_dump(s: &Struct) -> String {
|
||||
let mut out = String::new();
|
||||
write_masked(s, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_masked(s: &Struct, depth: usize, out: &mut String) {
|
||||
// Sort so two implementations that differ only in member order still
|
||||
// produce identical text. (They should not — the encoder sorts — but the
|
||||
// trace must not be the thing that hides it if they do.)
|
||||
let mut fields: Vec<_> = s.iter().collect();
|
||||
fields.sort_by_key(|(t, _)| *t);
|
||||
|
||||
for (tag, value) in fields {
|
||||
let pad = " ".repeat(depth);
|
||||
let label = tag.to_label();
|
||||
let volatile = VOLATILE_TAGS.contains(&label.as_str());
|
||||
match value {
|
||||
Value::Struct(inner) => {
|
||||
let _ = writeln!(out, "{pad}{label} (struct)");
|
||||
write_masked(inner, depth + 1, out);
|
||||
}
|
||||
Value::List { elem, items } => {
|
||||
let _ = writeln!(out, "{pad}{label} (list[{}] x{})", elem.name(), items.len());
|
||||
for it in items {
|
||||
if let Value::Struct(inner) = it {
|
||||
write_masked(inner, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Map { key, val, entries } => {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{pad}{label} (map[{}->{}] x{})",
|
||||
key.name(),
|
||||
val.name(),
|
||||
entries.len()
|
||||
);
|
||||
}
|
||||
other => {
|
||||
let rendered = if volatile {
|
||||
masked_scalar(other)
|
||||
} else {
|
||||
render_scalar(other)
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{pad}{label} ({}) = {rendered}",
|
||||
other.type_id().name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the value but keep its shape, so a length or type change still diffs.
|
||||
fn masked_scalar(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => format!("<masked:str:{}>", s.len()),
|
||||
Value::Int(_) => "<masked:int>".to_string(),
|
||||
Value::Blob(b) => format!("<masked:blob:{}>", b.len()),
|
||||
other => format!("<masked:{}>", other.type_id().name()),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_scalar(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Int(n) => n.to_string(),
|
||||
Value::String(s) => format!("{s:?}"),
|
||||
Value::Blob(b) => format!("<blob:{}>", b.len()),
|
||||
Value::VarList(items) => format!("{items:?}"),
|
||||
Value::Float(f) => format!("{f}"),
|
||||
Value::ObjType { component, ty } => format!("({component},{ty})"),
|
||||
Value::ObjId { component, ty, id } => format!("({component},{ty},{id})"),
|
||||
other => other.type_id().name().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a over the masked dump: a stable structural fingerprint.
|
||||
///
|
||||
/// Hand-rolled because it is eight lines and pulling a hashing crate into a
|
||||
/// diagnostics path would be the heavier choice. Not a security hash and never
|
||||
/// used as one.
|
||||
pub fn structure_digest(s: &Struct) -> u64 {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in masked_dump(s).as_bytes() {
|
||||
hash ^= *byte as u64;
|
||||
hash = hash.wrapping_mul(0x1000_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frame_with(body: Struct) -> Frame {
|
||||
Frame::new(0x0001, 0x000A, 12, MsgType::Reply, heat2::encode(&body))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volatile_values_are_masked_but_their_shape_survives() {
|
||||
let a = Struct::new()
|
||||
.with("KEY", Value::String("aaaaaaaa".into()))
|
||||
.with("UID", Value::Int(33068179));
|
||||
let b = Struct::new()
|
||||
.with("KEY", Value::String("bbbbbbbb".into()))
|
||||
.with("UID", Value::Int(33068179));
|
||||
// Different session keys of the same length: identical trace.
|
||||
assert_eq!(structure_digest(&a), structure_digest(&b));
|
||||
|
||||
// A different length is a real change and must still diff.
|
||||
let c = Struct::new()
|
||||
.with("KEY", Value::String("short".into()))
|
||||
.with("UID", Value::Int(33068179));
|
||||
assert_ne!(structure_digest(&a), structure_digest(&c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_volatile_values_are_not_masked() {
|
||||
let a = Struct::new().with("UID", Value::Int(1));
|
||||
let b = Struct::new().with("UID", Value::Int(2));
|
||||
assert_ne!(structure_digest(&a), structure_digest(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_field_shows_up_as_a_difference() {
|
||||
// The denylist must not hide additions.
|
||||
let a = Struct::new().with("UID", Value::Int(1));
|
||||
let b = Struct::new()
|
||||
.with("UID", Value::Int(1))
|
||||
.with("NEWF", Value::Int(0));
|
||||
assert_ne!(structure_digest(&a), structure_digest(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_line_is_stable_across_sessions() {
|
||||
let a = frame_with(Struct::new().with("KEY", Value::String("k1-aaaaaa".into())));
|
||||
let b = frame_with(Struct::new().with("KEY", Value::String("k2-bbbbbb".into())));
|
||||
assert_eq!(
|
||||
trace_line(1, "TX", "1.0", &a),
|
||||
trace_line(1, "TX", "1.0", &b)
|
||||
);
|
||||
assert!(trace_line(1, "TX", "1.0", &a).contains("Authentication::login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_masked_value_leaks_into_the_line() {
|
||||
let secret = "SUPER-SECRET-SESSION-KEY";
|
||||
let f = frame_with(Struct::new().with("KEY", Value::String(secret.into())));
|
||||
let line = trace_line(1, "TX", "1.0", &f);
|
||||
assert!(!line.contains(secret));
|
||||
assert!(!masked_dump(&heat2::decode(&f.payload).unwrap()).contains(secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undecodable_body_is_reported_not_hidden() {
|
||||
let f = Frame::new(9, 7, 1, MsgType::Reply, vec![0xFF; 8]);
|
||||
assert!(trace_line(1, "TX", "1.0", &f).contains("DECODE-FAILED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_payloads_are_distinguishable_from_failures() {
|
||||
let f = Frame::new(9, 7, 1, MsgType::Reply, vec![]);
|
||||
assert!(trace_line(1, "TX", "1.0", &f).contains("(empty)"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user