ship one record per function: merge the release set, gen reads it, descriptions as doc comments, gates for what was only claimed; v3.0
This commit is contained in:
parent
71ce34edd2
commit
3410a79b6a
28 changed files with 30596 additions and 955 deletions
97
src/live.rs
97
src/live.rs
|
|
@ -1,6 +1,15 @@
|
|||
//! Read-only window into a *running* CS2 server's memory — the runtime oracle that verifies the
|
||||
//! offline derivations against ground truth. No injection, no debugger: just `/proc/<pid>/mem` (needs
|
||||
//! ptrace access — same-user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`).
|
||||
//! Window into a *running* CS2 server — the runtime oracle that verifies the offline derivations against
|
||||
//! ground truth. Needs ptrace access (same-user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`).
|
||||
//!
|
||||
//! **Mostly reading, but not only reading, and the difference is worth stating plainly.** The bulk of this
|
||||
//! module reads `/proc/<pid>/mem`. Two things go further: `poke_bytes` writes bytes in with
|
||||
//! `PTRACE_POKEDATA`, and [`call_remote`] ATTACHES, saves the main thread's registers, builds a call frame
|
||||
//! and executes a function in the live process before restoring the thread exactly. Both exist because
|
||||
//! some claims cannot be checked any other way — a lazy-init singleton is zeroed until something calls its
|
||||
//! accessor — and both are used only against the narrow set of functions the derivation has already
|
||||
//! measured as safe to call (nullary, `this`-only, no game state). Nothing is injected and nothing
|
||||
//! persists: the process is left as it was found, and a faulting call is caught and the thread restored
|
||||
//! rather than allowed to kill the server.
|
||||
//!
|
||||
//! Offline we resolve `.rela.dyn` by hand to recover as-loaded pointer values; the running process is
|
||||
//! the authority on what those values actually are. So reading the same structures live and comparing
|
||||
|
|
@ -37,6 +46,19 @@ fn maps_path(line: &str) -> &str {
|
|||
rest.trim_start()
|
||||
}
|
||||
|
||||
/// The scheduler state character from `/proc/<pid>/stat` (`R`/`S`/`D`/`Z`/`T`/…), or `None` if the process
|
||||
/// is gone entirely. Parsed from AFTER the final `)`, because the comm field is parenthesised and may itself
|
||||
/// contain spaces and brackets — splitting the line on whitespace from the left gets this wrong for any
|
||||
/// process whose name has a space in it.
|
||||
fn proc_state(pid: u32) -> Option<char> {
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
stat[stat.rfind(')')? + 1..]
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.chars()
|
||||
.next()
|
||||
}
|
||||
|
||||
impl LiveProcess {
|
||||
pub fn attach(pid: u32) -> Result<Self> {
|
||||
let maps = std::fs::read_to_string(format!("/proc/{pid}/maps"))
|
||||
|
|
@ -80,10 +102,30 @@ impl LiveProcess {
|
|||
}
|
||||
}
|
||||
executable.sort_unstable();
|
||||
// Distinguish the two ways this fails, because they call for opposite responses and the kernel
|
||||
// reports BOTH as EACCES. If the process is gone, `/proc/<pid>` is gone with it — so check that
|
||||
// first: a server that CRASHED mid-derive otherwise reads as a permissions problem, and the
|
||||
// operator goes off tuning `ptrace_scope` for a fault that had nothing to do with it. (Seen: a
|
||||
// CS2 server crashed in Steam auth and this line blamed ptrace.)
|
||||
let mem = File::open(format!("/proc/{pid}/mem")).with_context(|| {
|
||||
format!(
|
||||
"open /proc/{pid}/mem — needs ptrace access (yama ptrace_scope=0 or run as root)"
|
||||
)
|
||||
match proc_state(pid) {
|
||||
// A crashed child stays a ZOMBIE until the parent reaps it, so `/proc/<pid>` still exists
|
||||
// and only `mem` is unreadable — an existence check alone reports it as a permissions
|
||||
// fault. Read the state instead.
|
||||
Some('Z') | None => format!(
|
||||
"the game process {pid} DIED during the live stage — it is {}, so there is nothing \
|
||||
left to read. This is NOT a ptrace-permission problem: check the server's own log \
|
||||
and /tmp/dumps for a minidump.",
|
||||
if proc_state(pid) == Some('Z') {
|
||||
"a zombie (crashed, not yet reaped)"
|
||||
} else {
|
||||
"gone"
|
||||
}
|
||||
),
|
||||
Some(_) => format!(
|
||||
"open /proc/{pid}/mem — needs ptrace access (yama ptrace_scope=0 or run as root)"
|
||||
),
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
mem,
|
||||
|
|
@ -207,9 +249,6 @@ impl LiveProcess {
|
|||
pub struct CallResult {
|
||||
pub rax: u64,
|
||||
pub clean_return: bool,
|
||||
/// Where the scratch blob was placed, so the caller can read back what the callee wrote into it.
|
||||
/// Zero when the call carried no scratch.
|
||||
pub scratch_base: u64,
|
||||
}
|
||||
|
||||
/// One argument to a remote call.
|
||||
|
|
@ -234,6 +273,13 @@ pub struct Scratch<'a> {
|
|||
pub relocs: &'a [(usize, i64)],
|
||||
}
|
||||
|
||||
/// How long an injected call may run before it is abandoned and the thread restored.
|
||||
///
|
||||
/// Generous by design: every call site here is a nullary accessor or a `this`-only query, which returns in
|
||||
/// microseconds, so a second is four orders of magnitude of headroom and only a genuinely stuck callee
|
||||
/// reaches it. `clean_return: false` is then the honest verdict — the same one a faulting call gets.
|
||||
const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
/// Write `data` into the target at `addr`, a word at a time.
|
||||
///
|
||||
/// A trailing partial word is read back and merged rather than zero-filled: `PTRACE_POKEDATA` writes a
|
||||
|
|
@ -413,10 +459,37 @@ pub fn call_remote_ex(
|
|||
);
|
||||
}
|
||||
|
||||
// Run, absorbing any spurious signals, until the function returns into our null trap.
|
||||
// Run, absorbing any spurious signals, until the function returns into our null trap — or until
|
||||
// the deadline. BOUNDED, because the alternative is unbounded: the injected callee is chosen to
|
||||
// be leaf-ish, but "chosen to be" is not "proven to be", and one that blocks on a lock, a socket
|
||||
// or a condition variable would park this `waitpid` forever with the tracee STOPPED — hanging a
|
||||
// CI derive with no output and no timeout above it. A live check that cannot finish is a failed
|
||||
// live check, not a reason to stop the release from ever being decided.
|
||||
let deadline = std::time::Instant::now() + CALL_TIMEOUT;
|
||||
loop {
|
||||
libc::ptrace(libc::PTRACE_CONT, pid, 0usize, 0usize);
|
||||
if libc::waitpid(pid, &mut status, 0) < 0 || !libc::WIFSTOPPED(status) {
|
||||
// Polled rather than blocking, so the deadline is observable at all.
|
||||
let waited = loop {
|
||||
let r = libc::waitpid(pid, &mut status, libc::WNOHANG);
|
||||
if r != 0 {
|
||||
break r;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break 0;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
};
|
||||
if waited == 0 {
|
||||
// Still RUNNING, so `restore` would fail ESRCH — stop it first, then put it back exactly.
|
||||
libc::kill(pid, libc::SIGSTOP);
|
||||
libc::waitpid(pid, &mut status, 0);
|
||||
restore(&saved);
|
||||
return Ok(CallResult {
|
||||
rax: 0,
|
||||
clean_return: false,
|
||||
});
|
||||
}
|
||||
if waited < 0 || !libc::WIFSTOPPED(status) {
|
||||
restore(&saved);
|
||||
bail!("target vanished mid-call (status {status:#x})");
|
||||
}
|
||||
|
|
@ -433,7 +506,6 @@ pub fn call_remote_ex(
|
|||
let r = CallResult {
|
||||
rax: cur.rax,
|
||||
clean_return: true,
|
||||
scratch_base,
|
||||
};
|
||||
restore(&saved);
|
||||
return Ok(r);
|
||||
|
|
@ -442,7 +514,6 @@ pub fn call_remote_ex(
|
|||
let r = CallResult {
|
||||
rax: cur.rax,
|
||||
clean_return: false,
|
||||
scratch_base,
|
||||
};
|
||||
restore(&saved);
|
||||
return Ok(r);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue