feat(companions): port the launcher's two Python services to Rust
The launcher spawned `python3 lsx_responder_v2.py` and `python3 autopatch.py`. Both are
now Rust workspace crates, and the launcher spawns the binaries (gitlink 1cd4f18).
openfut-lsx (2244 lines, 57 tests) — EA Origin LSX emulator on loopback 4216.
Dependency-light on purpose: `aes` for the one security-shaped primitive, parking_lot
per the project lock rule. AES-128-ECB is the whole cipher requirement, so the
surrounding framing (PKCS7, lowercase hex, NUL-termination) stays explicit and separate
because it is protocol, not cryptography.
openfut-autopatch (43 tests) — ProtoSSL cert gates plus the CardsDLL store patches,
applied over /proc/<pid>/mem. Deliberately dependency-free: a tool that writes another
process's memory should be auditable end to end without a dependency tree. std has no
getuid and no local-time formatting, so it carries a small TZif reader rather than
pulling in chrono to reproduce Python's strftime('%H:%M:%S').
The Python remains in fifa17-recon/tools. It is NOT dead: the docker entrypoint,
client_arm.sh, the runbooks and test_autopatch_guard.py still use it. Only the
launcher's dependency on Python is gone, which is what was asked for; deleting the
recon toolchain's implementation would have broken unrelated workflows.
VERIFICATION — the ports are checked against the Python, not against themselves:
* Crypto parity across THREE implementations. The Rust tests assert the Rust's own
constants, which proves consistency, not parity, and the Python cannot run here
(pycryptodome absent) with the client host unreachable. So the LCG and key derivation
were transcribed from the Python and run as plain arithmetic, and every AES value came
from the openssl CLI. All agree: msvcr_rand(7)==61, _TAIL_CONST
954f64f2e4e86e9eee82d20216684899, the 96-hex emu challenge shape, the derived session
key 6a9da3e78615153cc2f10eec25ae6382, the framing rule at both boundaries (an aligned
payload gains a whole block), and the port's pinned 4-block login-frame ciphertext.
* LSX end to end on the real port. 4216 here is a docker forward into the production
netns, so the smoke test runs under `unshare -n` — the real binary on the port the
client actually dials, with no port-override hack and no risk to production. A
hand-written client read the unprompted <Challenge>, completed the handshake, and
decrypted the GetProfileResponse (PersonaId 33068179, Persona CAGE) with a session key
derived INDEPENDENTLY of the Rust, then observed the Login pushes across all three
candidate senders.
* autopatch behaviourally. The startup banner, the --launcher-pid watchdog exiting with
the exact Python message, dual stdout+logfile output, and a missing value rejected
with Python's own "invalid --launcher-pid". The subagent additionally cross-checked
every constant by executing the Python module and drove the binary against a synthetic
client (correct comm, a CardsDLL mapping, gates mmapped at their absolute VAs),
confirming all eleven patches byte-exact in table order.
* The `[store-guard] verified capability …` line is byte-identical to openfut-launcher's
own parser fixture, so backend capability registration still works.
Workspace builds; openfut-lsx 57, openfut-autopatch 43, openfut-launcher 74 tests green.
This commit is contained in:
Generated
+42
@@ -114,6 +114,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -810,6 +821,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -2307,6 +2328,15 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.1"
|
||||
@@ -3118,6 +3148,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-autopatch"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-blaze-host"
|
||||
version = "0.1.0"
|
||||
@@ -3238,6 +3272,14 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-lsx"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-protocol-blaze"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -15,6 +15,9 @@ members = [
|
||||
"openfut-import-fifa17",
|
||||
"openfut-bridge",
|
||||
"openfut-launcher",
|
||||
# The two companion services the launcher used to shell out to Python for.
|
||||
"openfut-lsx",
|
||||
"openfut-autopatch",
|
||||
"fifa-blaze/crates/blaze-proto",
|
||||
"fifa-blaze/crates/server",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "openfut-autopatch"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Applies the FIFA 17 ProtoSSL cert and CardsDLL store patches to a running client"
|
||||
publish = false
|
||||
|
||||
# Deliberately dependency-free. Everything this needs is in std: /proc scanning for
|
||||
# the client pid, and positioned reads/writes against /proc/<pid>/mem via
|
||||
# std::os::unix::fs::FileExt. A patcher that edits another process's memory should be
|
||||
# auditable end to end without pulling in a dependency tree.
|
||||
[dependencies]
|
||||
@@ -0,0 +1,477 @@
|
||||
//! FIFA 17 client patcher — the ProtoSSL cert gates and the CardsDLL FUT store
|
||||
//! patches, applied to a running `FIFA17.exe` through `/proc/<pid>/mem`.
|
||||
//!
|
||||
//! Port of `fifa17-recon/tools/autopatch.py`; that file is the specification and
|
||||
//! every address, byte pattern and log line here is reproduced from it verbatim.
|
||||
//! The store patches are re-applied on every tick because the game rewrites
|
||||
//! those sites; the cert gates are applied once per pid.
|
||||
//!
|
||||
//! This module holds the constants, the two pure decision functions and the
|
||||
//! parsers. The enforcement passes live in [`patch`], process access in
|
||||
//! [`procmem`], timestamping in [`localtime`], and the watch loop in `main.rs`.
|
||||
|
||||
pub mod localtime;
|
||||
pub mod logging;
|
||||
pub mod patch;
|
||||
pub mod procmem;
|
||||
|
||||
use std::fmt;
|
||||
|
||||
pub use logging::Logger;
|
||||
|
||||
/// `/proc/<pid>/comm` of the client we patch (exact match after trimming).
|
||||
pub const CLIENT_COMM: &str = "FIFA17.exe";
|
||||
|
||||
/// Substring identifying the CardsDLL mapping in `/proc/<pid>/maps`.
|
||||
pub const CARDSDLL_MARKER: &str = "CardsDLL";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProtoSSL certificate gates (absolute VAs in the unpacked FIFA17.exe image;
|
||||
// present only once the packer has mapped the real code, hence the watch loop).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const GATE2: u64 = 0x1461361b0;
|
||||
pub const GATE2_ORIG: [u8; 3] = [0x48, 0x89, 0x5c];
|
||||
pub const GATE2_PATCH: [u8; 3] = [0x31, 0xc0, 0xc3];
|
||||
|
||||
pub const GATE1: u64 = 0x146132548;
|
||||
pub const GATE1_ORIG: [u8; 6] = [0x0f, 0x85, 0x76, 0x01, 0x00, 0x00];
|
||||
pub const GATE1_PATCH: [u8; 6] = [0x90; 6];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FUT store patches, expressed against the CardsDLL preferred image base and
|
||||
// relocated to the live mapping base at runtime.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `mov eax, 1; ret` — force a predicate true.
|
||||
pub const RET_TRUE: [u8; 6] = [0xb8, 0x01, 0x00, 0x00, 0x00, 0xc3];
|
||||
/// Two `nop`s.
|
||||
pub const NOP2: [u8; 2] = [0x90, 0x90];
|
||||
/// `jmp +0x3f`, the one non-uniform store patch (site `0x180017543`).
|
||||
pub const SHORT_JMP_3F: [u8; 2] = [0xeb, 0x3f];
|
||||
/// CardsDLL's preferred image base; live address = `cbase + (va - IMG_BASE)`.
|
||||
pub const IMG_BASE: u64 = 0x180000000;
|
||||
|
||||
/// Unconditional store patches, in the Python's insertion order — the order
|
||||
/// decides the order of the `ENFORCED store patch` log lines.
|
||||
///
|
||||
/// The Python carries no per-site rationale for these eight sites, so none is
|
||||
/// invented here; the addresses and bytes are reproduced verbatim.
|
||||
pub const STORE_PATCHES: [(u64, &[u8]); 8] = [
|
||||
(0x1800f7fb0, &RET_TRUE),
|
||||
(0x1800fb850, &RET_TRUE),
|
||||
(0x180100500, &RET_TRUE),
|
||||
(0x180013cf0, &RET_TRUE),
|
||||
(0x180017543, &SHORT_JMP_3F),
|
||||
(0x180017487, &NOP2),
|
||||
(0x180017490, &NOP2),
|
||||
(0x1800175aa, &NOP2),
|
||||
];
|
||||
|
||||
/// Store resolver crash-guard for the empty "My Packs" case (bug 6c; PROVEN R1 on the
|
||||
/// tested FIFA 17 build -- see docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV and
|
||||
/// docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md).
|
||||
///
|
||||
/// When no `mypacks` group exists, FIFA's Store resolver receives category id -1. CardsDLL
|
||||
/// FUN_1800147f0 @ 0x180014858 is `JNZ 0x14869` (75 0f): the original treats every non-zero
|
||||
/// category (including -1) as resolvable, calls FUN_180014420, gets NULL, and crashes at the
|
||||
/// \[NULL+0x48\] deref in FUN_1800147f0 (0x180014882). Changing JNZ->JG (7f 0f) preserves
|
||||
/// positive-category resolution (EDI>0 branch) while routing zero/negative categories through
|
||||
/// the existing Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.
|
||||
///
|
||||
/// CAVEAT: this guards the category SIGN only. It does NOT protect a stale *positive* invalid
|
||||
/// ordinal produced by changing the Store group topology (sentinel-present <-> sentinel-absent)
|
||||
/// DURING one running FIFA process -- that reproduced the same crash in the confounded run F3.
|
||||
/// The empty-My-Packs representation MUST stay stable for a FIFA session (see the SESSION-STABLE
|
||||
/// invariant in the client-fix plan).
|
||||
///
|
||||
/// Orig-verified / fail-closed: applied only when the live bytes are the known original (75 0f);
|
||||
/// already-patched (7f 0f) is a no-op; anything else is logged and SKIPPED (never blindly
|
||||
/// overwritten), so an unrecognised CardsDLL build is not patched.
|
||||
///
|
||||
/// Tuple layout: `(va, orig, patch)`. `JNZ 0x14869` -> `JG 0x14869`.
|
||||
pub const STORE_PATCHES_GUARDED: [(u64, &[u8], &[u8]); 1] =
|
||||
[(0x180014858, &[0x75, 0x0f], &[0x7f, 0x0f])];
|
||||
|
||||
/// Capability advertised to the launcher/backend once the resolver guard is VERIFIED
|
||||
/// live in a specific FIFA process (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md #3/#4).
|
||||
pub const EMPTY_MYPACKS_RESOLVER_CAPABILITY: &str = "fifa17.empty_mypacks_resolver";
|
||||
pub const EMPTY_MYPACKS_RESOLVER_VERSION: u32 = 1;
|
||||
|
||||
/// The guarded site whose verified enforcement backs the capability above.
|
||||
pub const RESOLVER_GUARD_VA: u64 = 0x180014858;
|
||||
|
||||
/// Longest patch payload, so the watch loop can compare live bytes on the stack.
|
||||
pub const MAX_PATCH_LEN: usize = RET_TRUE.len();
|
||||
|
||||
// Compile-time proof that the watch loop's stack buffers are large enough, so no
|
||||
// slicing panic is reachable from the patch tables.
|
||||
const _: () = {
|
||||
let mut i = 0;
|
||||
while i < STORE_PATCHES.len() {
|
||||
assert!(STORE_PATCHES[i].1.len() <= MAX_PATCH_LEN);
|
||||
i += 1;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < STORE_PATCHES_GUARDED.len() {
|
||||
assert!(STORE_PATCHES_GUARDED[i].1.len() <= MAX_PATCH_LEN);
|
||||
assert!(STORE_PATCHES_GUARDED[i].2.len() <= MAX_PATCH_LEN);
|
||||
assert!(STORE_PATCHES_GUARDED[i].1.len() == STORE_PATCHES_GUARDED[i].2.len());
|
||||
i += 1;
|
||||
}
|
||||
};
|
||||
|
||||
/// Fail-closed decision for a guarded byte patch (see [`STORE_PATCHES_GUARDED`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GuardedAction {
|
||||
/// Live bytes are already the patch; nothing to write.
|
||||
Noop,
|
||||
/// Live bytes are the known original; safe to apply.
|
||||
Patch,
|
||||
/// Unrecognised CardsDLL build — never blindly overwritten.
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GuardState {
|
||||
/// CardsDLL not mapped / guard not yet evaluated.
|
||||
NotAttempted,
|
||||
/// Live bytes == patch after enforcement (patch or noop).
|
||||
Verified,
|
||||
/// Neither original nor patched ([`GuardedAction::Skip`]).
|
||||
UnsupportedBuild,
|
||||
/// The `/proc/<pid>/mem` write failed.
|
||||
WriteFailed,
|
||||
/// Post-write re-read != patch.
|
||||
VerifyFailed,
|
||||
}
|
||||
|
||||
impl GuardState {
|
||||
/// Wire spelling used in the `[store-guard] guard status=…` line the launcher reads.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GuardState::NotAttempted => "NOT_ATTEMPTED",
|
||||
GuardState::Verified => "VERIFIED",
|
||||
GuardState::UnsupportedBuild => "UNSUPPORTED_BUILD",
|
||||
GuardState::WriteFailed => "WRITE_FAILED",
|
||||
GuardState::VerifyFailed => "VERIFY_FAILED",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GuardState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fail-closed decision for a guarded byte patch.
|
||||
///
|
||||
/// [`GuardedAction::Noop`] when the live bytes are already patched,
|
||||
/// [`GuardedAction::Patch`] when they are the known original (safe to apply), or
|
||||
/// [`GuardedAction::Skip`] for anything else — an unrecognised CardsDLL build
|
||||
/// that must never be blindly overwritten.
|
||||
pub fn guarded_action(cur: &[u8], orig: &[u8], patch: &[u8]) -> GuardedAction {
|
||||
if cur == patch {
|
||||
return GuardedAction::Noop;
|
||||
}
|
||||
if cur == orig {
|
||||
return GuardedAction::Patch;
|
||||
}
|
||||
GuardedAction::Skip
|
||||
}
|
||||
|
||||
/// Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure).
|
||||
///
|
||||
/// Mirrors [`guarded_action`]'s decision, extended with post-write verification so the
|
||||
/// caller advertises the capability only on VERIFIED. No `/proc` access — unit-testable.
|
||||
///
|
||||
/// - `cur_before == patch` -> VERIFIED (already patched; [`GuardedAction::Noop`])
|
||||
/// - `cur_before == orig` -> WRITE_FAILED if the write raised, else VERIFIED when the
|
||||
/// re-read is patch, else VERIFY_FAILED ([`GuardedAction::Patch`])
|
||||
/// - otherwise -> UNSUPPORTED_BUILD ([`GuardedAction::Skip`])
|
||||
///
|
||||
/// [`GuardState::NotAttempted`] is never returned: it is the state a pid carries before
|
||||
/// the guard is evaluated at all (CardsDLL not mapped yet), and this function is only
|
||||
/// reached once live bytes have been read.
|
||||
pub fn guard_state_after(
|
||||
cur_before: &[u8],
|
||||
orig: &[u8],
|
||||
patch: &[u8],
|
||||
wrote_ok: bool,
|
||||
cur_after: &[u8],
|
||||
) -> GuardState {
|
||||
if cur_before == patch {
|
||||
return GuardState::Verified;
|
||||
}
|
||||
if cur_before == orig {
|
||||
if !wrote_ok {
|
||||
return GuardState::WriteFailed;
|
||||
}
|
||||
if cur_after == patch {
|
||||
return GuardState::Verified;
|
||||
}
|
||||
return GuardState::VerifyFailed;
|
||||
}
|
||||
GuardState::UnsupportedBuild
|
||||
}
|
||||
|
||||
/// Live address of an image-relative patch site inside the mapped CardsDLL.
|
||||
pub fn live_addr(cbase: u64, va: u64) -> u64 {
|
||||
cbase + (va - IMG_BASE)
|
||||
}
|
||||
|
||||
/// Lowercase, unseparated hex — the spelling of `bytes.hex()` in the SKIP log line.
|
||||
pub fn hex(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
// Two nibbles, no separator, no allocation per byte.
|
||||
const DIGITS: &[u8; 16] = b"0123456789abcdef";
|
||||
out.push(DIGITS[(b >> 4) as usize] as char);
|
||||
out.push(DIGITS[(b & 0x0f) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when a `/proc/<pid>/comm` body names the FIFA 17 client.
|
||||
pub fn is_client_comm(comm: &str) -> bool {
|
||||
comm.trim() == CLIENT_COMM
|
||||
}
|
||||
|
||||
/// Pid from a `/proc` directory entry name.
|
||||
///
|
||||
/// The Python globs `/proc/[0-9]*` and then `int()`s the name inside a bare
|
||||
/// `except`, so a name has to start with a digit *and* be entirely numeric.
|
||||
pub fn pid_from_proc_entry(name: &str) -> Option<u32> {
|
||||
if !name.as_bytes().first().is_some_and(u8::is_ascii_digit) {
|
||||
return None;
|
||||
}
|
||||
name.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
/// Base address of the first `CardsDLL` mapping in a `/proc/<pid>/maps` body.
|
||||
///
|
||||
/// Only the first matching line is considered, and a line whose base does not
|
||||
/// parse yields `None` rather than falling through to the next mapping — the
|
||||
/// Python's `int(...)` raises inside the `try` that returns `None`.
|
||||
pub fn parse_cardsdll_base(maps: &str) -> Option<u64> {
|
||||
let line = maps.lines().find(|line| line.contains(CARDSDLL_MARKER))?;
|
||||
u64::from_str_radix(line.split('-').next()?, 16).ok()
|
||||
}
|
||||
|
||||
/// `--launcher-pid <pid>` out of the argument list.
|
||||
///
|
||||
/// `Ok(None)` when the flag is absent, `Err(())` when it is present with a
|
||||
/// missing or non-numeric value (the Python raises `SystemExit`). The value is
|
||||
/// kept signed and un-clamped so the liveness check behaves exactly like the
|
||||
/// Python's `os.path.exists(f"/proc/{launcher_pid}")` for odd inputs.
|
||||
#[allow(clippy::result_unit_err)]
|
||||
pub fn parse_launcher_pid<I, S>(args: I) -> Result<Option<i64>, ()>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let args: Vec<S> = args.into_iter().collect();
|
||||
let Some(idx) = args.iter().position(|a| a.as_ref() == "--launcher-pid") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value = args.get(idx + 1).ok_or(())?;
|
||||
value.as_ref().trim().parse::<i64>().map(Some).map_err(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ORIG: &[u8] = &[0x75, 0x0f];
|
||||
const PATCH: &[u8] = &[0x7f, 0x0f];
|
||||
|
||||
#[test]
|
||||
fn constants_match_the_python_spec() {
|
||||
assert_eq!(GATE2, 0x1461361b0);
|
||||
assert_eq!(GATE2_ORIG, [0x48, 0x89, 0x5c]);
|
||||
assert_eq!(GATE2_PATCH, [0x31, 0xc0, 0xc3]);
|
||||
assert_eq!(GATE1, 0x146132548);
|
||||
assert_eq!(GATE1_ORIG, [0x0f, 0x85, 0x76, 0x01, 0x00, 0x00]);
|
||||
assert_eq!(GATE1_PATCH, [0x90, 0x90, 0x90, 0x90, 0x90, 0x90]);
|
||||
assert_eq!(IMG_BASE, 0x180000000);
|
||||
assert_eq!(hex(&RET_TRUE), "b801000000c3");
|
||||
assert_eq!(hex(&NOP2), "9090");
|
||||
|
||||
// Site order is part of the log contract, so assert the whole table.
|
||||
let sites: Vec<(u64, String)> = STORE_PATCHES
|
||||
.iter()
|
||||
.map(|(va, data)| (*va, hex(data)))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sites,
|
||||
vec![
|
||||
(0x1800f7fb0, "b801000000c3".to_string()),
|
||||
(0x1800fb850, "b801000000c3".to_string()),
|
||||
(0x180100500, "b801000000c3".to_string()),
|
||||
(0x180013cf0, "b801000000c3".to_string()),
|
||||
(0x180017543, "eb3f".to_string()),
|
||||
(0x180017487, "9090".to_string()),
|
||||
(0x180017490, "9090".to_string()),
|
||||
(0x1800175aa, "9090".to_string()),
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(STORE_PATCHES_GUARDED.len(), 1);
|
||||
let (va, orig, patch) = STORE_PATCHES_GUARDED[0];
|
||||
assert_eq!(va, 0x180014858);
|
||||
assert_eq!(hex(orig), "750f");
|
||||
assert_eq!(hex(patch), "7f0f");
|
||||
assert_eq!(RESOLVER_GUARD_VA, va);
|
||||
assert_eq!(EMPTY_MYPACKS_RESOLVER_CAPABILITY, "fifa17.empty_mypacks_resolver");
|
||||
assert_eq!(EMPTY_MYPACKS_RESOLVER_VERSION, 1);
|
||||
assert_eq!(MAX_PATCH_LEN, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guarded_action_noops_when_already_patched() {
|
||||
assert_eq!(guarded_action(PATCH, ORIG, PATCH), GuardedAction::Noop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guarded_action_patches_the_known_original() {
|
||||
assert_eq!(guarded_action(ORIG, ORIG, PATCH), GuardedAction::Patch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guarded_action_skips_an_unrecognised_build() {
|
||||
// Fail-closed: an unknown CardsDLL build is never overwritten.
|
||||
assert_eq!(guarded_action(&[0x74, 0x0f], ORIG, PATCH), GuardedAction::Skip);
|
||||
assert_eq!(guarded_action(&[], ORIG, PATCH), GuardedAction::Skip);
|
||||
assert_eq!(guarded_action(&[0x75], ORIG, PATCH), GuardedAction::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_state_verified_when_already_patched() {
|
||||
// wrote_ok / cur_after are irrelevant on this branch.
|
||||
assert_eq!(
|
||||
guard_state_after(PATCH, ORIG, PATCH, false, &[]),
|
||||
GuardState::Verified
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_state_verified_after_a_successful_write() {
|
||||
assert_eq!(
|
||||
guard_state_after(ORIG, ORIG, PATCH, true, PATCH),
|
||||
GuardState::Verified
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_state_write_failed() {
|
||||
assert_eq!(
|
||||
guard_state_after(ORIG, ORIG, PATCH, false, ORIG),
|
||||
GuardState::WriteFailed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_state_verify_failed() {
|
||||
// Write reported success but the re-read still shows the original …
|
||||
assert_eq!(
|
||||
guard_state_after(ORIG, ORIG, PATCH, true, ORIG),
|
||||
GuardState::VerifyFailed
|
||||
);
|
||||
// … or could not be re-read at all (the Python's `cur_after = b""`).
|
||||
assert_eq!(
|
||||
guard_state_after(ORIG, ORIG, PATCH, true, &[]),
|
||||
GuardState::VerifyFailed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_state_unsupported_build() {
|
||||
assert_eq!(
|
||||
guard_state_after(&[0x74, 0x0f], ORIG, PATCH, true, PATCH),
|
||||
GuardState::UnsupportedBuild
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_state_spellings_are_the_launcher_contract() {
|
||||
assert_eq!(GuardState::NotAttempted.to_string(), "NOT_ATTEMPTED");
|
||||
assert_eq!(GuardState::Verified.to_string(), "VERIFIED");
|
||||
assert_eq!(GuardState::UnsupportedBuild.to_string(), "UNSUPPORTED_BUILD");
|
||||
assert_eq!(GuardState::WriteFailed.to_string(), "WRITE_FAILED");
|
||||
assert_eq!(GuardState::VerifyFailed.to_string(), "VERIFY_FAILED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_addr_relocates_against_the_image_base() {
|
||||
assert_eq!(live_addr(0x7f0000000000, 0x180014858), 0x7f0000014858);
|
||||
assert_eq!(live_addr(IMG_BASE, RESOLVER_GUARD_VA), RESOLVER_GUARD_VA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_is_lowercase_and_unseparated() {
|
||||
assert_eq!(hex(&[0x00, 0x0f, 0xa5, 0xff]), "000fa5ff");
|
||||
assert_eq!(hex(&[]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comm_matches_only_the_client() {
|
||||
assert!(is_client_comm("FIFA17.exe\n"));
|
||||
assert!(is_client_comm("FIFA17.exe"));
|
||||
assert!(!is_client_comm("fifa17.exe\n"));
|
||||
assert!(!is_client_comm("FIFA17.exe.bak\n"));
|
||||
assert!(!is_client_comm("wineserver\n"));
|
||||
assert!(!is_client_comm(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proc_entry_names_yield_only_numeric_pids() {
|
||||
assert_eq!(pid_from_proc_entry("1"), Some(1));
|
||||
assert_eq!(pid_from_proc_entry("41234"), Some(41234));
|
||||
assert_eq!(pid_from_proc_entry("self"), None);
|
||||
assert_eq!(pid_from_proc_entry("1abc"), None);
|
||||
assert_eq!(pid_from_proc_entry("+7"), None);
|
||||
assert_eq!(pid_from_proc_entry(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardsdll_base_is_the_first_matching_mapping() {
|
||||
let maps = concat!(
|
||||
"140000000-140001000 r--p 00000000 08:02 12 /home/u/FIFA17.exe\n",
|
||||
"7f2a11c00000-7f2a11c9c000 r-xp 00000000 08:02 44 /home/u/CardsDLL_Win64_retail.dll\n",
|
||||
"7f2a12000000-7f2a12001000 r--p 00000000 08:02 45 /home/u/CardsDLL_second.dll\n",
|
||||
);
|
||||
assert_eq!(parse_cardsdll_base(maps), Some(0x7f2a11c00000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardsdll_base_absent_or_unparsable() {
|
||||
assert_eq!(parse_cardsdll_base(""), None);
|
||||
let no_dll = "140000000-140001000 r--p 00000000 08:02 12 /home/u/FIFA17.exe\n";
|
||||
assert_eq!(parse_cardsdll_base(no_dll), None);
|
||||
// A CardsDLL line whose base is not hex: fail, do not fall through.
|
||||
let broken = concat!(
|
||||
"zzzz-140001000 r--p 00000000 08:02 12 /home/u/CardsDLL.dll\n",
|
||||
"7f2a11c00000-7f2a11c9c000 r-xp 0 08:02 44 /home/u/CardsDLL.dll\n",
|
||||
);
|
||||
assert_eq!(parse_cardsdll_base(broken), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_pid_parsing() {
|
||||
assert_eq!(parse_launcher_pid(Vec::<String>::new()), Ok(None));
|
||||
assert_eq!(parse_launcher_pid(["--other", "3"]), Ok(None));
|
||||
assert_eq!(parse_launcher_pid(["--launcher-pid", "4242"]), Ok(Some(4242)));
|
||||
assert_eq!(
|
||||
parse_launcher_pid(["-x", "--launcher-pid", "7", "--launcher-pid", "9"]),
|
||||
Ok(Some(7))
|
||||
);
|
||||
// Falsy in the Python (`if launcher_pid and ...`): parsed, never watched.
|
||||
assert_eq!(parse_launcher_pid(["--launcher-pid", "0"]), Ok(Some(0)));
|
||||
assert_eq!(parse_launcher_pid(["--launcher-pid", "-5"]), Ok(Some(-5)));
|
||||
assert_eq!(parse_launcher_pid(["--launcher-pid"]), Err(()));
|
||||
assert_eq!(parse_launcher_pid(["--launcher-pid", "abc"]), Err(()));
|
||||
assert_eq!(parse_launcher_pid(["--launcher-pid", ""]), Err(()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Wall-clock `HH:MM:SS` in local time, from std alone.
|
||||
//!
|
||||
//! The Python logs `time.strftime('%H:%M:%S')`, i.e. *local* time, and the
|
||||
//! launcher interleaves these lines with its own log, so UTC would misreport the
|
||||
//! timestamps by the machine's offset. std has no local-time support, so the
|
||||
//! UTC offset is taken from the system's TZif database (`TZ` or `/etc/localtime`),
|
||||
//! parsed here — a bounded, well-specified format (RFC 8536).
|
||||
//!
|
||||
//! LIMITATION, stated rather than hidden: only the TZif transition table is
|
||||
//! evaluated, not the trailing POSIX-TZ footer string. With the "fat" tzdata
|
||||
//! that Debian-family systems ship, transitions run to 2037, so the offset —
|
||||
//! including DST — is exact. Two cases fall back to the last known transition's
|
||||
//! offset (so a DST-observing zone could read one hour off) and one falls back
|
||||
//! to UTC:
|
||||
//! * "slim" tzdata, or dates past the last transition -> last transition;
|
||||
//! * `TZ` holding a bare POSIX rule (`EST5EDT`) with no such zone file, or an
|
||||
//! unreadable/corrupt zone file -> UTC.
|
||||
//! The timestamp is diagnostic; no line the launcher parses carries a time.
|
||||
|
||||
use std::fs;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Resolved UTC offsets over time: an offset before the first transition, then
|
||||
/// `(transition instant, offset from that instant on)` in ascending order.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct TzData {
|
||||
initial: i32,
|
||||
transitions: Vec<(i64, i32)>,
|
||||
}
|
||||
|
||||
impl TzData {
|
||||
/// UTC offset in seconds applying at `unix_secs`.
|
||||
pub fn offset_at(&self, unix_secs: i64) -> i32 {
|
||||
let idx = self
|
||||
.transitions
|
||||
.partition_point(|(start, _)| *start <= unix_secs);
|
||||
if idx == 0 {
|
||||
self.initial
|
||||
} else {
|
||||
self.transitions[idx - 1].1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[HH:MM:SS]`-worthy time-of-day for a Unix timestamp at a given UTC offset.
|
||||
pub fn hms(unix_secs: i64, utc_offset_secs: i32) -> (u32, u32, u32) {
|
||||
let local = unix_secs + i64::from(utc_offset_secs);
|
||||
// rem_euclid keeps pre-epoch and negative-offset instants on a sane clock.
|
||||
let day = local.rem_euclid(86_400);
|
||||
((day / 3600) as u32, (day % 3600 / 60) as u32, (day % 60) as u32)
|
||||
}
|
||||
|
||||
/// Current local time of day, `(hour, minute, second)`.
|
||||
pub fn now_hms() -> (u32, u32, u32) {
|
||||
let unix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
// Before 1970 the clock is broken anyway; keep logging rather than panic.
|
||||
.unwrap_or(0);
|
||||
hms(unix, local_offset_at(unix))
|
||||
}
|
||||
|
||||
/// UTC offset in seconds for `unix_secs`, or 0 when no zone data is usable.
|
||||
///
|
||||
/// The zone file is read and parsed once; the offset is then recomputed per call
|
||||
/// so a DST transition during a long-running session is picked up.
|
||||
pub fn local_offset_at(unix_secs: i64) -> i32 {
|
||||
static TZ: LazyLock<Option<TzData>> = LazyLock::new(load_system_tz);
|
||||
TZ.as_ref().map_or(0, |tz| tz.offset_at(unix_secs))
|
||||
}
|
||||
|
||||
/// Read and parse the zone file named by `TZ`, else `/etc/localtime`.
|
||||
fn load_system_tz() -> Option<TzData> {
|
||||
let path = match std::env::var("TZ") {
|
||||
Ok(tz) if !tz.is_empty() => {
|
||||
// glibc accepts a leading ':' and either an absolute path or a name
|
||||
// relative to the zoneinfo directory.
|
||||
let name = tz.strip_prefix(':').unwrap_or(&tz);
|
||||
if name.starts_with('/') {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("/usr/share/zoneinfo/{name}")
|
||||
}
|
||||
}
|
||||
_ => "/etc/localtime".to_string(),
|
||||
};
|
||||
parse_tzif(&fs::read(path).ok()?)
|
||||
}
|
||||
|
||||
/// Parse a TZif (RFC 8536) file into resolved offsets.
|
||||
///
|
||||
/// For version 2+ files the 64-bit data block is used; the legacy 32-bit block
|
||||
/// is skipped, because modern tzdata leaves it minimal.
|
||||
pub fn parse_tzif(bytes: &[u8]) -> Option<TzData> {
|
||||
let (version, counts) = parse_header(bytes, 0)?;
|
||||
if version >= b'2' {
|
||||
// Skip the v1 header + v1 data block, then re-read the 64-bit header.
|
||||
let v1_end = 44 + data_block_len(&counts, 4)?;
|
||||
let (_, counts64) = parse_header(bytes, v1_end)?;
|
||||
parse_data(bytes, v1_end + 44, &counts64, 8)
|
||||
} else {
|
||||
parse_data(bytes, 44, &counts, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// `(isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt)`.
|
||||
type Counts = [u32; 6];
|
||||
|
||||
fn parse_header(bytes: &[u8], off: usize) -> Option<(u8, Counts)> {
|
||||
let head = bytes.get(off..off + 44)?;
|
||||
if &head[0..4] != b"TZif" {
|
||||
return None;
|
||||
}
|
||||
let version = head[4];
|
||||
let mut counts = [0u32; 6];
|
||||
for (i, slot) in counts.iter_mut().enumerate() {
|
||||
let at = 20 + i * 4;
|
||||
*slot = u32::from_be_bytes(head[at..at + 4].try_into().ok()?);
|
||||
}
|
||||
Some((version, counts))
|
||||
}
|
||||
|
||||
/// Byte length of a data block with `time_len`-wide transition times.
|
||||
fn data_block_len(counts: &Counts, time_len: usize) -> Option<usize> {
|
||||
let [isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt] = counts.map(|c| c as usize);
|
||||
Some(
|
||||
timecnt * time_len
|
||||
+ timecnt
|
||||
+ typecnt * 6
|
||||
+ charcnt
|
||||
+ leapcnt * (time_len + 4)
|
||||
+ isstdcnt
|
||||
+ isutcnt,
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_data(bytes: &[u8], off: usize, counts: &Counts, time_len: usize) -> Option<TzData> {
|
||||
let [_, _, _, timecnt, typecnt, _] = counts.map(|c| c as usize);
|
||||
if typecnt == 0 {
|
||||
return None;
|
||||
}
|
||||
let block = bytes.get(off..off + data_block_len(counts, time_len)?)?;
|
||||
|
||||
let times = block.get(..timecnt * time_len)?;
|
||||
let type_idx = block.get(timecnt * time_len..timecnt * time_len + timecnt)?;
|
||||
let ttinfo_at = timecnt * time_len + timecnt;
|
||||
let ttinfo = block.get(ttinfo_at..ttinfo_at + typecnt * 6)?;
|
||||
|
||||
// utoff + isdst per local-time type.
|
||||
let mut offsets = Vec::with_capacity(typecnt);
|
||||
for i in 0..typecnt {
|
||||
let rec = &ttinfo[i * 6..i * 6 + 6];
|
||||
let utoff = i32::from_be_bytes(rec[0..4].try_into().ok()?);
|
||||
offsets.push((utoff, rec[4] != 0));
|
||||
}
|
||||
|
||||
// Before the first transition, RFC 8536 says to use the first non-DST type,
|
||||
// falling back to the first type. This is also the whole answer for a
|
||||
// fixed-offset zone (typecnt 1, timecnt 0), e.g. Etc/UTC.
|
||||
let initial = offsets
|
||||
.iter()
|
||||
.find(|(_, isdst)| !*isdst)
|
||||
.unwrap_or(&offsets[0])
|
||||
.0;
|
||||
|
||||
let mut transitions = Vec::with_capacity(timecnt);
|
||||
for i in 0..timecnt {
|
||||
let raw = ×[i * time_len..(i + 1) * time_len];
|
||||
let at = if time_len == 8 {
|
||||
i64::from_be_bytes(raw.try_into().ok()?)
|
||||
} else {
|
||||
i64::from(i32::from_be_bytes(raw.try_into().ok()?))
|
||||
};
|
||||
let (utoff, _) = *offsets.get(*type_idx.get(i)? as usize)?;
|
||||
transitions.push((at, utoff));
|
||||
}
|
||||
|
||||
Some(TzData {
|
||||
initial,
|
||||
transitions,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimal TZif builder: `transitions` are `(instant, type index)`.
|
||||
fn tzif(version: u8, types: &[(i32, bool)], transitions: &[(i64, u8)]) -> Vec<u8> {
|
||||
fn block(types: &[(i32, bool)], transitions: &[(i64, u8)], time_len: usize) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for (at, _) in transitions {
|
||||
if time_len == 8 {
|
||||
out.extend_from_slice(&at.to_be_bytes());
|
||||
} else {
|
||||
out.extend_from_slice(&(*at as i32).to_be_bytes());
|
||||
}
|
||||
}
|
||||
for (_, idx) in transitions {
|
||||
out.push(*idx);
|
||||
}
|
||||
for (utoff, isdst) in types {
|
||||
out.extend_from_slice(&utoff.to_be_bytes());
|
||||
out.push(u8::from(*isdst));
|
||||
out.push(0); // abbreviation index
|
||||
}
|
||||
out.push(0); // one NUL abbreviation byte
|
||||
out
|
||||
}
|
||||
fn header(version: u8, types: usize, transitions: usize) -> Vec<u8> {
|
||||
let mut out = Vec::from(*b"TZif");
|
||||
out.push(version);
|
||||
out.extend_from_slice(&[0u8; 15]);
|
||||
for count in [0u32, 0, 0, transitions as u32, types as u32, 1] {
|
||||
out.extend_from_slice(&count.to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
let mut out = header(version, types.len(), transitions.len());
|
||||
if version >= b'2' {
|
||||
// Modern "slim-ish" shape: an empty v1 block, then the 64-bit block.
|
||||
out.truncate(0);
|
||||
out.extend(header(version, types.len(), 0));
|
||||
out.extend(block(types, &[], 4));
|
||||
out.extend(header(version, types.len(), transitions.len()));
|
||||
out.extend(block(types, transitions, 8));
|
||||
} else {
|
||||
out.extend(block(types, transitions, 4));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_offset_zone_has_no_transitions() {
|
||||
let tz = parse_tzif(&tzif(b'2', &[(0, false)], &[])).unwrap();
|
||||
assert_eq!(tz.offset_at(0), 0);
|
||||
assert_eq!(tz.offset_at(1_800_000_000), 0);
|
||||
|
||||
let kolkata = parse_tzif(&tzif(b'2', &[(19_800, false)], &[])).unwrap();
|
||||
assert_eq!(kolkata.offset_at(1_800_000_000), 19_800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dst_transitions_select_the_right_offset() {
|
||||
// CET/CEST with two transitions.
|
||||
let tz = parse_tzif(&tzif(
|
||||
b'2',
|
||||
&[(3600, false), (7200, true)],
|
||||
&[(1_000_000_000, 1), (1_100_000_000, 0)],
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(tz.offset_at(999_999_999), 3600); // before the first transition
|
||||
assert_eq!(tz.offset_at(1_000_000_000), 7200); // exactly at it
|
||||
assert_eq!(tz.offset_at(1_050_000_000), 7200);
|
||||
assert_eq!(tz.offset_at(1_100_000_000), 3600);
|
||||
assert_eq!(tz.offset_at(i64::MAX), 3600); // past the table: last known
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_1_files_parse_from_the_32_bit_block() {
|
||||
let tz = parse_tzif(&tzif(b'\0', &[(-18_000, false)], &[(100, 0)])).unwrap();
|
||||
assert_eq!(tz.offset_at(0), -18_000);
|
||||
assert_eq!(tz.offset_at(1_000), -18_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_offset_skips_a_leading_dst_type() {
|
||||
let tz = parse_tzif(&tzif(b'2', &[(7200, true), (3600, false)], &[])).unwrap();
|
||||
assert_eq!(tz.offset_at(0), 3600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_rejected_rather_than_guessed() {
|
||||
assert_eq!(parse_tzif(b""), None);
|
||||
assert_eq!(parse_tzif(b"not a tzif file at all, truncated"), None);
|
||||
let mut truncated = tzif(b'2', &[(3600, false)], &[(1, 0)]);
|
||||
truncated.truncate(truncated.len() - 5);
|
||||
assert_eq!(parse_tzif(&truncated), None);
|
||||
// Well-formed header claiming zero local-time types is unusable.
|
||||
assert_eq!(parse_tzif(&tzif(b'2', &[], &[])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hms_matches_known_instants() {
|
||||
assert_eq!(hms(0, 0), (0, 0, 0));
|
||||
// 2026-08-18T04:52:08Z
|
||||
assert_eq!(hms(1_787_028_728, 0), (4, 52, 8));
|
||||
// …the same instant at +02:00 and at -05:00 (the latter is the day before).
|
||||
assert_eq!(hms(1_787_028_728, 7200), (6, 52, 8));
|
||||
assert_eq!(hms(1_787_028_728, -18_000), (23, 52, 8));
|
||||
// Offsets that cross midnight in either direction stay on the clock.
|
||||
assert_eq!(hms(86_399, 1), (0, 0, 0));
|
||||
assert_eq!(hms(0, -1), (23, 59, 59));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_zone_resolves_to_a_plausible_offset() {
|
||||
// Whatever this machine's zone is, the offset must be a real one.
|
||||
let offset = local_offset_at(1_786_697_528);
|
||||
assert!((-50_400..=50_400).contains(&offset), "implausible {offset}");
|
||||
assert_eq!(offset % 60, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! `[HH:MM:SS] <msg>` to stdout (flushed per line) and appended to the log file.
|
||||
//!
|
||||
//! The launcher pipes this process's stdout into its own log buffer and *parses*
|
||||
//! some of these lines, so the per-line flush and the line shapes are a contract,
|
||||
//! not cosmetics.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::localtime;
|
||||
use crate::procmem;
|
||||
|
||||
/// Environment override for the log file path.
|
||||
pub const LOG_PATH_ENV: &str = "OPENFUT_AUTOPATCH_LOG";
|
||||
|
||||
pub struct Logger {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Logger {
|
||||
/// `$OPENFUT_AUTOPATCH_LOG`, defaulting to `/tmp/openfut-autopatch-<uid>.log`.
|
||||
///
|
||||
/// Resolved once at startup, exactly like the Python's module-level `LOG`, so
|
||||
/// a later environment change cannot move the file mid-run.
|
||||
pub fn from_env() -> io::Result<Self> {
|
||||
let path = match std::env::var_os(LOG_PATH_ENV) {
|
||||
Some(path) if !path.is_empty() => PathBuf::from(path),
|
||||
_ => PathBuf::from(format!(
|
||||
"/tmp/openfut-autopatch-{}.log",
|
||||
procmem::current_uid()?
|
||||
)),
|
||||
};
|
||||
Ok(Self { path })
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &std::path::Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Emit one line. Timestamped in local time.
|
||||
pub fn log(&self, msg: &str) {
|
||||
let (h, m, s) = localtime::now_hms();
|
||||
let line = format!("[{h:02}:{m:02}:{s:02}] {msg}");
|
||||
|
||||
let mut stdout = io::stdout().lock();
|
||||
// Ignore a broken pipe: the launcher may have stopped reading, and dying
|
||||
// here would leave FIFA's store patches unenforced.
|
||||
let _ = writeln!(stdout, "{line}");
|
||||
let _ = stdout.flush();
|
||||
drop(stdout);
|
||||
|
||||
if let Err(e) = self.append(&line) {
|
||||
// The Python lets a failing log write kill the process. Patching the
|
||||
// running client matters more than the transcript, so report once to
|
||||
// stderr (the launcher captures it too) and carry on.
|
||||
let _ = writeln!(io::stderr(), "autopatch: cannot append to {}: {e}", self.path.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn append(&self, line: &str) -> io::Result<()> {
|
||||
let mut file = OpenOptions::new().create(true).append(true).open(&self.path)?;
|
||||
file.write_all(line.as_bytes())?;
|
||||
file.write_all(b"\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn appends_a_timestamped_line_to_the_configured_path() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"openfut-autopatch-test-{}.log",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let logger = Logger {
|
||||
path: path.clone(),
|
||||
};
|
||||
logger.log("pid 4242: PATCHED cert gates");
|
||||
logger.log("second line");
|
||||
|
||||
let body = std::fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = body.lines().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(&lines[0][..1], "[");
|
||||
assert_eq!(&lines[0][3..4], ":");
|
||||
assert_eq!(&lines[0][6..7], ":");
|
||||
assert_eq!(&lines[0][9..], "] pid 4242: PATCHED cert gates");
|
||||
assert!(lines[1].ends_with("] second line"));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_log_path_does_not_kill_the_patcher() {
|
||||
let logger = Logger {
|
||||
path: PathBuf::from("/proc/definitely/not/writable.log"),
|
||||
};
|
||||
logger.log("still running");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Watch for a (re)launched FIFA17.exe and auto-apply the ProtoSSL cert patches
|
||||
//! the moment its unpacked code is mapped, plus the CardsDLL FUT store patches.
|
||||
//! Idempotent; keeps watching across relaunches.
|
||||
//!
|
||||
//! Port of `fifa17-recon/tools/autopatch.py`, tick for tick: cert gates once per
|
||||
//! pid, store patches re-enforced every second (the game rewrites those sites),
|
||||
//! then the resolver-guard capability reported once per pid. The passes
|
||||
//! themselves live in `openfut_autopatch::patch`; this is the loop and the CLI.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::process::ExitCode;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
use openfut_autopatch::patch::{cert_pass, enforce_store_patches};
|
||||
use openfut_autopatch::procmem::{self, ProcMem};
|
||||
use openfut_autopatch::{parse_launcher_pid, Logger};
|
||||
|
||||
/// One tick per second, as in the Python.
|
||||
const TICK: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Per-pid bookkeeping so each of these lines is logged exactly once per client
|
||||
/// process (the Python's three module-level sets).
|
||||
#[derive(Default)]
|
||||
struct Seen {
|
||||
patched: HashSet<u32>,
|
||||
store_patched: HashSet<u32>,
|
||||
guard_reported: HashSet<u32>,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let launcher_pid = match parse_launcher_pid(std::env::args().skip(1)) {
|
||||
Ok(pid) => pid,
|
||||
Err(()) => {
|
||||
eprintln!("invalid --launcher-pid");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let logger = match Logger::from_env() {
|
||||
Ok(logger) => logger,
|
||||
Err(e) => {
|
||||
eprintln!("cannot resolve the autopatch log path: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
logger.log("=== AUTOPATCH watching for FIFA17.exe ===");
|
||||
|
||||
let mut seen = Seen::default();
|
||||
loop {
|
||||
// `if launcher_pid and not os.path.exists(...)`: pid 0 is falsy in the
|
||||
// Python, so `--launcher-pid 0` parses but is never watched.
|
||||
if let Some(pid) = launcher_pid {
|
||||
if pid != 0 && !procmem::pid_alive(pid) {
|
||||
logger.log(&format!("launcher pid {pid} exited; stopping autopatch"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for pid in procmem::find_pids() {
|
||||
let mem = ProcMem::new(pid);
|
||||
|
||||
if !seen.patched.contains(&pid)
|
||||
&& !cert_pass(&mem, &mut |line| logger.log(line), &mut seen.patched)
|
||||
{
|
||||
// Code not mapped yet: nothing else to do for this pid this tick.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store patches are enforced on EVERY tick, not once: the game
|
||||
// rewrites these sites, so a single pass at startup does not hold.
|
||||
let Some(cbase) = procmem::cardsdll_base(pid) else {
|
||||
continue;
|
||||
};
|
||||
match enforce_store_patches(
|
||||
&mem,
|
||||
cbase,
|
||||
&mut |line| logger.log(line),
|
||||
&mut seen.guard_reported,
|
||||
) {
|
||||
Ok(()) => {
|
||||
if seen.store_patched.insert(pid) {
|
||||
logger.log(&format!(
|
||||
"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => logger.log(&format!("pid {pid}: store patch write failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
sleep(TICK);
|
||||
}
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
//! The two enforcement passes: ProtoSSL cert gates (once per pid) and the
|
||||
//! CardsDLL store patches (every tick).
|
||||
//!
|
||||
//! Both are written against the [`Memory`] trait rather than `/proc` directly, so
|
||||
//! the log lines and their order — which the launcher reads — are unit-testable
|
||||
//! without a live FIFA client.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
|
||||
use crate::{
|
||||
guard_state_after, guarded_action, hex, live_addr, GuardState, GuardedAction,
|
||||
EMPTY_MYPACKS_RESOLVER_CAPABILITY, EMPTY_MYPACKS_RESOLVER_VERSION, GATE1, GATE1_ORIG,
|
||||
GATE1_PATCH, GATE2, GATE2_ORIG, GATE2_PATCH, MAX_PATCH_LEN, RESOLVER_GUARD_VA, STORE_PATCHES,
|
||||
STORE_PATCHES_GUARDED,
|
||||
};
|
||||
|
||||
/// Byte-level access to one client process's address space.
|
||||
pub trait Memory {
|
||||
/// The pid being patched; it appears in every log line.
|
||||
fn pid(&self) -> u32;
|
||||
/// Fill `buf` from virtual address `va`. An error means "not mapped (yet)".
|
||||
fn read(&self, va: u64, buf: &mut [u8]) -> io::Result<()>;
|
||||
/// Write `data` at virtual address `va`.
|
||||
fn write(&self, va: u64, data: &[u8]) -> io::Result<()>;
|
||||
}
|
||||
|
||||
/// Apply the two ProtoSSL cert gates, once per pid.
|
||||
///
|
||||
/// Returns `false` when the gates could not be read — the packer has not mapped
|
||||
/// that code yet, which is the Python's `continue`, not an error to report.
|
||||
pub fn cert_pass<M: Memory>(
|
||||
mem: &M,
|
||||
log: &mut impl FnMut(&str),
|
||||
patched: &mut HashSet<u32>,
|
||||
) -> bool {
|
||||
let pid = mem.pid();
|
||||
// Sized from the patterns themselves; the initial contents are overwritten by
|
||||
// the reads and are never compared unless both reads succeed.
|
||||
let mut g2 = GATE2_ORIG;
|
||||
let mut g1 = GATE1_ORIG;
|
||||
if mem.read(GATE2, &mut g2).is_err() || mem.read(GATE1, &mut g1).is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if g2 == GATE2_PATCH && g1 == GATE1_PATCH {
|
||||
log(&format!("pid {pid}: cert gates already patched"));
|
||||
patched.insert(pid);
|
||||
} else if g2 == GATE2_ORIG && g1 == GATE1_ORIG {
|
||||
match mem
|
||||
.write(GATE2, &GATE2_PATCH)
|
||||
.and_then(|()| mem.write(GATE1, &GATE1_PATCH))
|
||||
{
|
||||
Ok(()) => {
|
||||
log(&format!("pid {pid}: PATCHED cert gates"));
|
||||
patched.insert(pid);
|
||||
}
|
||||
Err(e) => log(&format!("pid {pid}: cert patch write failed: {e}")),
|
||||
}
|
||||
}
|
||||
// Anything else is a build we do not recognise: left alone, as in the Python.
|
||||
true
|
||||
}
|
||||
|
||||
/// Re-apply every store patch whose live bytes have drifted, then the guarded
|
||||
/// patch, then report the resolver-guard capability once per pid.
|
||||
///
|
||||
/// An `Err` is a read or write that failed outside the guarded site's own
|
||||
/// handling; it aborts the rest of this pid's pass for this tick, exactly like
|
||||
/// the Python's enclosing `try`.
|
||||
pub fn enforce_store_patches<M: Memory>(
|
||||
mem: &M,
|
||||
cbase: u64,
|
||||
log: &mut impl FnMut(&str),
|
||||
guard_reported: &mut HashSet<u32>,
|
||||
) -> io::Result<()> {
|
||||
let pid = mem.pid();
|
||||
|
||||
for (va, data) in STORE_PATCHES {
|
||||
let live = live_addr(cbase, va);
|
||||
let mut buf = [0u8; MAX_PATCH_LEN];
|
||||
let cur = &mut buf[..data.len()];
|
||||
mem.read(live, cur)?;
|
||||
if cur != data {
|
||||
mem.write(live, data)?;
|
||||
log(&format!("pid {pid}: ENFORCED store patch @ {live:#x}"));
|
||||
}
|
||||
}
|
||||
|
||||
for (va, orig, patch) in STORE_PATCHES_GUARDED {
|
||||
let live = live_addr(cbase, va);
|
||||
let mut before = [0u8; MAX_PATCH_LEN];
|
||||
mem.read(live, &mut before[..patch.len()])?;
|
||||
let cur = &before[..patch.len()];
|
||||
|
||||
let mut wrote_ok = true;
|
||||
// The Python starts with `cur_after = cur`, which only matters on the
|
||||
// branches that never re-read.
|
||||
let mut after = [0u8; MAX_PATCH_LEN];
|
||||
after[..patch.len()].copy_from_slice(cur);
|
||||
let mut after_len = patch.len();
|
||||
|
||||
match guarded_action(cur, orig, patch) {
|
||||
GuardedAction::Patch => {
|
||||
match mem.write(live, patch) {
|
||||
Ok(()) => log(&format!(
|
||||
"pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)"
|
||||
)),
|
||||
Err(e) => {
|
||||
wrote_ok = false;
|
||||
log(&format!(
|
||||
"pid {pid}: guarded patch write failed @ {live:#x}: {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
if wrote_ok && mem.read(live, &mut after[..patch.len()]).is_err() {
|
||||
// The Python's `cur_after = b""`: unverifiable, so not verified.
|
||||
after_len = 0;
|
||||
}
|
||||
}
|
||||
GuardedAction::Skip => log(&format!(
|
||||
"pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {} (build mismatch)",
|
||||
hex(cur)
|
||||
)),
|
||||
// Already patched; nothing to write.
|
||||
GuardedAction::Noop => {}
|
||||
}
|
||||
|
||||
if va == RESOLVER_GUARD_VA && !guard_reported.contains(&pid) {
|
||||
let state = guard_state_after(cur, orig, patch, wrote_ok, &after[..after_len]);
|
||||
if state == GuardState::Verified {
|
||||
// PARSED BY THE LAUNCHER (fifa17_capability::parse_capability_line):
|
||||
// this line must keep both `verified capability` and the
|
||||
// `fifa17.empty_mypacks_resolver=<version>` token verbatim.
|
||||
log(&format!(
|
||||
"[store-guard] verified capability {EMPTY_MYPACKS_RESOLVER_CAPABILITY}={EMPTY_MYPACKS_RESOLVER_VERSION} fifa_pid={pid}"
|
||||
));
|
||||
} else {
|
||||
log(&format!(
|
||||
"[store-guard] guard status={state} fifa_pid={pid} (no capability advertised)"
|
||||
));
|
||||
}
|
||||
guard_reported.insert(pid);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const CBASE: u64 = 0x7f2a11c00000;
|
||||
const GUARD_LIVE: u64 = CBASE + 0x14858;
|
||||
|
||||
/// Sparse fake address space: an unmapped byte reads as `NotFound`, mirroring
|
||||
/// `/proc/<pid>/mem` refusing an address the packer has not produced yet.
|
||||
struct FakeMemory {
|
||||
bytes: RefCell<BTreeMap<u64, u8>>,
|
||||
/// Writes to these addresses fail.
|
||||
fail_writes: Vec<u64>,
|
||||
/// Writes to these addresses report success but change nothing (the
|
||||
/// VERIFY_FAILED shape).
|
||||
swallow_writes: Vec<u64>,
|
||||
/// Reads of these addresses fail even when mapped.
|
||||
fail_reads: Vec<u64>,
|
||||
}
|
||||
|
||||
impl FakeMemory {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
bytes: RefCell::new(BTreeMap::new()),
|
||||
fail_writes: Vec::new(),
|
||||
swallow_writes: Vec::new(),
|
||||
fail_reads: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map(self, va: u64, bytes: &[u8]) -> Self {
|
||||
{
|
||||
let mut mem = self.bytes.borrow_mut();
|
||||
for (i, b) in bytes.iter().enumerate() {
|
||||
mem.insert(va + i as u64, *b);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Every store-patch site mapped with filler that is neither the patch
|
||||
/// nor (for the guarded site) the original.
|
||||
fn with_store_sites(mut self, filler: u8) -> Self {
|
||||
for (va, data) in STORE_PATCHES {
|
||||
self = self.map(live_addr(CBASE, va), &vec![filler; data.len()]);
|
||||
}
|
||||
for (va, _, patch) in STORE_PATCHES_GUARDED {
|
||||
self = self.map(live_addr(CBASE, va), &vec![filler; patch.len()]);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn at(&self, va: u64, len: usize) -> Vec<u8> {
|
||||
let mem = self.bytes.borrow();
|
||||
(0..len as u64).map(|i| mem[&(va + i)]).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Memory for FakeMemory {
|
||||
fn pid(&self) -> u32 {
|
||||
4242
|
||||
}
|
||||
|
||||
fn read(&self, va: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
if self.fail_reads.contains(&va) {
|
||||
return Err(io::Error::from(io::ErrorKind::PermissionDenied));
|
||||
}
|
||||
let mem = self.bytes.borrow();
|
||||
for (i, slot) in buf.iter_mut().enumerate() {
|
||||
*slot = *mem
|
||||
.get(&(va + i as u64))
|
||||
.ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write(&self, va: u64, data: &[u8]) -> io::Result<()> {
|
||||
if self.fail_writes.contains(&va) {
|
||||
return Err(io::Error::from(io::ErrorKind::PermissionDenied));
|
||||
}
|
||||
if self.swallow_writes.contains(&va) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut mem = self.bytes.borrow_mut();
|
||||
for (i, b) in data.iter().enumerate() {
|
||||
mem.insert(va + i as u64, *b);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects log lines so the contract strings can be asserted verbatim.
|
||||
#[derive(Default)]
|
||||
struct Lines(Vec<String>);
|
||||
|
||||
impl Lines {
|
||||
fn sink(&mut self) -> impl FnMut(&str) + '_ {
|
||||
|line: &str| self.0.push(line.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_pass_defers_while_the_code_is_not_mapped() {
|
||||
let mem = FakeMemory::new();
|
||||
let mut lines = Lines::default();
|
||||
let mut patched = HashSet::new();
|
||||
assert!(!cert_pass(&mem, &mut lines.sink(), &mut patched));
|
||||
assert!(lines.0.is_empty(), "{:?}", lines.0);
|
||||
assert!(patched.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_pass_defers_when_only_the_first_gate_is_mapped() {
|
||||
let mem = FakeMemory::new().map(GATE2, &GATE2_ORIG);
|
||||
let mut lines = Lines::default();
|
||||
let mut patched = HashSet::new();
|
||||
assert!(!cert_pass(&mem, &mut lines.sink(), &mut patched));
|
||||
assert!(lines.0.is_empty());
|
||||
assert!(patched.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_pass_writes_both_gates_once() {
|
||||
let mem = FakeMemory::new()
|
||||
.map(GATE2, &GATE2_ORIG)
|
||||
.map(GATE1, &GATE1_ORIG);
|
||||
let mut lines = Lines::default();
|
||||
let mut patched = HashSet::new();
|
||||
assert!(cert_pass(&mem, &mut lines.sink(), &mut patched));
|
||||
assert_eq!(lines.0, vec!["pid 4242: PATCHED cert gates"]);
|
||||
assert_eq!(mem.at(GATE2, 3), GATE2_PATCH);
|
||||
assert_eq!(mem.at(GATE1, 6), GATE1_PATCH);
|
||||
assert!(patched.contains(&4242));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_pass_recognises_an_already_patched_client() {
|
||||
let mem = FakeMemory::new()
|
||||
.map(GATE2, &GATE2_PATCH)
|
||||
.map(GATE1, &GATE1_PATCH);
|
||||
let mut lines = Lines::default();
|
||||
let mut patched = HashSet::new();
|
||||
assert!(cert_pass(&mem, &mut lines.sink(), &mut patched));
|
||||
assert_eq!(lines.0, vec!["pid 4242: cert gates already patched"]);
|
||||
assert!(patched.contains(&4242));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_pass_reports_a_write_failure_and_stays_unpatched() {
|
||||
let mut mem = FakeMemory::new()
|
||||
.map(GATE2, &GATE2_ORIG)
|
||||
.map(GATE1, &GATE1_ORIG);
|
||||
mem.fail_writes.push(GATE2);
|
||||
let mut lines = Lines::default();
|
||||
let mut patched = HashSet::new();
|
||||
assert!(cert_pass(&mem, &mut lines.sink(), &mut patched));
|
||||
assert_eq!(lines.0.len(), 1);
|
||||
assert!(
|
||||
lines.0[0].starts_with("pid 4242: cert patch write failed: "),
|
||||
"{}",
|
||||
lines.0[0]
|
||||
);
|
||||
// Not recorded as patched, so the next tick tries again.
|
||||
assert!(patched.is_empty());
|
||||
assert_eq!(mem.at(GATE2, 3), GATE2_ORIG);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_pass_leaves_an_unrecognised_build_alone() {
|
||||
let mem = FakeMemory::new()
|
||||
.map(GATE2, &[0x55, 0x48, 0x89])
|
||||
.map(GATE1, &[0x0f, 0x84, 0x76, 0x01, 0x00, 0x00]);
|
||||
let mut lines = Lines::default();
|
||||
let mut patched = HashSet::new();
|
||||
assert!(cert_pass(&mem, &mut lines.sink(), &mut patched));
|
||||
assert!(lines.0.is_empty(), "{:?}", lines.0);
|
||||
assert!(patched.is_empty());
|
||||
assert_eq!(mem.at(GATE2, 3), [0x55, 0x48, 0x89]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_pass_enforces_every_site_in_table_order_then_advertises() {
|
||||
let mem = FakeMemory::new().with_store_sites(0xcc);
|
||||
let mut lines = Lines::default();
|
||||
let mut reported = HashSet::new();
|
||||
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||
|
||||
let mut expected: Vec<String> = STORE_PATCHES
|
||||
.iter()
|
||||
.map(|(va, _)| {
|
||||
let live = live_addr(CBASE, *va);
|
||||
format!("pid 4242: ENFORCED store patch @ {live:#x}")
|
||||
})
|
||||
.collect();
|
||||
// 0xcc is neither the original nor the patch: fail-closed SKIP, and the
|
||||
// capability is withheld.
|
||||
expected.push(format!(
|
||||
"pid 4242: SKIP guarded patch @ {GUARD_LIVE:#x}: unexpected cccc (build mismatch)"
|
||||
));
|
||||
expected.push(
|
||||
"[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=4242 (no capability advertised)"
|
||||
.to_string(),
|
||||
);
|
||||
assert_eq!(lines.0, expected);
|
||||
assert!(reported.contains(&4242));
|
||||
|
||||
for (va, data) in STORE_PATCHES {
|
||||
assert_eq!(mem.at(live_addr(CBASE, va), data.len()), data);
|
||||
}
|
||||
// The guarded site was NOT overwritten.
|
||||
assert_eq!(mem.at(GUARD_LIVE, 2), [0xcc, 0xcc]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_pass_patches_the_guard_and_advertises_the_capability() {
|
||||
let mem = FakeMemory::new()
|
||||
.with_store_sites(0xcc)
|
||||
.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].1);
|
||||
let mut lines = Lines::default();
|
||||
let mut reported = HashSet::new();
|
||||
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
lines.0[lines.0.len() - 2],
|
||||
format!(
|
||||
"pid 4242: ENFORCED guarded store patch @ {GUARD_LIVE:#x} (JNZ->JG, empty My Packs)"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
lines.0[lines.0.len() - 1],
|
||||
"[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242"
|
||||
);
|
||||
assert_eq!(mem.at(GUARD_LIVE, 2), [0x7f, 0x0f]);
|
||||
|
||||
// Second tick: everything already enforced, and the capability is not
|
||||
// re-advertised.
|
||||
let mut lines = Lines::default();
|
||||
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||
assert!(lines.0.is_empty(), "{:?}", lines.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_pass_verifies_an_already_patched_guard() {
|
||||
let mem = FakeMemory::new()
|
||||
.with_store_sites(0xcc)
|
||||
.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].2);
|
||||
let mut lines = Lines::default();
|
||||
let mut reported = HashSet::new();
|
||||
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||
assert_eq!(
|
||||
lines.0.last().unwrap(),
|
||||
"[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_pass_reports_a_guarded_write_failure_without_aborting_the_tick() {
|
||||
let mut mem = FakeMemory::new()
|
||||
.with_store_sites(0xcc)
|
||||
.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].1);
|
||||
mem.fail_writes.push(GUARD_LIVE);
|
||||
let mut lines = Lines::default();
|
||||
let mut reported = HashSet::new();
|
||||
// The guarded write failure is handled inline, so the pass still succeeds.
|
||||
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||
assert!(
|
||||
lines.0[lines.0.len() - 2]
|
||||
.starts_with(&format!("pid 4242: guarded patch write failed @ {GUARD_LIVE:#x}: ")),
|
||||
"{}",
|
||||
lines.0[lines.0.len() - 2]
|
||||
);
|
||||
assert_eq!(
|
||||
lines.0[lines.0.len() - 1],
|
||||
"[store-guard] guard status=WRITE_FAILED fifa_pid=4242 (no capability advertised)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_pass_withholds_the_capability_when_verification_fails() {
|
||||
let mut mem = FakeMemory::new()
|
||||
.with_store_sites(0xcc)
|
||||
.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].1);
|
||||
// Write reported OK, memory unchanged: the re-read still shows the original.
|
||||
mem.swallow_writes.push(GUARD_LIVE);
|
||||
let mut lines = Lines::default();
|
||||
let mut reported = HashSet::new();
|
||||
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||
assert_eq!(
|
||||
lines.0[lines.0.len() - 1],
|
||||
"[store-guard] guard status=VERIFY_FAILED fifa_pid=4242 (no capability advertised)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_pass_aborts_the_tick_when_a_site_is_not_mapped() {
|
||||
// CardsDLL is mapped but this tick catches a site mid-unpack.
|
||||
let mem = FakeMemory::new();
|
||||
let mut lines = Lines::default();
|
||||
let mut reported = HashSet::new();
|
||||
let err = enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
||||
assert!(lines.0.is_empty());
|
||||
// Nothing advertised, so the next tick re-evaluates the guard.
|
||||
assert!(reported.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_pass_skips_writing_sites_that_already_hold_the_patch() {
|
||||
let mut mem = FakeMemory::new().with_store_sites(0xcc);
|
||||
for (va, data) in STORE_PATCHES {
|
||||
mem = mem.map(live_addr(CBASE, va), data);
|
||||
}
|
||||
mem = mem.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].2);
|
||||
// Any write at all would fail these sites, proving none is attempted.
|
||||
mem.fail_writes
|
||||
.extend(STORE_PATCHES.iter().map(|(va, _)| live_addr(CBASE, *va)));
|
||||
mem.fail_writes.push(GUARD_LIVE);
|
||||
|
||||
let mut lines = Lines::default();
|
||||
let mut reported = HashSet::new();
|
||||
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||
assert_eq!(
|
||||
lines.0,
|
||||
vec!["[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! `/proc` access: finding the client, locating CardsDLL, and positioned reads
|
||||
//! and writes against `/proc/<pid>/mem`.
|
||||
//!
|
||||
//! Positioned I/O (`pread`/`pwrite`) is used rather than seek+read: a 64-bit
|
||||
//! virtual address is passed straight through as the file offset, so nothing
|
||||
//! depends on a shared file cursor.
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io;
|
||||
use std::os::unix::fs::FileExt;
|
||||
|
||||
use crate::patch::Memory;
|
||||
use crate::{is_client_comm, parse_cardsdll_base, pid_from_proc_entry};
|
||||
|
||||
/// Pids whose `comm` is exactly `FIFA17.exe`.
|
||||
///
|
||||
/// Sorted ascending so that, when more than one client is somehow running, the
|
||||
/// per-pid log lines come out in a stable order (the Python inherits readdir
|
||||
/// order, which is arbitrary).
|
||||
pub fn find_pids() -> Vec<u32> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(entries) = fs::read_dir("/proc") else {
|
||||
return out;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(pid) = name.to_str().and_then(pid_from_proc_entry) else {
|
||||
continue;
|
||||
};
|
||||
// A pid can exit between readdir and this read; that is not an error.
|
||||
if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) {
|
||||
if is_client_comm(&comm) {
|
||||
out.push(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// Base address of the mapped CardsDLL, or `None` while it is not mapped.
|
||||
pub fn cardsdll_base(pid: u32) -> Option<u64> {
|
||||
let maps = fs::read_to_string(format!("/proc/{pid}/maps")).ok()?;
|
||||
parse_cardsdll_base(&maps)
|
||||
}
|
||||
|
||||
/// [`Memory`] over one live client process.
|
||||
pub struct ProcMem {
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
impl ProcMem {
|
||||
pub fn new(pid: u32) -> Self {
|
||||
Self { pid }
|
||||
}
|
||||
}
|
||||
|
||||
impl Memory for ProcMem {
|
||||
fn pid(&self) -> u32 {
|
||||
self.pid
|
||||
}
|
||||
|
||||
/// A failure here normally means the address is not mapped yet — the packer
|
||||
/// has not unpacked that code — which the watch loop treats as "come back
|
||||
/// next tick", not as a failure worth reporting. Read-only handle.
|
||||
fn read(&self, va: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
File::open(format!("/proc/{}/mem", self.pid))?.read_exact_at(buf, va)
|
||||
}
|
||||
|
||||
/// Opened read+write like the Python's `r+b`; write-only is not universally
|
||||
/// accepted for `/proc/<pid>/mem` across kernels.
|
||||
fn write(&self, va: u64, data: &[u8]) -> io::Result<()> {
|
||||
OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(format!("/proc/{}/mem", self.pid))?
|
||||
.write_all_at(data, va)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `/proc/<pid>` still exists — the launcher-liveness check.
|
||||
pub fn pid_alive(pid: i64) -> bool {
|
||||
// Formatted exactly like the Python so a negative or zero pid behaves the
|
||||
// same way (the path simply does not exist).
|
||||
fs::metadata(format!("/proc/{pid}")).is_ok()
|
||||
}
|
||||
|
||||
/// Real uid of this process, from the ownership of `/proc/self`.
|
||||
///
|
||||
/// std exposes no `getuid`, and this crate takes no dependencies; `/proc` is
|
||||
/// mandatory for the patcher anyway, so reading it back is not a new assumption.
|
||||
pub fn current_uid() -> io::Result<u32> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
Ok(fs::metadata("/proc/self")?.uid())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn our_own_pid_is_alive_and_pid_zero_is_not() {
|
||||
let me: i64 = fs::read_to_string("/proc/self/stat")
|
||||
.unwrap()
|
||||
.split(' ')
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(pid_alive(me));
|
||||
// /proc/0 and /proc/-1 never exist, matching the Python's path check.
|
||||
assert!(!pid_alive(0));
|
||||
assert!(!pid_alive(-1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uid_is_readable() {
|
||||
// Only that it resolves; the value is environment-dependent.
|
||||
assert!(current_uid().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_pids_scan_is_safe_without_a_client() {
|
||||
// Deterministic without a client: the scan must not panic and must only
|
||||
// ever return numeric pids.
|
||||
for pid in find_pids() {
|
||||
assert!(pid > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positioned_io_round_trips_against_our_own_address_space() {
|
||||
// Patching FIFA is not testable here, but the /proc/<pid>/mem mechanism
|
||||
// is: read and write this process's own heap through the same code path.
|
||||
let me: u32 = fs::read_to_string("/proc/self/stat")
|
||||
.unwrap()
|
||||
.split(' ')
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
let mem = ProcMem::new(me);
|
||||
// black_box throughout: this buffer is mutated by the kernel on our
|
||||
// behalf, never by Rust code, so the compiler must not assume it is
|
||||
// unchanged across the write.
|
||||
let target = std::hint::black_box(vec![0x75u8, 0x0f, 0x11, 0x22]);
|
||||
let va = target.as_ptr() as u64;
|
||||
|
||||
let mut seen = [0u8; 4];
|
||||
mem.read(va, &mut seen).unwrap();
|
||||
assert_eq!(seen, *target);
|
||||
|
||||
mem.write(va, &[0x7f, 0x0f]).unwrap();
|
||||
assert_eq!(*std::hint::black_box(&target), [0x7f, 0x0f, 0x11, 0x22]);
|
||||
|
||||
// An address that is certainly not mapped reads as an error, which the
|
||||
// watch loop treats as "not unpacked yet".
|
||||
assert!(mem.read(0x1000, &mut seen).is_err());
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule openfut-launcher updated: c5424158b9...1cd4f18e92
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "openfut-lsx"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "EA Origin LSX emulator for the FIFA 17 client (loopback 4216)"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
# AES-128-ECB only. The LSX session cipher is a single fixed-key ECB block operation,
|
||||
# so the block cipher alone is the whole requirement -- no AEAD, no TLS stack. Using
|
||||
# the `aes` crate rather than hand-rolling it keeps the one security-shaped primitive
|
||||
# in reviewed code, while the surrounding framing (PKCS7 pad, lowercase hex,
|
||||
# NUL-terminated) stays explicit here because it is protocol, not cryptography.
|
||||
aes = "0.8"
|
||||
|
||||
# Project rule rs-parking-lot: locks that are immediately unwrapped use parking_lot.
|
||||
parking_lot = "0.12"
|
||||
@@ -0,0 +1,429 @@
|
||||
//! LSX session crypto.
|
||||
//!
|
||||
//! Transcription of the crypto block of `fifa17-recon/tools/lsx_responder_v2.py`,
|
||||
//! which carries the note:
|
||||
//!
|
||||
//! > (verbatim from v1 -- verified end-to-end by decrypting captured
|
||||
//! > captures/lsx/lsx_raw/C1_ENC-IN_*.bin. DO NOT TOUCH.)
|
||||
//!
|
||||
//! So the Python is the specification and this file is a byte-for-byte port. The
|
||||
//! block cipher itself comes from the `aes` crate; the framing around it
|
||||
//! (PKCS7 pad to 16, lowercase hex, NUL terminator) is *protocol*, not
|
||||
//! cryptography, and is therefore spelled out here rather than delegated to a
|
||||
//! mode/padding helper.
|
||||
//!
|
||||
//! Wire recap (reversed from stp-origin_emu.dll @ 0x6ffffc930000):
|
||||
//! handshake: server sends `<Challenge key="...">` in PLAINTEXT; the client
|
||||
//! answers plaintext with `response=`/`key=`; the server answers
|
||||
//! `<ChallengeAccepted response="H">` where
|
||||
//! `H = hex(AES128_ECB(K_FIXED, clientKeyAscii))` + a fixed 3rd block.
|
||||
//! session: every later frame, both directions, Responses AND Events, is
|
||||
//! `hex_lower(AES128_ECB(SESSION_KEY, pkcs7pad16(xml))) + b"\0"`,
|
||||
//! with SESSION_KEY derived from `H` through the MSVCR srand/rand LCG.
|
||||
|
||||
use std::fmt;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use aes::cipher::generic_array::GenericArray;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
|
||||
/// A 128-bit LSX key (the fixed handshake key or a derived session key).
|
||||
pub type Key = [u8; 16];
|
||||
|
||||
/// Recovered from stp-origin_emu.dll .rdata @ VA 0x6ffffc935038.
|
||||
/// `bytes(range(16))` == `000102030405060708090a0b0c0d0e0f`.
|
||||
pub const K_FIXED: Key = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
];
|
||||
|
||||
/// Failure modes of the LSX codec. The variants mirror the exceptions the Python
|
||||
/// raises at the same points, because they surface through the same log lines
|
||||
/// (`decrypt fail: ...`, `connection error: ...`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CryptoError {
|
||||
/// ECB has no padding of its own: pycryptodome raises ValueError here.
|
||||
NotBlockAligned(usize),
|
||||
/// `bytes.fromhex` rejected the payload.
|
||||
BadHex,
|
||||
/// Nothing before the first NUL, so there is no block to decrypt (the Python
|
||||
/// dies on `raw[-1]` with IndexError).
|
||||
Empty,
|
||||
/// The client's own `response=` carried a 3rd block that is not the emu's
|
||||
/// constant. The Python asserts here on purpose: a future client that
|
||||
/// randomises block 3 must fail LOUDLY, not silently.
|
||||
UnexpectedTail(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for CryptoError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
// Wording kept close to pycryptodome's so existing log greps still hit.
|
||||
CryptoError::NotBlockAligned(n) => write!(
|
||||
f,
|
||||
"Data must be padded to 16 byte boundary in ECB mode (got {n} bytes)"
|
||||
),
|
||||
CryptoError::BadHex => f.write_str("Non-hexadecimal digit found"),
|
||||
CryptoError::Empty => f.write_str("empty ciphertext"),
|
||||
CryptoError::UnexpectedTail(t) => {
|
||||
write!(f, "unexpected ChallengeResponse tail {t:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CryptoError {}
|
||||
|
||||
/// MSVCR120 `srand`/`rand` LCG (verified: `srand(7); rand() == 61`).
|
||||
pub struct MsvcrRand {
|
||||
state: u32,
|
||||
}
|
||||
|
||||
impl MsvcrRand {
|
||||
/// `srand(seed)`.
|
||||
pub fn new(seed: u32) -> Self {
|
||||
Self { state: seed }
|
||||
}
|
||||
|
||||
/// `rand()` -- 15 bits, exactly as MSVCR120 returns them.
|
||||
pub fn next_u15(&mut self) -> u16 {
|
||||
self.state = self.state.wrapping_mul(214013).wrapping_add(2531011);
|
||||
((self.state >> 16) & 0x7FFF) as u16
|
||||
}
|
||||
}
|
||||
|
||||
/// Reimplementation of emu `sub_0x6ffffc931f10` tail (0x9320bf-0x932101).
|
||||
///
|
||||
/// ```text
|
||||
/// srand(7); r0 = rand() -> r0 == 61
|
||||
/// bx = (u16)((resp[0] << 8) + resp[1]) (16-bit wrap)
|
||||
/// srand(bx + r0)
|
||||
/// key[i] = (uint8_t)rand() for i in 0..15
|
||||
/// ```
|
||||
///
|
||||
/// `resp[0]`/`resp[1]` are the first two ASCII *characters* of the hex response,
|
||||
/// not the first decoded byte.
|
||||
pub fn derive_session_key(resp_hex: &str) -> Key {
|
||||
let r0 = u32::from(MsvcrRand::new(7).next_u15()); // == 61
|
||||
let b = resp_hex.as_bytes();
|
||||
assert!(
|
||||
b.len() >= 2,
|
||||
"derive_session_key needs the first two response chars, got {:?}",
|
||||
resp_hex
|
||||
);
|
||||
let bx = ((u32::from(b[0]) << 8) + u32::from(b[1])) & 0xFFFF;
|
||||
let mut g = MsvcrRand::new(bx.wrapping_add(r0));
|
||||
let mut key = [0u8; 16];
|
||||
for byte in key.iter_mut() {
|
||||
*byte = (g.next_u15() & 0xFF) as u8;
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
/// `AES128-ECB(K_FIXED, 0x10 * 16)` -- the constant the emu appends as the 3rd
|
||||
/// hex block (== the PKCS7 pad block of an aligned 32-byte key). See
|
||||
/// REPACK_INTEL.md sec.0-B.
|
||||
pub fn tail_const() -> &'static str {
|
||||
static TAIL: LazyLock<String> = LazyLock::new(|| {
|
||||
let mut block = [0x10u8; 16];
|
||||
ecb_encrypt_blocks(&K_FIXED, &mut block);
|
||||
hex_lower(&block)
|
||||
});
|
||||
TAIL.as_str()
|
||||
}
|
||||
|
||||
/// Emu-exact `ChallengeAccepted.response` (stp-origin_emu.dll 0x180001f10).
|
||||
///
|
||||
/// The emu computes only TWO AES blocks from the 32-ASCII client key, then
|
||||
/// `strcat_s`'s the client's OWN `response[64:]` verbatim (@0x1800020a9) -> 96
|
||||
/// hex. The older 3-block PKCS7 form is numerically identical *while the client
|
||||
/// PKCS7-pads its 3rd block* (REPACK_INTEL.md sec.0-A/0-B, workflow-confirmed
|
||||
/// byte-exact). We reproduce the emu exactly and, when the client's `response=`
|
||||
/// is available, echo its tail and assert the constant so a future client that
|
||||
/// randomises block 3 fails LOUDLY instead of silently.
|
||||
pub fn challenge_response(
|
||||
client_key_ascii: &str,
|
||||
client_response_attr: &str,
|
||||
) -> Result<String, CryptoError> {
|
||||
let mut buf = client_key_ascii.as_bytes().to_vec();
|
||||
if buf.is_empty() || buf.len() % 16 != 0 {
|
||||
// ECB cannot pad: the Python's AES.encrypt raises here too.
|
||||
return Err(CryptoError::NotBlockAligned(buf.len()));
|
||||
}
|
||||
ecb_encrypt_blocks(&K_FIXED, &mut buf);
|
||||
let two = hex_lower(&buf);
|
||||
|
||||
// Python slices `client_response_attr[64:]`, i.e. by characters.
|
||||
if let Some((idx, _)) = client_response_attr.char_indices().nth(64) {
|
||||
let tail = &client_response_attr[idx..];
|
||||
if tail != tail_const() {
|
||||
return Err(CryptoError::UnexpectedTail(tail.to_string()));
|
||||
}
|
||||
return Ok(two + tail);
|
||||
}
|
||||
if client_response_attr.chars().count() == 64 {
|
||||
// len(attr) >= 64 with an empty tail: the assert compares "" against the
|
||||
// constant and fails, exactly as it would here.
|
||||
return Err(CryptoError::UnexpectedTail(String::new()));
|
||||
}
|
||||
Ok(two + tail_const())
|
||||
}
|
||||
|
||||
/// pkcs7-pad to 16, AES-128-ECB, lowercase hex, NUL-terminated.
|
||||
pub fn lsx_encrypt(xml: &str, key: &Key) -> Vec<u8> {
|
||||
let body = xml.as_bytes();
|
||||
let pad = 16 - (body.len() % 16); // emu always pads (pad==16 when aligned)
|
||||
let mut buf = Vec::with_capacity(body.len() + pad);
|
||||
buf.extend_from_slice(body);
|
||||
buf.resize(body.len() + pad, pad as u8);
|
||||
ecb_encrypt_blocks(key, &mut buf);
|
||||
|
||||
// hex + NUL, sized up front: this runs on every frame we ever send.
|
||||
let mut out = Vec::with_capacity(buf.len() * 2 + 1);
|
||||
for b in &buf {
|
||||
out.push(HEX_DIGITS[usize::from(*b >> 4)]);
|
||||
out.push(HEX_DIGITS[usize::from(*b & 0x0f)]);
|
||||
}
|
||||
out.push(0);
|
||||
out
|
||||
}
|
||||
|
||||
/// Hex up to the first NUL, decrypt, then take up to the first NUL of the
|
||||
/// plaintext (the PKCS7 tail is stripped when it is well-formed, matching the
|
||||
/// Python's tolerant check).
|
||||
pub fn lsx_decrypt(data: &[u8], key: &Key) -> Result<String, CryptoError> {
|
||||
let head = match data.iter().position(|b| *b == 0) {
|
||||
Some(i) => &data[..i],
|
||||
None => data,
|
||||
};
|
||||
let mut raw = hex_decode(trim_ascii_ws(head))?;
|
||||
if raw.is_empty() {
|
||||
return Err(CryptoError::Empty);
|
||||
}
|
||||
if raw.len() % 16 != 0 {
|
||||
return Err(CryptoError::NotBlockAligned(raw.len()));
|
||||
}
|
||||
ecb_decrypt_blocks(key, &mut raw);
|
||||
|
||||
let pad = usize::from(raw[raw.len() - 1]);
|
||||
if pad > 0 && pad <= 16 && raw[raw.len() - pad..].iter().all(|c| usize::from(*c) == pad) {
|
||||
raw.truncate(raw.len() - pad);
|
||||
}
|
||||
if let Some(i) = raw.iter().position(|b| *b == 0) {
|
||||
raw.truncate(i);
|
||||
}
|
||||
// Python decodes with errors="replace".
|
||||
Ok(String::from_utf8_lossy(&raw).into_owned())
|
||||
}
|
||||
|
||||
const HEX_DIGITS: [u8; 16] = *b"0123456789abcdef";
|
||||
|
||||
/// Lowercase hex, like `bytes.hex()`.
|
||||
pub fn hex_lower(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push(char::from(HEX_DIGITS[usize::from(*b >> 4)]));
|
||||
s.push(char::from(HEX_DIGITS[usize::from(*b & 0x0f)]));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// `bytes.fromhex`: ASCII whitespace between bytes is skipped, everything else
|
||||
/// must be a hex digit pair.
|
||||
fn hex_decode(s: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
let mut hi: Option<u8> = None;
|
||||
for c in s {
|
||||
if c.is_ascii_whitespace() {
|
||||
continue;
|
||||
}
|
||||
let nib = match c {
|
||||
b'0'..=b'9' => c - b'0',
|
||||
b'a'..=b'f' => c - b'a' + 10,
|
||||
b'A'..=b'F' => c - b'A' + 10,
|
||||
_ => return Err(CryptoError::BadHex),
|
||||
};
|
||||
match hi.take() {
|
||||
None => hi = Some(nib),
|
||||
Some(h) => out.push((h << 4) | nib),
|
||||
}
|
||||
}
|
||||
if hi.is_some() {
|
||||
return Err(CryptoError::BadHex);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `bytes.strip()` -- ASCII whitespace at both ends.
|
||||
fn trim_ascii_ws(mut b: &[u8]) -> &[u8] {
|
||||
while let Some((first, rest)) = b.split_first() {
|
||||
if first.is_ascii_whitespace() {
|
||||
b = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some((last, rest)) = b.split_last() {
|
||||
if last.is_ascii_whitespace() {
|
||||
b = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn ecb_encrypt_blocks(key: &Key, buf: &mut [u8]) {
|
||||
debug_assert_eq!(buf.len() % 16, 0);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
for block in buf.chunks_exact_mut(16) {
|
||||
cipher.encrypt_block(GenericArray::from_mut_slice(block));
|
||||
}
|
||||
}
|
||||
|
||||
fn ecb_decrypt_blocks(key: &Key, buf: &mut [u8]) {
|
||||
debug_assert_eq!(buf.len() % 16, 0);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
for block in buf.chunks_exact_mut(16) {
|
||||
cipher.decrypt_block(GenericArray::from_mut_slice(block));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The captured client key from the 2026-07-30 session the Python's own
|
||||
/// selftest pins.
|
||||
const CAPTURED_CLIENT_KEY: &str = "18a70055a3541fb27ab8e0f47afad18c";
|
||||
const CAPTURED_H: &str = concat!(
|
||||
"e4f5166209929e156a2ca47b81cdd6bf",
|
||||
"6f2a20371532ed4f968c5a9274899dbf",
|
||||
"954f64f2e4e86e9eee82d20216684899"
|
||||
);
|
||||
const CAPTURED_SESSION_KEY: &str = "6a9da3e78615153cc2f10eec25ae6382";
|
||||
|
||||
#[test]
|
||||
fn msvcr_srand7_first_rand_is_61() {
|
||||
// The one vector the Python names in its docstring.
|
||||
assert_eq!(MsvcrRand::new(7).next_u15(), 61);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_const_is_the_emu_constant() {
|
||||
// AES128-ECB(K_FIXED, 0x10*16), pinned so a cipher/keying regression is
|
||||
// caught without a live client.
|
||||
assert_eq!(tail_const(), "954f64f2e4e86e9eee82d20216684899");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn k_fixed_is_000102_to_0f() {
|
||||
assert_eq!(hex_lower(&K_FIXED), "000102030405060708090a0b0c0d0e0f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_response_matches_captured_session() {
|
||||
let h = challenge_response(CAPTURED_CLIENT_KEY, "").unwrap();
|
||||
assert!(h.starts_with("e4f5166209929e15"), "{h}");
|
||||
assert_eq!(h, CAPTURED_H);
|
||||
assert_eq!(h.len(), 96);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_response_echoes_the_clients_own_tail() {
|
||||
// The emu strcat_s's response[64:] verbatim; identical result while the
|
||||
// client pads block 3 with the constant.
|
||||
let h = challenge_response(CAPTURED_CLIENT_KEY, CAPTURED_H).unwrap();
|
||||
assert_eq!(h, CAPTURED_H);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_response_rejects_a_randomised_third_block() {
|
||||
let bogus = format!("{}{}", &CAPTURED_H[..64], "00".repeat(16));
|
||||
let err = challenge_response(CAPTURED_CLIENT_KEY, &bogus).unwrap_err();
|
||||
assert_eq!(err, CryptoError::UnexpectedTail("00".repeat(16)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_response_rejects_unaligned_client_key() {
|
||||
let err = challenge_response("short", "").unwrap_err();
|
||||
assert_eq!(err, CryptoError::NotBlockAligned(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_key_derives_from_the_response() {
|
||||
let k = derive_session_key(CAPTURED_H);
|
||||
assert_eq!(hex_lower(&k), CAPTURED_SESSION_KEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_pins_the_first_login_frame() {
|
||||
let k = derive_session_key(CAPTURED_H);
|
||||
let frame = r#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#;
|
||||
let out = lsx_encrypt(frame, &k);
|
||||
assert_eq!(*out.last().unwrap(), 0, "frames are NUL-terminated");
|
||||
// Pinned against `openssl enc -aes-128-ecb -nopad` over the PKCS7-padded
|
||||
// frame under the captured session key: 62 bytes of XML -> pad 2 -> 4 blocks.
|
||||
assert_eq!(
|
||||
std::str::from_utf8(&out[..out.len() - 1]).unwrap(),
|
||||
concat!(
|
||||
"ded1180ab8a2ab85b7408cc009eb0191",
|
||||
"00b7b4c827fe0b9b4de2ca7834b3ed51",
|
||||
"31a0714a8eb66ba3e38d1855724b0a48",
|
||||
"09ad8616e71b3a78880c570c9af7ff80"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let k = derive_session_key(CAPTURED_H);
|
||||
for xml in [
|
||||
r#"<LSX><Response id="1" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX>"#,
|
||||
r#"<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||
"",
|
||||
] {
|
||||
assert_eq!(lsx_decrypt(&lsx_encrypt(xml, &k), &k).unwrap(), xml);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_of_block_aligned_plaintext_pads_a_whole_block() {
|
||||
let k = derive_session_key(CAPTURED_H);
|
||||
let xml = "0123456789abcdef"; // exactly 16 bytes
|
||||
let enc = lsx_encrypt(xml, &k);
|
||||
// 2 blocks (16 data + 16 pad) -> 64 hex chars + NUL.
|
||||
assert_eq!(enc.len(), 65);
|
||||
assert_eq!(lsx_decrypt(&enc, &k).unwrap(), xml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_ignores_everything_past_the_first_nul() {
|
||||
let k = derive_session_key(CAPTURED_H);
|
||||
let mut enc = lsx_encrypt("<LSX/>", &k);
|
||||
enc.extend_from_slice(b"deadbeef\0trailing");
|
||||
assert_eq!(lsx_decrypt(&enc, &k).unwrap(), "<LSX/>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_strips_surrounding_whitespace() {
|
||||
let k = derive_session_key(CAPTURED_H);
|
||||
let enc = lsx_encrypt("<LSX/>", &k);
|
||||
let mut padded = b" ".to_vec();
|
||||
padded.extend_from_slice(&enc[..enc.len() - 1]);
|
||||
padded.extend_from_slice(b"\r\n\0");
|
||||
assert_eq!(lsx_decrypt(&padded, &k).unwrap(), "<LSX/>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_rejects_garbage() {
|
||||
let k = derive_session_key(CAPTURED_H);
|
||||
assert_eq!(lsx_decrypt(b"zz\0", &k).unwrap_err(), CryptoError::BadHex);
|
||||
assert_eq!(lsx_decrypt(b"abc\0", &k).unwrap_err(), CryptoError::BadHex);
|
||||
assert_eq!(lsx_decrypt(b"\0", &k).unwrap_err(), CryptoError::Empty);
|
||||
assert_eq!(
|
||||
lsx_decrypt(b"00112233\0", &k).unwrap_err(),
|
||||
CryptoError::NotBlockAligned(4)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! PUSHED EVENTS -- the whole reason v2 exists, plus the per-connection state
|
||||
//! they need.
|
||||
//!
|
||||
//! Frame shape is identical to the server-initiated `<Challenge>` that already
|
||||
//! works, i.e. `<LSX><Event sender="..."><Element .../></Event></LSX>`. No `id`
|
||||
//! attribute (the Challenge has none; the matcher never reads one).
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
|
||||
use crate::crypto::{self, Key};
|
||||
use crate::{env_flag, log, ConfigError, Env};
|
||||
|
||||
/// `sender` is strcmp'd against the handler's registered service name. A mismatch
|
||||
/// is SILENTLY DROPPED -- it costs us nothing -- so we emit every candidate.
|
||||
///
|
||||
/// Event handlers are keyed on `serviceNames[facility]` (registrar 0x14710df80);
|
||||
/// with our empty GetConfigResponse those names are "", so the handlers expect
|
||||
/// `sender=""`. "" first; the named variants are harmless no-ops (dropped
|
||||
/// silently) and become correct once GetConfigResponse populates the table.
|
||||
/// "LOGIN_EVENT" is table index 14 (the one the event-handler factory uses) and
|
||||
/// "LOGIN" is index 8 (the plain service name); exactly one of the three will
|
||||
/// match.
|
||||
pub const LOGIN_EVENT_SENDERS: [&str; 3] = ["", "LOGIN_EVENT", "LOGIN"];
|
||||
pub const ONLINE_EVENT_SENDERS: [&str; 2] = ["", "ONLINE_STATUS_EVENT"];
|
||||
|
||||
pub fn event(sender: &str, element: &str) -> String {
|
||||
format!(r#"<LSX><Event sender="{sender}"><{element}/></Event></LSX>"#)
|
||||
}
|
||||
|
||||
/// The frames that flip `OriginMgr.m_isLoggedIn` ([OriginMgr+0x13]) to 1.
|
||||
///
|
||||
/// `IsLoggedIn` is parsed as `strcmp(v,"false") != 0`, so "true" -> TRUE. Keep the
|
||||
/// value literally "true" anyway: it is what a real Origin client sends and it
|
||||
/// keeps the log readable.
|
||||
pub fn login_event_frames() -> Vec<String> {
|
||||
let mut out = Vec::with_capacity(LOGIN_EVENT_SENDERS.len() + ONLINE_EVENT_SENDERS.len());
|
||||
out.extend(LOGIN_EVENT_SENDERS.iter().map(|s| event(s, r#"Login IsLoggedIn="true""#)));
|
||||
out.extend(
|
||||
ONLINE_EVENT_SENDERS
|
||||
.iter()
|
||||
.map(|s| event(s, r#"OnlineStatusEvent isOnline="true""#)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Event tuning.
|
||||
///
|
||||
/// Pushes are idempotent state notifications, so re-sending is harmless and is
|
||||
/// cheap insurance against FIFA registering its `<Login>` handler later than our
|
||||
/// first push. Set `OPENFUT_LSX_EVENTS=0` to fall back to v1 behaviour (useful as
|
||||
/// an A/B control if you want to prove the events are what moved the needle).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct EventConfig {
|
||||
/// `OPENFUT_LSX_EVENTS != "0"` (default on).
|
||||
pub enabled: bool,
|
||||
/// `OPENFUT_LSX_EVENT_PERIOD` seconds (default 5).
|
||||
pub period_secs: f64,
|
||||
/// `OPENFUT_LSX_EVENT_COUNT` heartbeat repeats (default 24).
|
||||
pub count: i64,
|
||||
/// EXPERIMENT (`OPENFUT_LSX_LOGIN_PLAINTEXT`, default off): push the Login
|
||||
/// Event in PLAINTEXT right after ChallengeAccepted (before the stream goes
|
||||
/// encrypted) instead of via the encrypted heartbeat. Tests the workflow's
|
||||
/// strongest remaining hypothesis -- that FIFA drops encrypted mid-session
|
||||
/// Events (the emu's only Event, the Challenge, is plaintext and pre-key).
|
||||
/// See REPACK_INTEL.md sec.4 step 2.
|
||||
pub login_plaintext: bool,
|
||||
}
|
||||
|
||||
impl Default for EventConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
period_secs: 5.0,
|
||||
count: 24,
|
||||
login_plaintext: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventConfig {
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
Self::from_vars(&crate::os_env)
|
||||
}
|
||||
|
||||
pub fn from_vars(env: Env<'_>) -> Result<Self, ConfigError> {
|
||||
let period_secs = match env("OPENFUT_LSX_EVENT_PERIOD") {
|
||||
Some(v) => v.trim().parse::<f64>().map_err(|_| ConfigError {
|
||||
var: "OPENFUT_LSX_EVENT_PERIOD",
|
||||
value: v.clone(),
|
||||
expected: "a number of seconds",
|
||||
})?,
|
||||
None => 5.0,
|
||||
};
|
||||
let count = match env("OPENFUT_LSX_EVENT_COUNT") {
|
||||
Some(v) => v.trim().parse::<i64>().map_err(|_| ConfigError {
|
||||
var: "OPENFUT_LSX_EVENT_COUNT",
|
||||
value: v.clone(),
|
||||
expected: "an integer",
|
||||
})?,
|
||||
None => 24,
|
||||
};
|
||||
Ok(Self {
|
||||
enabled: env_flag(env, "OPENFUT_LSX_EVENTS", true),
|
||||
period_secs,
|
||||
count,
|
||||
login_plaintext: env_flag(env, "OPENFUT_LSX_LOGIN_PLAINTEXT", false),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a period the way Python's `float` repr does, so the startup line reads
|
||||
/// `period=5.0s` and not `period=5s`.
|
||||
pub fn fmt_secs(secs: f64) -> String {
|
||||
if secs.is_finite() && secs.fract() == 0.0 && secs.abs() < 1e16 {
|
||||
format!("{secs:.1}")
|
||||
} else {
|
||||
format!("{secs}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Socket + session key + a send lock.
|
||||
///
|
||||
/// The lock matters: pushes come from a heartbeat thread while the request loop
|
||||
/// may be writing a Response. LSX frames are NUL-delimited, so two interleaved
|
||||
/// writes would corrupt the stream and the client would drop the connection
|
||||
/// (which would look exactly like a protocol bug).
|
||||
pub struct Conn {
|
||||
pub peer: SocketAddr,
|
||||
/// A `try_clone` of the accepted socket: the request loop reads from the
|
||||
/// original while this half is serialised behind the send lock.
|
||||
writer: Mutex<TcpStream>,
|
||||
key: RwLock<Option<Key>>,
|
||||
alive: AtomicBool,
|
||||
pushed_login: AtomicBool,
|
||||
/// Set once GetAuthCode has been issued, so the heartbeat stops re-pushing
|
||||
/// Login/OnlineStatus events. Re-pushing after the auth code is granted
|
||||
/// re-enters FIFA's state-mutating Origin event dispatcher (case 2
|
||||
/// @0x146f1e0ab sets m_isLoggedIn + clears loginError + rebroadcasts on the FE
|
||||
/// bus) ~24 more times DURING Blaze login, which we do not want.
|
||||
stop_events: AtomicBool,
|
||||
events: EventConfig,
|
||||
}
|
||||
|
||||
impl Conn {
|
||||
pub fn new(sock: &TcpStream, peer: SocketAddr, events: EventConfig) -> io::Result<Arc<Self>> {
|
||||
Ok(Arc::new(Self {
|
||||
peer,
|
||||
writer: Mutex::new(sock.try_clone()?),
|
||||
key: RwLock::new(None),
|
||||
alive: AtomicBool::new(true),
|
||||
pushed_login: AtomicBool::new(false),
|
||||
stop_events: AtomicBool::new(false),
|
||||
events,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn events(&self) -> &EventConfig {
|
||||
&self.events
|
||||
}
|
||||
|
||||
pub fn set_key(&self, key: Key) {
|
||||
*self.key.write() = Some(key);
|
||||
}
|
||||
|
||||
pub fn key(&self) -> Option<Key> {
|
||||
*self.key.read()
|
||||
}
|
||||
|
||||
// These flags are advisory hand-offs between the request loop and the
|
||||
// heartbeat thread; no data travels with them, so Relaxed is enough.
|
||||
pub fn is_alive(&self) -> bool {
|
||||
self.alive.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn mark_dead(&self) {
|
||||
self.alive.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn stop_events(&self) {
|
||||
self.stop_events.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn events_stopped(&self) -> bool {
|
||||
self.stop_events.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn send_plain(&self, xml: &str) -> io::Result<()> {
|
||||
let mut buf = Vec::with_capacity(xml.len() + 1);
|
||||
buf.extend_from_slice(xml.as_bytes());
|
||||
buf.push(0);
|
||||
self.writer.lock().write_all(&buf)
|
||||
}
|
||||
|
||||
pub fn send_enc(&self, xml: &str) -> io::Result<()> {
|
||||
let key = self
|
||||
.key()
|
||||
.ok_or_else(|| io::Error::other("session key not initialised"))?;
|
||||
let frame = crypto::lsx_encrypt(xml, &key);
|
||||
self.writer.lock().write_all(&frame)
|
||||
}
|
||||
|
||||
pub fn push_login_state(&self, why: &str) {
|
||||
if !self.events.enabled {
|
||||
return;
|
||||
}
|
||||
for frame in login_event_frames() {
|
||||
if let Err(e) = self.send_enc(&frame) {
|
||||
self.mark_dead();
|
||||
log!("push failed: {e}");
|
||||
return;
|
||||
}
|
||||
log!("PUSH ({why}) >> {frame}");
|
||||
}
|
||||
if !self.pushed_login.swap(true, Ordering::Relaxed) {
|
||||
log!(
|
||||
"*** first <Login IsLoggedIn=\"true\"> pushed. Watch for \
|
||||
GetAuthCode next. ***"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-push the login state a bounded number of times.
|
||||
///
|
||||
/// FIFA builds its Origin event handlers lazily; if our first push lands
|
||||
/// before the `<Login>` handler is registered the matcher simply finds no
|
||||
/// handler and drops it. Re-pushing removes that race without needing to
|
||||
/// guess the exact registration moment.
|
||||
pub fn heartbeat(&self) {
|
||||
let period = match Duration::try_from_secs_f64(self.events.period_secs) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
// Python's time.sleep() would raise here and kill just this
|
||||
// thread; the connection keeps serving requests either way.
|
||||
log!(
|
||||
"heartbeat disabled: OPENFUT_LSX_EVENT_PERIOD={} is not a usable delay ({e})",
|
||||
self.events.period_secs
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
for _ in 0..self.events.count.max(0) {
|
||||
std::thread::sleep(period);
|
||||
if !self.is_alive() || self.events_stopped() {
|
||||
return;
|
||||
}
|
||||
self.push_login_state("heartbeat");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::{derive_session_key, lsx_decrypt, lsx_encrypt};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let map: HashMap<String, String> = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
|
||||
.collect();
|
||||
move |k: &str| map.get(k).cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_are_the_five_candidate_pushes_with_the_empty_sender_first() {
|
||||
let frames = login_event_frames();
|
||||
assert_eq!(
|
||||
frames.len(),
|
||||
LOGIN_EVENT_SENDERS.len() + ONLINE_EVENT_SENDERS.len()
|
||||
);
|
||||
assert_eq!(
|
||||
frames,
|
||||
vec![
|
||||
r#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||
r#"<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||
r#"<LSX><Event sender="LOGIN"><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||
r#"<LSX><Event sender=""><OnlineStatusEvent isOnline="true"/></Event></LSX>"#,
|
||||
r#"<LSX><Event sender="ONLINE_STATUS_EVENT"><OnlineStatusEvent isOnline="true"/></Event></LSX>"#,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_round_trip_through_the_session_codec() {
|
||||
let k = derive_session_key(&crate::crypto::challenge_response(
|
||||
"18a70055a3541fb27ab8e0f47afad18c",
|
||||
"",
|
||||
)
|
||||
.unwrap());
|
||||
for f in login_event_frames() {
|
||||
assert_eq!(lsx_decrypt(&lsx_encrypt(&f, &k), &k).unwrap(), f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_python() {
|
||||
let cfg = EventConfig::from_vars(&env_of(&[])).unwrap();
|
||||
assert_eq!(cfg, EventConfig::default());
|
||||
assert!(cfg.enabled);
|
||||
assert_eq!(cfg.period_secs, 5.0);
|
||||
assert_eq!(cfg.count, 24);
|
||||
assert!(!cfg.login_plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_are_disabled_only_by_the_literal_zero() {
|
||||
for (value, enabled) in [("0", false), ("1", true), ("", true), ("false", true)] {
|
||||
let cfg = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENTS", value)])).unwrap();
|
||||
assert_eq!(cfg.enabled, enabled, "OPENFUT_LSX_EVENTS={value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_plaintext_is_enabled_by_anything_but_zero() {
|
||||
for (value, on) in [("0", false), ("1", true), ("", true), ("no", true)] {
|
||||
let cfg =
|
||||
EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_LOGIN_PLAINTEXT", value)])).unwrap();
|
||||
assert_eq!(cfg.login_plaintext, on, "OPENFUT_LSX_LOGIN_PLAINTEXT={value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn period_and_count_are_overridable() {
|
||||
let cfg = EventConfig::from_vars(&env_of(&[
|
||||
("OPENFUT_LSX_EVENT_PERIOD", "0.25"),
|
||||
("OPENFUT_LSX_EVENT_COUNT", "3"),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(cfg.period_secs, 0.25);
|
||||
assert_eq!(cfg.count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_period_is_refused() {
|
||||
let err = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENT_PERIOD", "soon")]))
|
||||
.unwrap_err();
|
||||
assert_eq!(err.var, "OPENFUT_LSX_EVENT_PERIOD");
|
||||
let err =
|
||||
EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENT_COUNT", "many")])).unwrap_err();
|
||||
assert_eq!(err.var, "OPENFUT_LSX_EVENT_COUNT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn period_renders_like_a_python_float() {
|
||||
assert_eq!(fmt_secs(5.0), "5.0");
|
||||
assert_eq!(fmt_secs(0.25), "0.25");
|
||||
assert_eq!(fmt_secs(0.0), "0.0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! The identity LSX reports, ported from `fut_account.py`'s tier-1/tier-2 fields.
|
||||
//!
|
||||
//! SOURCED FROM `fut_account.ACCOUNT`, shared with blaze_responder_v3b.py,
|
||||
//! fut_store.py, fut_seed.py and utas_server.py.
|
||||
//!
|
||||
//! THE CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE: what LSX
|
||||
//! reports here must equal what Blaze returns in LoginResponse.SESS.PDTL and what
|
||||
//! UTAS serves as userInfo.personaId. (An older comment blamed a mismatch for
|
||||
//! AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA /
|
||||
//! AUTH_ERR_PERSONA_NOT_FOUND -- those are Blaze *server* error codes and we are
|
||||
//! the server. Neither "CAGE" nor "33068179" appears in FIFA17.exe, CardsDLL or
|
||||
//! dbdata.dll; 33068179 lives only in stp-origin_emu.dll's own ini default. They
|
||||
//! stay the defaults because they are what the working stack asserts.)
|
||||
//!
|
||||
//! PRECEDENCE: env var > built-in default.
|
||||
//!
|
||||
//! `fut_account.py` sits one tier deeper -- env > `fut_account.json` > default --
|
||||
//! but the JSON is deliberately NOT read here: its persisted values for the three
|
||||
//! fields LSX uses (`persona_id`, `persona_name`; `locale` is not even stored) are
|
||||
//! identical to the built-in defaults, and the launcher always passes
|
||||
//! `FUT_PERSONA_ID`/`FUT_PERSONA_NAME` explicitly when it spawns us
|
||||
//! (openfut-launcher/src/local_services.rs), so env decides in every real run.
|
||||
//! Parsing a JSON file to reach the same answer would only add a failure mode --
|
||||
//! and the launcher, not a file next to the Python tools, is the identity owner
|
||||
//! for this binary.
|
||||
|
||||
use crate::{ConfigError, Env};
|
||||
|
||||
// ------------------------------------------------------------------ tier 1
|
||||
// LOCKED WIRE CONSTANTS. No env override on purpose: these are not preferences.
|
||||
|
||||
/// FIFA 17 EA offer id (retail).
|
||||
pub const CONTENT_ID: &str = "1027460";
|
||||
|
||||
/// `TRIAL_ONLINE_ACCESS` for FIFA17_Trial.exe; retail uses this.
|
||||
pub const ENTITLEMENT_TAG: &str = "ONLINE_ACCESS";
|
||||
|
||||
// ------------------------------------------------------------------ tier 2
|
||||
pub const DEFAULT_PERSONA_ID: i64 = 33068179;
|
||||
pub const DEFAULT_PERSONA_NAME: &str = "CAGE";
|
||||
pub const DEFAULT_LOCALE: &str = "en_US";
|
||||
|
||||
/// The identity fields LSX puts on the wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Identity {
|
||||
/// Blaze SESS.BUID / SESS.UID / PDTL.PID, LSX PersonaId/UserId, UTAS
|
||||
/// userInfo.personaId and squad.personaId.
|
||||
pub persona_id: i64,
|
||||
/// Blaze PDTL.DSNM / LSX GetProfileResponse Persona / UTAS sellerName.
|
||||
pub persona_name: String,
|
||||
/// LSX `GetSetting LANGUAGE`.
|
||||
pub locale: String,
|
||||
}
|
||||
|
||||
impl Default for Identity {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
persona_id: DEFAULT_PERSONA_ID,
|
||||
persona_name: DEFAULT_PERSONA_NAME.to_string(),
|
||||
locale: DEFAULT_LOCALE.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
/// Read the process environment.
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
Self::from_vars(&crate::os_env)
|
||||
}
|
||||
|
||||
/// A `FUT_PERSONA_ID` that `int()` would reject kills `fut_account.py` at
|
||||
/// import; we refuse to start for the same reason, rather than serve one
|
||||
/// layer of the stack a silently different persona.
|
||||
pub fn from_vars(env: Env<'_>) -> Result<Self, ConfigError> {
|
||||
let persona_id = match env("FUT_PERSONA_ID") {
|
||||
// `int()` tolerates surrounding whitespace and a sign.
|
||||
Some(v) => v.trim().parse::<i64>().map_err(|_| ConfigError {
|
||||
var: "FUT_PERSONA_ID",
|
||||
value: v.clone(),
|
||||
expected: "an integer",
|
||||
})?,
|
||||
None => DEFAULT_PERSONA_ID,
|
||||
};
|
||||
Ok(Self {
|
||||
persona_id,
|
||||
persona_name: env("FUT_PERSONA_NAME").unwrap_or_else(|| DEFAULT_PERSONA_NAME.into()),
|
||||
locale: env("FUT_LOCALE").unwrap_or_else(|| DEFAULT_LOCALE.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Blaze blazeId / userId (SESS.BUID, SESS.UID, AccountInfo.UID).
|
||||
///
|
||||
/// DERIVED, read-only, and deliberately NOT an independent knob: the client
|
||||
/// sends both `nuc` and `nucleusPersonaId` and both came out equal, so the
|
||||
/// getter->field mapping is undetermined. Do not split them until a live test
|
||||
/// proves Blaze USER_ID may legitimately differ from PERSONA_ID.
|
||||
pub fn user_id(&self) -> i64 {
|
||||
self.persona_id
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let map: HashMap<String, String> = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
|
||||
.collect();
|
||||
move |k: &str| map.get(k).cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_the_working_stacks_values() {
|
||||
let id = Identity::from_vars(&env_of(&[])).unwrap();
|
||||
assert_eq!(id.persona_id, 33068179);
|
||||
assert_eq!(id.persona_name, "CAGE");
|
||||
assert_eq!(id.locale, "en_US");
|
||||
assert_eq!(id.user_id(), id.persona_id);
|
||||
assert_eq!(id, Identity::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_wins_over_defaults() {
|
||||
let id = Identity::from_vars(&env_of(&[
|
||||
("FUT_PERSONA_ID", "1234567"),
|
||||
("FUT_PERSONA_NAME", "OTHER"),
|
||||
("FUT_LOCALE", "de_DE"),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(id.persona_id, 1234567);
|
||||
assert_eq!(id.persona_name, "OTHER");
|
||||
assert_eq!(id.locale, "de_DE");
|
||||
assert_eq!(id.user_id(), 1234567);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_env_value_still_wins() {
|
||||
// `os.environ.get` returns "" for `FUT_PERSONA_NAME=`, and "" is not None.
|
||||
let id = Identity::from_vars(&env_of(&[("FUT_PERSONA_NAME", "")])).unwrap();
|
||||
assert_eq!(id.persona_name, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_around_the_persona_id_is_tolerated_like_int() {
|
||||
let id = Identity::from_vars(&env_of(&[("FUT_PERSONA_ID", " 42 ")])).unwrap();
|
||||
assert_eq!(id.persona_id, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_numeric_persona_id_is_refused() {
|
||||
let err = Identity::from_vars(&env_of(&[("FUT_PERSONA_ID", "CAGE")])).unwrap_err();
|
||||
assert_eq!(err.var, "FUT_PERSONA_ID");
|
||||
assert_eq!(err.to_string(), r#"FUT_PERSONA_ID must be an integer (got "CAGE")"#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! OpenFUT clean-room LSX responder for FIFA 17 -- Rust port of
|
||||
//! `fifa17-recon/tools/lsx_responder_v2.py` (v2, EVENT-PUSHING).
|
||||
//!
|
||||
//! v1 (`lsx_responder.py`) was REQUEST-DRIVEN ONLY. It answered every verb the
|
||||
//! client asked for and never sent an unsolicited frame. That is exactly why the
|
||||
//! client never issued GetAuthCode and never sent Blaze Authentication::login.
|
||||
//!
|
||||
//! THE ORIGIN SDK HAS TWO INDEPENDENT FLAGS, FED BY TWO DIFFERENT MECHANISMS:
|
||||
//!
|
||||
//! 1. "internet is reachable" -> OriginMgr online byte [0x1448a3ac0],
|
||||
//! fed by the REQUEST verb `GetInternetConnectedState -> connected="1"`
|
||||
//! (v1 already beat this; live-confirmed == 1).
|
||||
//! 2. "a user is LOGGED IN" -> `OriginMgr.m_isLoggedIn` [OriginMgr+0x13],
|
||||
//! fed ONLY by a server-PUSHED `<Event sender="LOGIN_EVENT"><Login/>`.
|
||||
//! There is NO request verb that can set it.
|
||||
//!
|
||||
//! v1 fed (1) and never fed (2), so `m_isLoggedIn` was 0 for the whole session,
|
||||
//! FIFA never enqueued an auth-code request into FirstPartyAuthTokenRetriever
|
||||
//! (both request slots live-read as 0x0), DoTick @0x146f199c0 exited immediately,
|
||||
//! OriginRequestAuthCodeSync @0x1470db3c0 was never called, LoginRequest.AUTH
|
||||
//! could never be filled -> no Blaze login -> "Unable to retrieve account
|
||||
//! information."
|
||||
//!
|
||||
//! The binary evidence for the push (dispatcher @0x146f1e060 case 2, the `<Login>`
|
||||
//! matcher @0x147102880 and its silent sender strcmp, the service-name tables at
|
||||
//! 0x144341420 / sdk+0x3b0, the `strcmp(v,"false")` truthiness of `IsLoggedIn`)
|
||||
//! lives in the Python's module docstring; the specific facts that constrain code
|
||||
//! are repeated at the site that depends on them. Nothing here is derived from
|
||||
//! the 2021 EA/FIFA leak: every constant came from our own static+dynamic
|
||||
//! analysis of binaries we own plus traffic we captured ourselves.
|
||||
//!
|
||||
//! Transport: TCP `127.0.0.1:4216`, every message NUL-terminated (send strlen+1).
|
||||
|
||||
pub mod crypto;
|
||||
pub mod events;
|
||||
pub mod identity;
|
||||
pub mod protocol;
|
||||
|
||||
/// Loopback port the Steampunks Origin emulator stub binds; we must own it
|
||||
/// BEFORE FIFA 17 starts so the stub's own `bind()` fails.
|
||||
pub const LSX_PORT: u16 = 4216;
|
||||
|
||||
/// Emu's own advertised challenge (any 32 hex chars work; the client echoes it
|
||||
/// back).
|
||||
pub const CHALLENGE_KEY: &str = "2b8ee7faea76e8a34f5f5d20e5328e32";
|
||||
pub const BUILD: &str = "release";
|
||||
pub const VERSION: &str = "10,4,13,6637";
|
||||
|
||||
/// Success-signal files the run's watch steps poll. Written only from a REAL
|
||||
/// GetAuthCode on a live connection.
|
||||
pub const AUTHCODE_FILE: &str = "/tmp/openfut_authcode.txt";
|
||||
pub const CLIENTID_FILE: &str = "/tmp/openfut_lsx_clientid.txt";
|
||||
|
||||
/// How the modules read environment knobs. Indirected through a closure so the
|
||||
/// tests can pin an environment without mutating the process (env vars are
|
||||
/// process-global and `cargo test` runs threads in parallel).
|
||||
pub type Env<'a> = &'a dyn Fn(&str) -> Option<String>;
|
||||
|
||||
/// The real environment. Present-but-empty is Some(""), which matters: the
|
||||
/// Python's `os.environ.get(...)` returns "" for `FOO=`, and "" is not None, so
|
||||
/// an empty setting WINS over the built-in default.
|
||||
pub fn os_env(name: &str) -> Option<String> {
|
||||
std::env::var_os(name).map(|v| v.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// The Python's recurring `os.environ.get(NAME, "0") != "0"` rule: ANY value
|
||||
/// other than the literal "0" enables the knob (including the empty string).
|
||||
pub fn env_flag(env: Env<'_>, name: &str, default_on: bool) -> bool {
|
||||
match env(name) {
|
||||
Some(v) => v != "0",
|
||||
None => default_on,
|
||||
}
|
||||
}
|
||||
|
||||
/// An env knob was set to something the Python's own `int()`/`float()` would
|
||||
/// reject. The Python dies at import in that case; we refuse to start for the
|
||||
/// same reason -- a silently different persona, or a silently absent heartbeat,
|
||||
/// is worse than a loud failure.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigError {
|
||||
pub var: &'static str,
|
||||
pub value: String,
|
||||
pub expected: &'static str,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{} must be {} (got {:?})",
|
||||
self.var, self.expected, self.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
/// Every diagnostic line carries the `[lsx] ` prefix and is flushed per line:
|
||||
/// the launcher pipes our stdout+stderr into its log buffer and parses some of
|
||||
/// these lines, so the formats are a contract.
|
||||
#[macro_export]
|
||||
macro_rules! log {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write;
|
||||
let mut out = std::io::stdout().lock();
|
||||
let _ = writeln!(out, "[lsx] {}", format_args!($($arg)*));
|
||||
let _ = out.flush();
|
||||
}};
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! `openfut-lsx` -- the LSX responder process the launcher spawns.
|
||||
//!
|
||||
//! USAGE: bind BEFORE launching FIFA 17 so the Steampunks stub's `bind()` fails.
|
||||
//! This process does NOT auto-start anything; the launcher owns processes.
|
||||
|
||||
use std::io::{self, Read};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
|
||||
use openfut_lsx::crypto;
|
||||
use openfut_lsx::events::{fmt_secs, login_event_frames, Conn, EventConfig};
|
||||
use openfut_lsx::identity::Identity;
|
||||
use openfut_lsx::protocol::{
|
||||
parse_request, push_after, py_repr, resp, safe_xml_for_log, Attrs, ProtocolConfig, Responder,
|
||||
};
|
||||
use openfut_lsx::{log, os_env, BUILD, CHALLENGE_KEY, LSX_PORT, VERSION};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
if std::env::args().skip(1).any(|a| a == "--selftest") {
|
||||
return match selftest() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
log!("SELFTEST FAILED: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
};
|
||||
}
|
||||
match serve_forever() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
log!("fatal: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_forever() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let identity = Identity::from_env()?;
|
||||
let events = EventConfig::from_env()?;
|
||||
let responder = Arc::new(Responder::new(identity, ProtocolConfig::from_env()));
|
||||
|
||||
// Readiness IS the bind: nothing is logged until 4216 is ours, so the launcher
|
||||
// never reads a ready line for a listener that does not exist. `TcpListener::bind`
|
||||
// already sets SO_REUSEADDR on every non-Windows target, which is the Python's
|
||||
// explicit `setsockopt(SO_REUSEADDR, 1)` -- do not reach for socket2 to re-add it.
|
||||
let bind_host = os_env("OPENFUT_BIND").unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let listener = TcpListener::bind((bind_host.as_str(), LSX_PORT))?;
|
||||
|
||||
// Literal "127.0.0.1:4216" even under OPENFUT_BIND, as the Python logs it: this
|
||||
// line is what the launcher watches for.
|
||||
log!("v2 listening on 127.0.0.1:{LSX_PORT} (start FIFA 17 now)");
|
||||
log!(
|
||||
"login-state event push: {} (period={}s count={})",
|
||||
if events.enabled { "ENABLED" } else { "DISABLED" },
|
||||
fmt_secs(events.period_secs),
|
||||
events.count
|
||||
);
|
||||
|
||||
for stream in listener.incoming() {
|
||||
let stream = match stream {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log!("accept failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let peer = stream
|
||||
.peer_addr()
|
||||
.unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0)));
|
||||
log!("connection from {}", py_addr(peer));
|
||||
let responder = Arc::clone(&responder);
|
||||
let events = events.clone();
|
||||
std::thread::spawn(move || serve(stream, peer, &responder, events));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Python prints the accept tuple, e.g. `('127.0.0.1', 54321)`.
|
||||
fn py_addr(peer: SocketAddr) -> String {
|
||||
format!("('{}', {})", peer.ip(), peer.port())
|
||||
}
|
||||
|
||||
fn serve(mut sock: TcpStream, peer: SocketAddr, responder: &Responder, events: EventConfig) {
|
||||
let conn = match Conn::new(&sock, peer, events) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log!("connection error: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = session(&mut sock, &conn, responder) {
|
||||
log!("connection error: {e}");
|
||||
}
|
||||
conn.mark_dead();
|
||||
// Dropping our halves closes the socket; the heartbeat thread notices on its
|
||||
// next tick (and its own send would fail regardless).
|
||||
drop(sock);
|
||||
log!("connection closed {}", py_addr(peer));
|
||||
}
|
||||
|
||||
fn session(
|
||||
sock: &mut TcpStream,
|
||||
conn: &Arc<Conn>,
|
||||
responder: &Responder,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. plaintext Challenge
|
||||
conn.send_plain(&format!(
|
||||
r#"<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" build="{BUILD}" version="{VERSION}"/></Event></LSX>"#
|
||||
))?;
|
||||
|
||||
// 2. plaintext ChallengeResponse from the client. The emu parses `response="`
|
||||
// BEFORE `key="` (0x180001f10); extract both so challenge_response can echo
|
||||
// the client's own 3rd block (REPACK_INTEL.md C1/C2).
|
||||
let mut first = [0u8; 4096];
|
||||
let n = sock.read(&mut first)?;
|
||||
let txt = String::from_utf8_lossy(&first[..n]);
|
||||
let client_key = first_attr(&txt, "key").unwrap_or(CHALLENGE_KEY);
|
||||
let client_resp = first_attr(&txt, "response").unwrap_or("");
|
||||
let h = crypto::challenge_response(client_key, client_resp)?;
|
||||
conn.set_key(crypto::derive_session_key(&h));
|
||||
log!("handshake accepted; session crypto initialized");
|
||||
|
||||
// 3. plaintext ChallengeAccepted
|
||||
conn.send_plain(&resp(
|
||||
"1",
|
||||
&format!(r#"ChallengeAccepted response="{h}""#),
|
||||
"EALS",
|
||||
))?;
|
||||
|
||||
// 3b. EXPERIMENT (OPENFUT_LSX_LOGIN_PLAINTEXT=1): the shipped emu's ONLY
|
||||
// unsolicited Event is the plaintext, pre-session-key Challenge; there is
|
||||
// zero evidence an *encrypted mid-session* Event routes to the same parser
|
||||
// (REPACK_INTEL.md sec.4 step 2). So push the Login Event here, in
|
||||
// PLAINTEXT, right after ChallengeAccepted -- before the stream goes
|
||||
// encrypted -- and suppress the encrypted heartbeat to keep the A/B clean.
|
||||
if conn.events().login_plaintext && conn.events().enabled {
|
||||
conn.stop_events();
|
||||
for frame in login_event_frames() {
|
||||
conn.send_plain(&frame)?;
|
||||
log!("PUSH (plaintext post-accept) >> {frame}");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. encrypted request/response loop
|
||||
let mut heartbeat_started = false;
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 65536];
|
||||
loop {
|
||||
let n = sock.read(&mut chunk)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
// Buffer partial frames: a 64 KiB read can straddle a NUL boundary, and
|
||||
// splitting without keeping the remainder would silently drop the trailing
|
||||
// partial (C3).
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
let complete = match buf.iter().rposition(|b| *b == 0) {
|
||||
Some(i) => i + 1,
|
||||
None => continue,
|
||||
};
|
||||
let frames: Vec<Vec<u8>> = buf[..complete]
|
||||
.split(|b| *b == 0)
|
||||
.filter(|f| !f.is_empty())
|
||||
.map(<[u8]>::to_vec)
|
||||
.collect();
|
||||
buf.drain(..complete);
|
||||
|
||||
for frame in frames {
|
||||
let key = conn.key().expect("session key set during the handshake");
|
||||
let xml = match crypto::lsx_decrypt(&frame, &key) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
log!("decrypt fail: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(req) = parse_request(&xml) else {
|
||||
log!("<< {}", safe_xml_for_log(&xml));
|
||||
continue;
|
||||
};
|
||||
let reply = responder.build_reply(req.id, req.name, &req.attrs, Some(conn), req.recipient);
|
||||
log!(
|
||||
"<< id={} {} recipient={} {}",
|
||||
req.id,
|
||||
req.name,
|
||||
py_repr(req.recipient),
|
||||
req.attrs.py_repr()
|
||||
);
|
||||
log!(">> {}", safe_xml_for_log(&reply));
|
||||
conn.send_enc(&reply)?;
|
||||
|
||||
if let Some(why) = push_after(req.name) {
|
||||
if conn.events().enabled
|
||||
// For GetGameInfo only fire on UPTODATE, otherwise we would push
|
||||
// three times per boot for FREETRIAL/LANGUAGES too.
|
||||
&& (req.name != "GetGameInfo"
|
||||
|| req.attrs.get("GameInfoId") == Some("UPTODATE"))
|
||||
{
|
||||
conn.push_login_state(why);
|
||||
if !heartbeat_started {
|
||||
heartbeat_started = true;
|
||||
let conn = Arc::clone(conn);
|
||||
std::thread::spawn(move || conn.heartbeat());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `re.search(r'name="([^"]*)"', txt)` -- the first occurrence anywhere in the
|
||||
/// plaintext handshake frame, with no word-boundary requirement (which is why the
|
||||
/// needle keeps its `="`).
|
||||
fn first_attr<'a>(txt: &'a str, name: &str) -> Option<&'a str> {
|
||||
let needle = format!("{name}=\"");
|
||||
let start = txt.find(&needle)? + needle.len();
|
||||
let len = txt[start..].find('"')?;
|
||||
Some(&txt[start..start + len])
|
||||
}
|
||||
|
||||
/// No live game needed. Proves the crypto is untouched and the event frames
|
||||
/// encrypt/decrypt cleanly through our own codec.
|
||||
fn selftest() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let h = crypto::challenge_response("18a70055a3541fb27ab8e0f47afad18c", "")?;
|
||||
check(h.starts_with("e4f5166209929e15"), &h)?;
|
||||
let k = crypto::derive_session_key(&h);
|
||||
let k_hex = crypto::hex_lower(&k);
|
||||
check(k_hex == "6a9da3e78615153cc2f10eec25ae6382", &k_hex)?;
|
||||
println!("[ok] crypto matches the captured 2026-07-30 session verbatim");
|
||||
|
||||
let frames = login_event_frames();
|
||||
check(
|
||||
frames.len()
|
||||
== openfut_lsx::events::LOGIN_EVENT_SENDERS.len()
|
||||
+ openfut_lsx::events::ONLINE_EVENT_SENDERS.len(),
|
||||
&format!("{} frames", frames.len()),
|
||||
)?;
|
||||
for f in &frames {
|
||||
let round = crypto::lsx_decrypt(&crypto::lsx_encrypt(f, &k), &k)?;
|
||||
check(round == *f, &round)?;
|
||||
println!("[ok] round-trip: {f}");
|
||||
}
|
||||
// "" sender first (correct for the current empty service-name table)
|
||||
check(
|
||||
frames[0].contains(r#"<Event sender=""><Login IsLoggedIn="true"/></Event>"#),
|
||||
&frames[0],
|
||||
)?;
|
||||
|
||||
// conn=None: the selftest must not write the run's success-signal files.
|
||||
let responder = Responder::new(Identity::from_env()?, ProtocolConfig::from_env());
|
||||
let attrs: Attrs = [("ClientId", "X"), ("Scope", "Y")].into_iter().collect();
|
||||
let r = responder.build_reply("42", "GetAuthCode", &attrs, None, "");
|
||||
// `value` is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0)
|
||||
// actually reads; Code=/Return= are legacy padding.
|
||||
check(r.contains("<AuthCode value="), &r)?;
|
||||
|
||||
let redacted = safe_xml_for_log(r#"<AuthCode value="secret" Code="secret" Return="secret"/>"#);
|
||||
check(
|
||||
!redacted.contains("secret") && redacted.matches("[REDACTED]").count() == 3,
|
||||
&redacted,
|
||||
)?;
|
||||
let status = safe_xml_for_log(r#"<ErrorSuccess Code="0" Description=""/>"#);
|
||||
check(status.contains(r#"Code="0""#), &status)?;
|
||||
println!("[ok] GetAuthCode response shape and log redaction");
|
||||
println!("[ok] selftest passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Python's `assert cond, value`.
|
||||
fn check(cond: bool, value: &str) -> Result<(), io::Error> {
|
||||
if cond {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other(format!("assertion failed: {value}")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
//! Request parsing, verb dispatch and log redaction.
|
||||
//!
|
||||
//! The dispatch is request-DRIVEN (the Steampunks stub was a blind fixed script),
|
||||
//! and the frame grammar is the Python's three regexes, transcribed by hand so the
|
||||
//! crate needs no regex engine. Where the regexes are tolerant, this is tolerant
|
||||
//! in the same way -- see [`parse_request`].
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
|
||||
use crate::events::Conn;
|
||||
use crate::identity::{Identity, CONTENT_ID, ENTITLEMENT_TAG};
|
||||
use crate::{env_flag, log, Env, AUTHCODE_FILE, CLIENTID_FILE};
|
||||
|
||||
/// Attributes of the request's child element, in the order they appeared.
|
||||
///
|
||||
/// Python builds `dict(ATTR_RE.findall(rest))`: a repeated attribute keeps its
|
||||
/// FIRST position but takes its LAST value, and that dict is echoed into the
|
||||
/// `<< id=...` log line, so the ordering is observable.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Attrs<'a>(Vec<(&'a str, &'a str)>);
|
||||
|
||||
impl<'a> Attrs<'a> {
|
||||
pub fn new() -> Self {
|
||||
Self(Vec::new())
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, key: &'a str, value: &'a str) {
|
||||
match self.0.iter_mut().find(|(k, _)| *k == key) {
|
||||
Some(slot) => slot.1 = value,
|
||||
None => self.0.push((key, value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&'a str> {
|
||||
self.0.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
|
||||
}
|
||||
|
||||
/// `attrs.get(key, "")`.
|
||||
pub fn get_or_empty(&self, key: &str) -> &'a str {
|
||||
self.get(key).unwrap_or("")
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
|
||||
self.0.iter().copied()
|
||||
}
|
||||
|
||||
/// `repr(dict)`, because the request log line embeds it verbatim.
|
||||
pub fn py_repr(&self) -> String {
|
||||
let mut s = String::from("{");
|
||||
for (i, (k, v)) in self.0.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str(", ");
|
||||
}
|
||||
let _ = write!(s, "{}: {}", py_repr(k), py_repr(v));
|
||||
}
|
||||
s.push('}');
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FromIterator<(&'a str, &'a str)> for Attrs<'a> {
|
||||
fn from_iter<T: IntoIterator<Item = (&'a str, &'a str)>>(iter: T) -> Self {
|
||||
let mut attrs = Attrs::new();
|
||||
for (k, v) in iter {
|
||||
attrs.insert(k, v);
|
||||
}
|
||||
attrs
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed `<Request>` frame.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Request<'a> {
|
||||
/// The digits of `id="N"`, kept as text: it is echoed, never arithmetic.
|
||||
pub id: &'a str,
|
||||
pub name: &'a str,
|
||||
pub attrs: Attrs<'a>,
|
||||
/// The response `sender` must byte-equal this (matcher 0x1471189b0). Captured
|
||||
/// separately from the rest of the frame, and defaulting to "", so a frame
|
||||
/// that ever lacks `recipient` still gets answered fast instead of a 15s
|
||||
/// stall.
|
||||
pub recipient: &'a str,
|
||||
}
|
||||
|
||||
/// `REQ_RE = r'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>'` plus
|
||||
/// `RECIP_RE = r'<Request[^>]*\brecipient="([^"]*)"'`, both as `re.search`.
|
||||
///
|
||||
/// Transcription notes, all of them observable behaviour rather than style:
|
||||
/// * `[^>]*` cannot cross a `>`, so `id` must live inside the `<Request ...>`
|
||||
/// tag itself; the tag must be closed for the pattern to match at all.
|
||||
/// * that leading `[^>]*` is greedy, so with more than one `id="N"` in the tag
|
||||
/// the RIGHTMOST one wins.
|
||||
/// * the trailing `([^>]*)/?>` is greedy too, so the captured attribute region
|
||||
/// keeps a self-closing `/` -- harmless, `ATTR_RE` skips it.
|
||||
/// * `recipient` is searched over the whole frame independently of the element
|
||||
/// match, and its value may legally contain `>`.
|
||||
pub fn parse_request(xml: &str) -> Option<Request<'_>> {
|
||||
const TAG: &str = "<Request";
|
||||
let mut from = 0;
|
||||
while let Some(off) = xml[from..].find(TAG) {
|
||||
let start = from + off;
|
||||
from = start + TAG.len();
|
||||
let tag_end = match xml[from..].find('>') {
|
||||
Some(i) => from + i,
|
||||
None => continue, // `[^>]*>` needs the tag to close
|
||||
};
|
||||
let Some(id) = rightmost_id(xml, from, tag_end) else {
|
||||
continue;
|
||||
};
|
||||
// `>\s*<`
|
||||
let child = xml[tag_end + 1..].trim_start_matches(char::is_whitespace);
|
||||
let Some(child) = child.strip_prefix('<') else {
|
||||
continue;
|
||||
};
|
||||
let name_len = child
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.take_while(|c| c.is_ascii_alphabetic())
|
||||
.count();
|
||||
if name_len == 0 {
|
||||
continue;
|
||||
}
|
||||
let (name, tail) = child.split_at(name_len);
|
||||
let Some(gt) = tail.find('>') else {
|
||||
continue;
|
||||
};
|
||||
return Some(Request {
|
||||
id,
|
||||
name,
|
||||
attrs: parse_attrs(&tail[..gt]),
|
||||
recipient: find_recipient(xml).unwrap_or(""),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The rightmost `\bid="(\d+)"` inside `xml[from..tag_end]`.
|
||||
fn rightmost_id(xml: &str, from: usize, tag_end: usize) -> Option<&str> {
|
||||
let tag = &xml[from..tag_end];
|
||||
let mut search_end = tag.len();
|
||||
while let Some(at) = tag[..search_end].rfind("id=\"") {
|
||||
search_end = at;
|
||||
// `\b`: the character before `id` must not be a word character. Look at
|
||||
// the whole frame, so `<Requestid="1">` correctly fails.
|
||||
if !word_boundary_before(xml, from + at) {
|
||||
continue;
|
||||
}
|
||||
let value = &tag[at + 4..];
|
||||
let digits = value.as_bytes().iter().take_while(|c| c.is_ascii_digit()).count();
|
||||
if digits > 0 && value.as_bytes().get(digits) == Some(&b'"') {
|
||||
return Some(&value[..digits]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_recipient(xml: &str) -> Option<&str> {
|
||||
const TAG: &str = "<Request";
|
||||
let mut from = 0;
|
||||
while let Some(off) = xml[from..].find(TAG) {
|
||||
from += off + TAG.len();
|
||||
// The literal must start before the tag closes; the VALUE may run past it.
|
||||
let limit = xml[from..].find('>').map_or(xml.len(), |i| from + i);
|
||||
let mut search_end = limit;
|
||||
while let Some(at) = xml[from..search_end].rfind("recipient=\"") {
|
||||
let at = from + at;
|
||||
search_end = at;
|
||||
if !word_boundary_before(xml, at) {
|
||||
continue;
|
||||
}
|
||||
let value_start = at + "recipient=\"".len();
|
||||
if let Some(len) = xml[value_start..].find('"') {
|
||||
return Some(&xml[value_start..value_start + len]);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `ATTR_RE.findall` -- `(\w+)="([^"]*)"`, non-overlapping, left to right.
|
||||
fn parse_attrs(region: &str) -> Attrs<'_> {
|
||||
let mut attrs = Attrs::new();
|
||||
let mut pos = 0;
|
||||
while let Some(off) = region[pos..].find("=\"") {
|
||||
let eq = pos + off;
|
||||
// Greedy `\w+` immediately before `="`.
|
||||
let name_len: usize = region[..eq]
|
||||
.chars()
|
||||
.rev()
|
||||
.take_while(|c| is_word(*c))
|
||||
.map(char::len_utf8)
|
||||
.sum();
|
||||
let value_start = eq + 2;
|
||||
let Some(value_len) = region[value_start..].find('"') else {
|
||||
break; // an unterminated value ends the scan, as the regex does
|
||||
};
|
||||
if name_len > 0 {
|
||||
attrs.insert(
|
||||
®ion[eq - name_len..eq],
|
||||
®ion[value_start..value_start + value_len],
|
||||
);
|
||||
}
|
||||
pos = value_start + value_len + 1;
|
||||
}
|
||||
attrs
|
||||
}
|
||||
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
fn word_boundary_before(s: &str, at: usize) -> bool {
|
||||
at == 0 || !s[..at].chars().next_back().is_some_and(is_word)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ redaction
|
||||
const SECRET_ATTRS: [&str; 5] = ["AuthCode", "AuthToken", "SessionKey", "Token", "Sid"];
|
||||
const AUTH_CODE_ATTRS: [&str; 3] = ["value", "Code", "Return"];
|
||||
const CHALLENGE_ATTRS: [&str; 1] = ["response"];
|
||||
|
||||
/// Redact credential-bearing LSX attributes from ordinary diagnostics.
|
||||
///
|
||||
/// The blanket pass only catches `Name="..."` pairs; the auth code and the
|
||||
/// challenge response hide behind generic attribute names (`value`, `Code`,
|
||||
/// `Return`, `response`), so those two elements get a second, element-scoped pass
|
||||
/// -- otherwise `<ErrorSuccess Code="0">` would be redacted too and the ordinary
|
||||
/// status lines would stop being readable.
|
||||
pub fn safe_xml_for_log(xml: &str) -> String {
|
||||
let safe = redact_attrs(xml, &SECRET_ATTRS);
|
||||
let safe = if safe.contains("<AuthCode ") {
|
||||
redact_attrs(&safe, &AUTH_CODE_ATTRS)
|
||||
} else {
|
||||
safe
|
||||
};
|
||||
if safe.contains("<ChallengeAccepted ") {
|
||||
redact_attrs(&safe, &CHALLENGE_ATTRS)
|
||||
} else {
|
||||
safe
|
||||
}
|
||||
}
|
||||
|
||||
/// `re.sub(r'(?i)\b(a|b|c)="[^"]*"', r'\1="[REDACTED]"', xml)`; the attribute name
|
||||
/// keeps the case it was written in.
|
||||
fn redact_attrs(input: &str, names: &[&str]) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut last = 0;
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
if !input.is_char_boundary(i) || !word_boundary_before(input, i) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let rest = &input[i..];
|
||||
let hit = names.iter().find_map(|name| {
|
||||
let after = rest.get(..name.len())?;
|
||||
if !after.eq_ignore_ascii_case(name) {
|
||||
return None;
|
||||
}
|
||||
let value = rest[name.len()..].strip_prefix("=\"")?;
|
||||
let len = value.find('"')?;
|
||||
Some((name.len(), name.len() + 2 + len + 1))
|
||||
});
|
||||
match hit {
|
||||
Some((name_len, match_len)) => {
|
||||
out.push_str(&input[last..i]);
|
||||
out.push_str(&rest[..name_len]);
|
||||
out.push_str("=\"[REDACTED]\"");
|
||||
i += match_len;
|
||||
last = i;
|
||||
}
|
||||
None => i += 1,
|
||||
}
|
||||
}
|
||||
out.push_str(&input[last..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// `repr()` of a Python string, for the log lines that embed one.
|
||||
pub fn py_repr(s: &str) -> String {
|
||||
let quote = if s.contains('\'') && !s.contains('"') {
|
||||
'"'
|
||||
} else {
|
||||
'\''
|
||||
};
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push(quote);
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if c == quote => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
// Python renders the remaining ASCII controls as \xNN; anything
|
||||
// printable (including non-ASCII) goes through verbatim.
|
||||
c if (c as u32) < 0x20 || c as u32 == 0x7f => {
|
||||
let _ = write!(out, "\\x{:02x}", c as u32);
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push(quote);
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ responses
|
||||
pub fn resp(mid: &str, body: &str, sender: &str) -> String {
|
||||
format!(r#"<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>"#)
|
||||
}
|
||||
|
||||
/// Knobs that shape individual replies.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProtocolConfig {
|
||||
/// A/B-control integrity: v1 (`lsx_responder.py`) answered GetGameInfo
|
||||
/// FULLGAME_PURCHASED with "false" (it fell through to the default). v2 had
|
||||
/// silently changed it to "true", which meant `OPENFUT_LSX_EVENTS=0` was NOT a
|
||||
/// byte-identical control any more. Keep it OFF by default so events-off ==
|
||||
/// v1 exactly; flip `OPENFUT_LSX_FULLGAME=1` to run the FULLGAME="true"
|
||||
/// experiment on its own.
|
||||
pub fullgame_purchased: bool,
|
||||
/// `OPENFUT_AUTHCODE`.
|
||||
pub auth_code: String,
|
||||
}
|
||||
|
||||
impl Default for ProtocolConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fullgame_purchased: false,
|
||||
auth_code: format!("OPENFUT-{}", "0".repeat(24)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self::from_vars(&crate::os_env)
|
||||
}
|
||||
|
||||
pub fn from_vars(env: Env<'_>) -> Self {
|
||||
Self {
|
||||
fullgame_purchased: env_flag(env, "OPENFUT_LSX_FULLGAME", false),
|
||||
auth_code: env("OPENFUT_AUTHCODE").unwrap_or_else(|| Self::default().auth_code),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The verb dispatcher.
|
||||
pub struct Responder {
|
||||
pub identity: Identity,
|
||||
pub config: ProtocolConfig,
|
||||
}
|
||||
|
||||
impl Responder {
|
||||
pub fn new(identity: Identity, config: ProtocolConfig) -> Self {
|
||||
Self { identity, config }
|
||||
}
|
||||
|
||||
/// Request-DRIVEN dispatch.
|
||||
///
|
||||
/// CRITICAL (2026-07-31, connect-reverse workflow): FIFA's response matcher
|
||||
/// 0x1471189b0 rejects any `<Response>` whose `sender` attribute does not
|
||||
/// byte-equal the `recipient` the client put on the matching `<Request>` (it
|
||||
/// reads `serviceNames[facility]`; with our empty GetConfigResponse all 34
|
||||
/// names are "" so recipient="" for every verb after GetConfig, which itself
|
||||
/// uses the hard-coded literal "EbisuSDK"). We were answering GetProfile/
|
||||
/// GetAuthCode/QueryEntitlements with sender="EbisuSDK" -> silently discarded
|
||||
/// -> GetProfile (the SOLE writer of OriginSDK+0x3a0 default-user) never took
|
||||
/// -> the whole online-login chain stalled at OSDK_INVALID_USER. FIX = ECHO
|
||||
/// the request's recipient back as the response sender, which is what every
|
||||
/// `reply` below does.
|
||||
///
|
||||
/// `conn` is `None` in the selftest; a `None` connection must not touch the
|
||||
/// run's success-signal files.
|
||||
pub fn build_reply(
|
||||
&self,
|
||||
mid: &str,
|
||||
req_name: &str,
|
||||
attrs: &Attrs<'_>,
|
||||
conn: Option<&Conn>,
|
||||
recipient: &str,
|
||||
) -> String {
|
||||
let reply = |body: &str| resp(mid, body, recipient);
|
||||
|
||||
match req_name {
|
||||
// FLAG (1): "internet is reachable". The stub hardcoded
|
||||
// connected="0" -> "log in to Origin". This is NOT the logged-in
|
||||
// flag; see the crate docs.
|
||||
"GetInternetConnectedState" => reply(r#"InternetConnectedState connected="1""#),
|
||||
|
||||
// Request shape is built at 0x14713b8d0:
|
||||
// <GetAuthCode ClientId="..." Scope="..."/>
|
||||
// Response is matched at 0x1470e2b60: outer "LSX", element "AuthCode".
|
||||
//
|
||||
// THE ATTRIBUTE NAME IS "value" -- verified, not guessed: the
|
||||
// "AuthCode" element match at 0x1470e2b63 tail-jumps to 0x14712fac0
|
||||
// -> 0x1471312a0 = the lsx::AuthCodeT deserializer. It builds one
|
||||
// attribute name (ns-prefix for "lsx" @0x14394def0, then "value"
|
||||
// @0x1436c7768, concat at 0x14712d130) and does exactly ONE
|
||||
// get-attribute-as-string call 0x14713fe50(node, "value", &dest).
|
||||
// dest is ctx+0x00 == LSXRequest+0xb8, whose std::string size lands
|
||||
// at +0xc8 -- which is what OriginRequestAuthCodeSync's impl
|
||||
// 0x1470e67f0 reads back at 0x1470e6924 (`mov rbx,[rdi+0xc8]`) as
|
||||
// *out_len. Code=/Return= are NEVER read; with them alone the parsed
|
||||
// string is empty -> out_len 0 -> EbisuMgr+0x948 stays NULL -> the
|
||||
// OSDK classifier 0x14717d5d0 falls into its `test rbp,rbp / je` arm
|
||||
// and reports OSDK_UNDERAGE_ERROR (a mislabelled "no auth code"
|
||||
// fallback). Code=/Return= are kept only as harmless padding.
|
||||
"GetAuthCode" => {
|
||||
let client_id = attrs.get_or_empty("ClientId");
|
||||
let scope = attrs.get_or_empty("Scope");
|
||||
let code = &self.config.auth_code;
|
||||
// Only touch the run's success-signal files on a REAL request. The
|
||||
// selftest passes conn=None; if it wrote these files it would
|
||||
// pre-satisfy watch-step "authcode.txt becomes non-empty" and make
|
||||
// a non-event read as success on the next live run.
|
||||
if let Some(conn) = conn {
|
||||
for (path, val) in [(AUTHCODE_FILE, code.as_str()), (CLIENTID_FILE, client_id)]
|
||||
{
|
||||
if let Err(e) = fs::write(path, val) {
|
||||
log!("could not write {path}: {e}");
|
||||
}
|
||||
}
|
||||
// GetAuthCode has fired: stop the heartbeat so we do not keep
|
||||
// re-pushing Login/OnlineStatus events during Blaze login.
|
||||
conn.stop_events();
|
||||
}
|
||||
log!("*** GetAuthCode ISSUED ***");
|
||||
log!(
|
||||
" ClientId={} Scope={}",
|
||||
py_repr(client_id),
|
||||
py_repr(scope)
|
||||
);
|
||||
log!(" code=[REDACTED] -- issued for Blaze Authentication::login (1/0x0A)");
|
||||
reply(&format!(
|
||||
r#"AuthCode value="{code}" Code="{code}" Return="{code}""#
|
||||
))
|
||||
}
|
||||
|
||||
// The only reply with a child element, so it cannot use the
|
||||
// single-element `resp` helper.
|
||||
"QueryEntitlements" => format!(
|
||||
concat!(
|
||||
r#"<LSX><Response id="{mid}" sender="{recipient}">"#,
|
||||
r#"<QueryEntitlementsResponse>"#,
|
||||
r#"<OriginItem ItemId="{tag}" EntitlementId="1" "#,
|
||||
r#"ResourceId="{content}" OfferId="{content}" "#,
|
||||
r#"GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>"#,
|
||||
r#"</QueryEntitlementsResponse>"#,
|
||||
r#"</Response></LSX>"#
|
||||
),
|
||||
mid = mid,
|
||||
recipient = recipient,
|
||||
tag = ENTITLEMENT_TAG,
|
||||
content = CONTENT_ID
|
||||
),
|
||||
|
||||
// This is the ONLY feed for OriginSDK[+0x3a0]/[+0x3a8]
|
||||
// (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona
|
||||
// @0x1470da680 are bare reads of those fields, written only by
|
||||
// OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1). Keep it complete.
|
||||
// ONLY PersonaId/UserId/Persona come from the identity; the rest of
|
||||
// this template (Country/CommerceCountry/GeoCountry/CommerceCurrency/
|
||||
// AvatarId/IsSubscriber/IsUnderAge) is byte-exact per REPACK_INTEL 1.4
|
||||
// and is latched into OriginSDK[+0x3a0]/[+0x3a8] -- leave it verbatim.
|
||||
"GetProfile" => reply(&format!(
|
||||
concat!(
|
||||
r#"GetProfileResponse IsSubscriber="true" PersonaId="{persona}" "#,
|
||||
r#"AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" "#,
|
||||
r#"UserId="{user}" Persona="{name}" IsUnderAge="false" "#,
|
||||
r#"CommerceCurrency="USD""#
|
||||
),
|
||||
persona = self.identity.persona_id,
|
||||
user = self.identity.user_id(),
|
||||
name = self.identity.persona_name
|
||||
)),
|
||||
|
||||
"GetGameInfo" => match attrs.get("GameInfoId") {
|
||||
Some("LANGUAGES") => reply(concat!(
|
||||
r#"GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,"#,
|
||||
r#"en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,"#,
|
||||
r#"pt_PT,ru_RU,sv_SE,tr_TR,zh_TW""#
|
||||
)),
|
||||
// MUST be true or the client shows "Your title version is
|
||||
// outdated" and blocks all online features.
|
||||
Some("UPTODATE") => reply(r#"GetGameInfoResponse GameInfo="true""#),
|
||||
// OFF by default: v1 answered "false" here (fell through to the
|
||||
// default), and keeping this gated makes OPENFUT_LSX_EVENTS=0
|
||||
// byte-identical to v1.
|
||||
Some("FULLGAME_PURCHASED") if self.config.fullgame_purchased => {
|
||||
reply(r#"GetGameInfoResponse GameInfo="true""#)
|
||||
}
|
||||
// FREETRIAL / FULLGAME_PURCHASED etc. -> false (retail, not a
|
||||
// trial; matches v1 exactly)
|
||||
_ => reply(r#"GetGameInfoResponse GameInfo="false""#),
|
||||
},
|
||||
|
||||
"GetSetting" => {
|
||||
// The client asks in UPPERCASE.
|
||||
match attrs.get_or_empty("SettingId").to_uppercase().as_str() {
|
||||
"ENVIRONMENT" | "ENVIRONMENTNAME" => {
|
||||
reply(r#"GetSettingResponse Setting="production""#)
|
||||
}
|
||||
"LANGUAGE" => reply(&format!(
|
||||
r#"GetSettingResponse Setting="{}""#,
|
||||
self.identity.locale
|
||||
)),
|
||||
_ => reply(r#"GetSettingResponse Setting="false""#),
|
||||
}
|
||||
}
|
||||
|
||||
"GetConfig" => reply(r#"GetConfigResponse Config="false""#),
|
||||
|
||||
"IsProgressiveInstallationAvailable" => reply(concat!(
|
||||
r#"IsProgressiveInstallationAvailableResponse ItemId="" "#,
|
||||
r#"Available="false""#
|
||||
)),
|
||||
|
||||
_ => reply(r#"ErrorSuccess Code="0" Description="""#),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger points: push right after answering these verbs. GetProfile is the
|
||||
/// earliest safe moment -- by then the SDK has built its handler set and has a
|
||||
/// default user, so a Login event has somewhere to land.
|
||||
///
|
||||
/// For GetGameInfo the caller only fires on UPTODATE, otherwise we would push
|
||||
/// three times per boot for FREETRIAL/LANGUAGES too.
|
||||
pub fn push_after(req_name: &str) -> Option<&'static str> {
|
||||
match req_name {
|
||||
"GetProfile" => Some("after GetProfile"),
|
||||
"GetInternetConnectedState" => Some("after GetInternetConnectedState"),
|
||||
"GetGameInfo" => Some("after GetGameInfo UPTODATE"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let map: HashMap<String, String> = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
|
||||
.collect();
|
||||
move |k: &str| map.get(k).cloned()
|
||||
}
|
||||
|
||||
fn responder() -> Responder {
|
||||
Responder::new(Identity::default(), ProtocolConfig::default())
|
||||
}
|
||||
|
||||
fn reply(verb: &str, attrs: &[(&str, &str)]) -> String {
|
||||
responder().build_reply("7", verb, &attrs.iter().copied().collect(), None, "")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- parsing
|
||||
#[test]
|
||||
fn parses_a_request_with_a_recipient() {
|
||||
let xml = r#"<LSX><Request id="12" recipient="EbisuSDK"><GetConfig Locale="en_US" Env="prod"/></Request></LSX>"#;
|
||||
let req = parse_request(xml).unwrap();
|
||||
assert_eq!(req.id, "12");
|
||||
assert_eq!(req.name, "GetConfig");
|
||||
assert_eq!(req.recipient, "EbisuSDK");
|
||||
assert_eq!(req.attrs.get("Locale"), Some("en_US"));
|
||||
assert_eq!(req.attrs.get("Env"), Some("prod"));
|
||||
assert_eq!(req.attrs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_request_without_a_recipient() {
|
||||
// Must still be answered -- fast -- rather than stalling ~15s.
|
||||
let xml = r#"<LSX><Request id="3"><GetProfile/></Request></LSX>"#;
|
||||
let req = parse_request(xml).unwrap();
|
||||
assert_eq!((req.id, req.name, req.recipient), ("3", "GetProfile", ""));
|
||||
assert!(req.attrs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_an_empty_recipient() {
|
||||
let xml = r#"<LSX><Request id="9" recipient=""><GetSetting SettingId="LANGUAGE"/></Request></LSX>"#;
|
||||
let req = parse_request(xml).unwrap();
|
||||
assert_eq!(req.recipient, "");
|
||||
assert_eq!(req.attrs.get("SettingId"), Some("LANGUAGE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_whitespace_between_the_request_and_its_element() {
|
||||
let xml = "<LSX><Request id=\"4\" recipient=\"X\">\n <GetAuthCode ClientId=\"c\" Scope=\"s\" />\n</Request></LSX>";
|
||||
let req = parse_request(xml).unwrap();
|
||||
assert_eq!((req.id, req.name, req.recipient), ("4", "GetAuthCode", "X"));
|
||||
assert_eq!(req.attrs.get("ClientId"), Some("c"));
|
||||
assert_eq!(req.attrs.get("Scope"), Some("s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_frames_that_are_not_requests() {
|
||||
for xml in [
|
||||
"",
|
||||
r#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||
r#"<LSX><Request recipient="X"><GetConfig/></Request></LSX>"#, // no id
|
||||
r#"<LSX><Request id="abc"><GetConfig/></Request></LSX>"#, // id not digits
|
||||
r#"<LSX><Requestid="1"><GetConfig/></Request></LSX>"#, // \b fails
|
||||
r#"<LSX><Request id="1">"#, // no element
|
||||
] {
|
||||
assert!(parse_request(xml).is_none(), "{xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_attribute_keeps_its_place_and_takes_the_last_value() {
|
||||
let xml = r#"<LSX><Request id="1"><V a="1" b="2" a="3"/></Request></LSX>"#;
|
||||
let req = parse_request(xml).unwrap();
|
||||
assert_eq!(req.attrs.py_repr(), "{'a': '3', 'b': '2'}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greedy_id_takes_the_rightmost_one() {
|
||||
let xml = r#"<LSX><Request id="1" other-id="2"><GetConfig/></Request></LSX>"#;
|
||||
assert_eq!(parse_request(xml).unwrap().id, "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_self_closing_slash_does_not_become_an_attribute() {
|
||||
let xml = r#"<LSX><Request id="1"><GetGameInfo GameInfoId="UPTODATE"/></Request></LSX>"#;
|
||||
let req = parse_request(xml).unwrap();
|
||||
assert_eq!(req.attrs.len(), 1);
|
||||
assert_eq!(req.attrs.get("GameInfoId"), Some("UPTODATE"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ verbs
|
||||
#[test]
|
||||
fn internet_connected_state_is_one() {
|
||||
assert_eq!(
|
||||
reply("GetInternetConnectedState", &[]),
|
||||
r#"<LSX><Response id="7" sender=""><InternetConnectedState connected="1"/></Response></LSX>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_code_carries_the_value_attribute() {
|
||||
let r = reply("GetAuthCode", &[("ClientId", "X"), ("Scope", "Y")]);
|
||||
assert_eq!(
|
||||
r,
|
||||
concat!(
|
||||
r#"<LSX><Response id="7" sender=""><AuthCode "#,
|
||||
r#"value="OPENFUT-000000000000000000000000" "#,
|
||||
r#"Code="OPENFUT-000000000000000000000000" "#,
|
||||
r#"Return="OPENFUT-000000000000000000000000"/></Response></LSX>"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_code_honours_the_env_override() {
|
||||
let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_AUTHCODE", "ABC")]));
|
||||
let r = Responder::new(Identity::default(), cfg).build_reply(
|
||||
"1",
|
||||
"GetAuthCode",
|
||||
&Attrs::new(),
|
||||
None,
|
||||
"",
|
||||
);
|
||||
assert!(r.contains(r#"<AuthCode value="ABC" Code="ABC" Return="ABC"/>"#), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_entitlements_owns_the_retail_offer() {
|
||||
assert_eq!(
|
||||
reply("QueryEntitlements", &[]),
|
||||
concat!(
|
||||
r#"<LSX><Response id="7" sender=""><QueryEntitlementsResponse>"#,
|
||||
r#"<OriginItem ItemId="ONLINE_ACCESS" EntitlementId="1" "#,
|
||||
r#"ResourceId="1027460" OfferId="1027460" "#,
|
||||
r#"GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>"#,
|
||||
r#"</QueryEntitlementsResponse></Response></LSX>"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_carries_the_identity_and_the_verbatim_template() {
|
||||
assert_eq!(
|
||||
reply("GetProfile", &[]),
|
||||
concat!(
|
||||
r#"<LSX><Response id="7" sender=""><GetProfileResponse IsSubscriber="true" "#,
|
||||
r#"PersonaId="33068179" AvatarId="" Country="US" CommerceCountry="US" "#,
|
||||
r#"GeoCountry="US" UserId="33068179" Persona="CAGE" IsUnderAge="false" "#,
|
||||
r#"CommerceCurrency="USD"/></Response></LSX>"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_echoes_an_overridden_persona() {
|
||||
let ident = Identity::from_vars(&env_of(&[
|
||||
("FUT_PERSONA_ID", "42"),
|
||||
("FUT_PERSONA_NAME", "ZED"),
|
||||
]))
|
||||
.unwrap();
|
||||
let r = Responder::new(ident, ProtocolConfig::default())
|
||||
.build_reply("1", "GetProfile", &Attrs::new(), None, "");
|
||||
assert!(r.contains(r#"PersonaId="42""#), "{r}");
|
||||
assert!(r.contains(r#"UserId="42""#), "{r}");
|
||||
assert!(r.contains(r#"Persona="ZED""#), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_info_languages_and_uptodate() {
|
||||
let r = reply("GetGameInfo", &[("GameInfoId", "LANGUAGES")]);
|
||||
assert!(r.contains(r#"GameInfo="ar_SA,cs_CZ,"#), "{r}");
|
||||
assert!(r.ends_with(r#"tr_TR,zh_TW"/></Response></LSX>"#), "{r}");
|
||||
assert!(reply("GetGameInfo", &[("GameInfoId", "UPTODATE")])
|
||||
.contains(r#"GetGameInfoResponse GameInfo="true""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullgame_purchased_is_false_unless_the_knob_is_set() {
|
||||
// A/B-control integrity: events-off must stay byte-identical to v1.
|
||||
for id in ["FULLGAME_PURCHASED", "FREETRIAL", "ANYTHING_ELSE"] {
|
||||
assert!(
|
||||
reply("GetGameInfo", &[("GameInfoId", id)])
|
||||
.contains(r#"GetGameInfoResponse GameInfo="false""#),
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
// ... and a GetGameInfo with no GameInfoId at all.
|
||||
assert!(reply("GetGameInfo", &[]).contains(r#"GameInfo="false""#));
|
||||
|
||||
let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_LSX_FULLGAME", "1")]));
|
||||
let on = Responder::new(Identity::default(), cfg);
|
||||
assert!(on
|
||||
.build_reply(
|
||||
"7",
|
||||
"GetGameInfo",
|
||||
&[("GameInfoId", "FULLGAME_PURCHASED")].into_iter().collect(),
|
||||
None,
|
||||
""
|
||||
)
|
||||
.contains(r#"GameInfo="true""#));
|
||||
// Still only that one id flips.
|
||||
assert!(on
|
||||
.build_reply(
|
||||
"7",
|
||||
"GetGameInfo",
|
||||
&[("GameInfoId", "FREETRIAL")].into_iter().collect(),
|
||||
None,
|
||||
""
|
||||
)
|
||||
.contains(r#"GameInfo="false""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullgame_knob_follows_the_not_zero_rule() {
|
||||
for (value, on) in [("0", false), ("1", true), ("", true), ("false", true)] {
|
||||
let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_LSX_FULLGAME", value)]));
|
||||
assert_eq!(cfg.fullgame_purchased, on, "OPENFUT_LSX_FULLGAME={value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings() {
|
||||
for id in ["ENVIRONMENT", "environment", "EnvironmentName"] {
|
||||
assert!(
|
||||
reply("GetSetting", &[("SettingId", id)])
|
||||
.contains(r#"GetSettingResponse Setting="production""#),
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
assert!(reply("GetSetting", &[("SettingId", "LANGUAGE")])
|
||||
.contains(r#"GetSettingResponse Setting="en_US""#));
|
||||
assert!(reply("GetSetting", &[("SettingId", "WHATEVER")])
|
||||
.contains(r#"GetSettingResponse Setting="false""#));
|
||||
assert!(reply("GetSetting", &[]).contains(r#"Setting="false""#));
|
||||
|
||||
let ident = Identity::from_vars(&env_of(&[("FUT_LOCALE", "fr_FR")])).unwrap();
|
||||
assert!(Responder::new(ident, ProtocolConfig::default())
|
||||
.build_reply(
|
||||
"7",
|
||||
"GetSetting",
|
||||
&[("SettingId", "LANGUAGE")].into_iter().collect(),
|
||||
None,
|
||||
""
|
||||
)
|
||||
.contains(r#"Setting="fr_FR""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_is_empty_which_is_why_every_recipient_is_the_empty_string() {
|
||||
assert!(reply("GetConfig", &[]).contains(r#"GetConfigResponse Config="false""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_installation_is_unavailable() {
|
||||
assert!(reply("IsProgressiveInstallationAvailable", &[]).contains(
|
||||
r#"IsProgressiveInstallationAvailableResponse ItemId="" Available="false""#
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_verbs_fall_through_to_error_success() {
|
||||
assert_eq!(
|
||||
reply("GetSomethingWeHaveNeverSeen", &[]),
|
||||
r#"<LSX><Response id="7" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_response_sender_byte_equals_the_request_recipient() {
|
||||
let r = responder().build_reply("5", "GetProfile", &Attrs::new(), None, "EbisuSDK");
|
||||
assert!(r.starts_with(r#"<LSX><Response id="5" sender="EbisuSDK">"#), "{r}");
|
||||
// Including for the one verb built without the `resp` helper.
|
||||
let q = responder().build_reply("5", "QueryEntitlements", &Attrs::new(), None, "EbisuSDK");
|
||||
assert!(q.starts_with(r#"<LSX><Response id="5" sender="EbisuSDK">"#), "{q}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_triggers() {
|
||||
assert_eq!(push_after("GetProfile"), Some("after GetProfile"));
|
||||
assert_eq!(
|
||||
push_after("GetInternetConnectedState"),
|
||||
Some("after GetInternetConnectedState")
|
||||
);
|
||||
assert_eq!(push_after("GetGameInfo"), Some("after GetGameInfo UPTODATE"));
|
||||
assert_eq!(push_after("GetConfig"), None);
|
||||
assert_eq!(push_after("GetAuthCode"), None);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- redaction
|
||||
#[test]
|
||||
fn redacts_credential_attributes() {
|
||||
let safe = safe_xml_for_log(
|
||||
r#"<Thing AuthCode="a" AuthToken="b" SessionKey="c" Token="d" Sid="e" Keep="f"/>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
safe,
|
||||
concat!(
|
||||
r#"<Thing AuthCode="[REDACTED]" AuthToken="[REDACTED]" "#,
|
||||
r#"SessionKey="[REDACTED]" Token="[REDACTED]" Sid="[REDACTED]" Keep="f"/>"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_the_auth_code_element_generically() {
|
||||
let safe =
|
||||
safe_xml_for_log(r#"<AuthCode value="secret" Code="secret" Return="secret"/>"#);
|
||||
assert!(!safe.contains("secret"), "{safe}");
|
||||
assert_eq!(safe.matches("[REDACTED]").count(), 3, "{safe}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_the_challenge_response() {
|
||||
let safe = safe_xml_for_log(
|
||||
r#"<LSX><Response id="1" sender="EALS"><ChallengeAccepted response="e4f5"/></Response></LSX>"#,
|
||||
);
|
||||
assert!(!safe.contains("e4f5"), "{safe}");
|
||||
assert!(safe.contains(r#"response="[REDACTED]""#), "{safe}");
|
||||
assert!(safe.contains(r#"id="1""#), "{safe}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_ordinary_status_lines_readable() {
|
||||
let status = safe_xml_for_log(r#"<ErrorSuccess Code="0" Description=""/>"#);
|
||||
assert_eq!(status, r#"<ErrorSuccess Code="0" Description=""/>"#);
|
||||
// The element name alone must not trip the blanket pass.
|
||||
assert_eq!(
|
||||
safe_xml_for_log(r#"<GetConfigResponse Config="false"/>"#),
|
||||
r#"<GetConfigResponse Config="false"/>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redaction_is_case_insensitive_and_keeps_the_written_case() {
|
||||
assert_eq!(
|
||||
safe_xml_for_log(r#"<X authtoken="q" SID="r"/>"#),
|
||||
r#"<X authtoken="[REDACTED]" SID="[REDACTED]"/>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redaction_respects_word_boundaries() {
|
||||
// `MySid="x"` has no boundary before `Sid`, so it is left alone.
|
||||
assert_eq!(
|
||||
safe_xml_for_log(r#"<X MySid="x"/>"#),
|
||||
r#"<X MySid="x"/>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_repr_of_strings() {
|
||||
assert_eq!(py_repr(""), "''");
|
||||
assert_eq!(py_repr("X"), "'X'");
|
||||
assert_eq!(py_repr("it's"), "\"it's\"");
|
||||
assert_eq!(py_repr("a\"b"), "'a\"b'");
|
||||
assert_eq!(py_repr("a\nb"), r"'a\nb'");
|
||||
assert_eq!(py_repr("a\\b"), r"'a\\b'");
|
||||
assert_eq!(Attrs::new().py_repr(), "{}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user