750d6c2e18
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.
305 lines
11 KiB
Rust
305 lines
11 KiB
Rust
//! 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);
|
|
}
|
|
}
|