a9eb54ae9c
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>
301 lines
10 KiB
Rust
301 lines
10 KiB
Rust
//! 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)"));
|
|
}
|
|
}
|