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
251
src/abi.rs
251
src/abi.rs
|
|
@ -24,10 +24,12 @@
|
|||
//! Known limits (all bias toward UNDER-counting = a missed flag, never a false one): a pure forwarding
|
||||
//! thunk (`jmp Helper`) reads no arg register of its own, so it shapes as `(0,0)`; an argument used
|
||||
//! only inside a jump-table (indirect-branch) case isn't followed, so it can be missed. Both stay
|
||||
//! stable across builds (a thunk stays a thunk), so they don't manufacture false transitions — the
|
||||
//! diff's `int==0` low-confidence bucket also absorbs the thunk case. `int_args` is the OBSERVABLE
|
||||
//! footprint = a lower bound on the declared prototype (a constant-returner reads nothing → `int=0`);
|
||||
//! that too is stable per function, so the cross-build diff still works.
|
||||
//! stable across builds (a thunk stays a thunk), so they don't manufacture false transitions. `int_args`
|
||||
//! is the OBSERVABLE footprint = a lower bound on the declared prototype (a constant-returner reads
|
||||
//! nothing → `int=0`); that too is stable per function, which is what lets a shape measured in one build
|
||||
//! be compared against the model's consensus in the next — see `pipeline::AbiSig::differs`, which treats an
|
||||
//! `Unknown` return class as "no disagreement" for exactly this reason, and the derive-time
|
||||
//! `FlagReason::AbiDrift` check that reports the survivors.
|
||||
//!
|
||||
//! The lower-bound property is MEASURED, not assumed: Valve's entity-IO datadesc declares hundreds of
|
||||
//! independent handlers to one fixed `void(CEntityInstance*, InputData_t&)` prototype, and every one of
|
||||
|
|
@ -594,6 +596,172 @@ pub fn abi_shape(img: &CodeImage, entry: u64) -> Option<AbiShape> {
|
|||
})
|
||||
}
|
||||
|
||||
/// The 16 general-purpose registers as a slot index, sub-registers folded to their 64-bit parent.
|
||||
///
|
||||
/// `pub(crate)` because it is a fixed SysV fact, not per-pass tuning: `concmd` and `vscript` index
|
||||
/// `[_; 16]` arrays by exactly this mapping and each carried its own copy of it. (Unlike `MAX_NAME`, or
|
||||
/// the two `V` lattices, which differ between those readers deliberately.)
|
||||
pub(crate) fn gp_slot(r: Register) -> Option<usize> {
|
||||
let full = r.full_register();
|
||||
(full.is_gpr64() && full != Register::RIP).then(|| full as usize - Register::RAX as usize)
|
||||
}
|
||||
|
||||
/// Registers a `call` destroys — every caller-saved GPR. A pointer that SURVIVES a call is in a
|
||||
/// callee-saved register, which is exactly how a real `this` is kept across one.
|
||||
///
|
||||
/// One list, three shapes: this array, [`caller_saved_mask`]'s bitmask, and the slot indices `concmd`
|
||||
/// clears after a call. They must agree — a register missing from one and present in another is a
|
||||
/// tracker that forgets a value the machine kept, or keeps one the machine destroyed.
|
||||
pub(crate) const CALLER_SAVED: [Register; 9] = [
|
||||
Register::RAX,
|
||||
Register::RCX,
|
||||
Register::RDX,
|
||||
Register::RSI,
|
||||
Register::RDI,
|
||||
Register::R8,
|
||||
Register::R9,
|
||||
Register::R10,
|
||||
Register::R11,
|
||||
];
|
||||
|
||||
fn caller_saved_mask() -> u32 {
|
||||
CALLER_SAVED
|
||||
.iter()
|
||||
.filter_map(|&r| gp_slot(r))
|
||||
.fold(0u32, |m, s| m | (1 << s))
|
||||
}
|
||||
|
||||
/// The largest displacement the function reaches through the pointer it was handed in RDI — for a
|
||||
/// member function, how far into `this` it touches.
|
||||
///
|
||||
/// **What it is for.** Every other check in this project verifies that a locator RESOLVES; none verifies
|
||||
/// that it resolves to the RIGHT function. This one can, against a fact the SchemaSystem already states
|
||||
/// offline: a `CFoo::` method reaches its own object through `this`, so every `this + N` it touches must
|
||||
/// satisfy `N < sizeof(CFoo)`. Reaching past the end means the pointer is not a `CFoo`.
|
||||
///
|
||||
/// **Deliberately conservative, in the same direction as `abi_shape`.** A register stops holding `this`
|
||||
/// on any write that is not a move from another register already holding it, every caller-saved register
|
||||
/// is dropped across a `call`, and a path merge keeps only what holds on BOTH paths. So `this` is followed
|
||||
/// only where it is provably still `this`, and the error direction is a reach that is too SMALL — a missed
|
||||
/// contradiction rather than a false accusation against a correct entry.
|
||||
///
|
||||
/// Indexed memory operands (`(%rax,%rcx,8)`) are skipped: there the displacement is an array base rather
|
||||
/// than a field offset, so its magnitude says nothing about the object's size.
|
||||
pub fn this_reach(img: &CodeImage, entry: u64) -> Option<u64> {
|
||||
const MAX_SPAN: usize = 16 * 1024;
|
||||
const MAX_STEPS: usize = 6000;
|
||||
let code = img.code_at(entry)?;
|
||||
let cap = code.len().min(MAX_SPAN);
|
||||
let in_span = |t: u64| t >= entry && ((t - entry) as usize) < cap;
|
||||
let clobber = caller_saved_mask();
|
||||
|
||||
let mut factory = InstructionInfoFactory::new();
|
||||
let mut seen: HashMap<u64, u32> = HashMap::new();
|
||||
let rdi = 1u32 << gp_slot(Register::RDI)?;
|
||||
let mut work = vec![(entry, rdi)];
|
||||
let mut best: Option<u64> = None;
|
||||
let mut steps = 0usize;
|
||||
let mut insn = Instruction::default();
|
||||
|
||||
while let Some((ip, incoming)) = work.pop() {
|
||||
if !in_span(ip) {
|
||||
continue;
|
||||
}
|
||||
steps += 1;
|
||||
if steps > MAX_STEPS {
|
||||
break;
|
||||
}
|
||||
// Path merge is INTERSECTION: a register holds `this` here only if it did on every path in.
|
||||
let held = match seen.get(&ip) {
|
||||
Some(&prev) => {
|
||||
let merged = prev & incoming;
|
||||
if merged == prev {
|
||||
continue; // nothing new to propagate
|
||||
}
|
||||
merged
|
||||
}
|
||||
None => incoming,
|
||||
};
|
||||
seen.insert(ip, held);
|
||||
|
||||
let off = (ip - entry) as usize;
|
||||
let mut dec = Decoder::with_ip(64, &code[off..], ip, DecoderOptions::NONE);
|
||||
if !dec.can_decode() {
|
||||
continue;
|
||||
}
|
||||
dec.decode_out(&mut insn);
|
||||
if insn.is_invalid() || insn.len() == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Record every field access made through a register that still holds `this`.
|
||||
if insn.memory_index() == Register::None
|
||||
&& let Some(slot) = gp_slot(insn.memory_base())
|
||||
&& held & (1 << slot) != 0
|
||||
&& (0..insn.op_count()).any(|i| insn.op_kind(i) == OpKind::Memory)
|
||||
{
|
||||
let d = insn.memory_displacement64();
|
||||
if d < MAX_SPAN as u64 {
|
||||
best = Some(best.map_or(d, |b: u64| b.max(d)));
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate. A plain 64-bit register-to-register move carries `this`; anything else that writes
|
||||
// a register destroys whatever it held.
|
||||
let mut next = held;
|
||||
let is_reg_move = insn.mnemonic() == Mnemonic::Mov
|
||||
&& insn.op_count() == 2
|
||||
&& insn.op0_kind() == OpKind::Register
|
||||
&& insn.op1_kind() == OpKind::Register
|
||||
&& insn.op0_register().is_gpr64();
|
||||
let carried = is_reg_move
|
||||
.then(|| gp_slot(insn.op1_register()))
|
||||
.flatten()
|
||||
.filter(|&s| held & (1 << s) != 0)
|
||||
.and_then(|_| gp_slot(insn.op0_register()));
|
||||
for used in factory.info(&insn).used_registers() {
|
||||
if matches!(
|
||||
used.access(),
|
||||
OpAccess::Write | OpAccess::ReadWrite | OpAccess::CondWrite
|
||||
) && let Some(s) = gp_slot(used.register())
|
||||
{
|
||||
next &= !(1 << s);
|
||||
}
|
||||
}
|
||||
if let Some(s) = carried {
|
||||
next |= 1 << s;
|
||||
}
|
||||
if insn.flow_control() == FlowControl::Call
|
||||
|| insn.flow_control() == FlowControl::IndirectCall
|
||||
{
|
||||
next &= !clobber;
|
||||
}
|
||||
|
||||
let after = ip + insn.len() as u64;
|
||||
match insn.flow_control() {
|
||||
FlowControl::Return
|
||||
| FlowControl::IndirectBranch
|
||||
| FlowControl::Exception
|
||||
| FlowControl::Interrupt => {}
|
||||
FlowControl::UnconditionalBranch => {
|
||||
let t = insn.near_branch_target();
|
||||
if in_span(t) {
|
||||
work.push((t, next));
|
||||
}
|
||||
}
|
||||
FlowControl::ConditionalBranch => {
|
||||
work.push((after, next));
|
||||
let t = insn.near_branch_target();
|
||||
if in_span(t) {
|
||||
work.push((t, next));
|
||||
}
|
||||
}
|
||||
_ => work.push((after, next)),
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -803,4 +971,79 @@ mod tests {
|
|||
// ret — no result register written before returning.
|
||||
assert_eq!(shape_of(&[0xC3]).ret_class, RetClass::Void);
|
||||
}
|
||||
|
||||
// ---- this_reach: the identity check's measurement half. Every case here is one the FIELD-tracking
|
||||
// has to get right for the check to be usable as a rejection rather than a hint. ----
|
||||
|
||||
fn reach_of(bytes: &[u8]) -> Option<u64> {
|
||||
this_reach(&CodeImage::for_test(0x1000, bytes), 0x1000)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_reach_follows_a_move_into_a_callee_saved_register() {
|
||||
// mov %rdi,%r13 ; cmpb $0,0x7bc(%r13) ; ret
|
||||
// The shape that matters in practice: the prologue stashes `this` and every field access is
|
||||
// through the copy, so a tracker that only watches RDI measures nothing.
|
||||
assert_eq!(
|
||||
reach_of(&[
|
||||
0x49, 0x89, 0xFD, 0x41, 0x80, 0xBD, 0xBC, 0x07, 0x00, 0x00, 0x00, 0xC3
|
||||
]),
|
||||
Some(0x7bc)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_reach_stops_at_a_reloaded_register() {
|
||||
// mov 0x10(%rdi),%rdi ; mov 0x110(%rdi),%rax ; ret
|
||||
// RDI is REDEFINED from memory, so 0x110 is an offset into a different object. Crediting it to
|
||||
// `this` is exactly the false positive that made an earlier prototype of this check unusable.
|
||||
assert_eq!(
|
||||
reach_of(&[
|
||||
0x48, 0x8B, 0x7F, 0x10, 0x48, 0x8B, 0x87, 0x10, 0x01, 0x00, 0x00, 0xC3
|
||||
]),
|
||||
Some(0x10)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_reach_drops_caller_saved_registers_across_a_call() {
|
||||
// call +0 ; mov 0x200(%rdi),%rax ; ret
|
||||
// RDI is caller-saved, so after a call it holds whatever the callee left. A read through it is
|
||||
// not a read of `this`.
|
||||
assert_eq!(
|
||||
reach_of(&[
|
||||
0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8B, 0x87, 0x00, 0x02, 0x00, 0x00, 0xC3
|
||||
]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_reach_keeps_callee_saved_copies_across_a_call() {
|
||||
// mov %rdi,%rbx ; call +0 ; mov 0x200(%rbx),%rax ; ret
|
||||
// The counterpart: RBX is callee-saved, so the copy survives and the access IS through `this`.
|
||||
assert_eq!(
|
||||
reach_of(&[
|
||||
0x48, 0x89, 0xFB, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8B, 0x83, 0x00, 0x02, 0x00,
|
||||
0x00, 0xC3
|
||||
]),
|
||||
Some(0x200)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_reach_ignores_indexed_operands() {
|
||||
// mov 0x900(%rdi,%rcx,8),%rax ; ret — an array walk; the displacement is a base, not a field
|
||||
// offset, so its magnitude says nothing about the object's size.
|
||||
assert_eq!(
|
||||
reach_of(&[0x48, 0x8B, 0x84, 0xCF, 0x00, 0x09, 0x00, 0x00, 0xC3]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_reach_is_none_when_this_is_never_dereferenced() {
|
||||
// xor %eax,%eax ; ret — a constant returner touches no object at all.
|
||||
assert_eq!(reach_of(&[0x31, 0xC0, 0xC3]), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue