//! Structural, recompilation-invariant fingerprint of a function — integer features only. //! //! Walks the control-flow graph from the entry point (bounded), decoding with iced-x86, and //! summarises shape into counts that survive a recompile: instruction/block/call/branch counts, //! distinct callees, RIP-relative data references, and a mnemonic-category histogram. No //! addresses or immediates enter the vector (those change every build); only structure does. use crate::elf::CodeImage; use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind}; use std::collections::HashSet; pub const NCATS: usize = 18; /// Hash buckets for the referenced-string content sketch. pub const NREF: usize = 32; #[derive(Default, Clone)] pub struct Fingerprint { pub size_bytes: u32, pub insns: u32, pub blocks: u32, pub calls: u32, pub distinct_callees: u32, pub cond_branches: u32, pub uncond_branches: u32, pub rets: u32, pub data_refs: u32, pub indirect: u32, // call-graph context: aggregate shape of this function's callees (a callee's structure is // build-invariant), which distinguishes otherwise-identical thunks by which function they hit. pub ctx_ins: u32, pub ctx_calls: u32, pub ctx_br: u32, pub cats: [u32; NCATS], // content sketch: hash-bucket histogram of the printable rodata strings this function // references. The string *content* is build-invariant and highly function-specific, so it adds // discriminative signal the pure structural counts lack. pub refs: [u32; NREF], } impl Fingerprint { pub fn to_vec(&self) -> Vec { let mut v = vec![ self.size_bytes, self.insns, self.blocks, self.calls, self.distinct_callees, self.cond_branches, self.uncond_branches, self.rets, self.data_refs, self.indirect, self.ctx_ins, self.ctx_calls, self.ctx_br, ]; v.extend_from_slice(&self.cats); v.extend_from_slice(&self.refs); v } } /// If `target` points at a printable C string in read-only data, hash it into a `[0, NREF)` bucket. /// Only strings of a few printable chars count — this skips jump tables and pointer arrays (whose /// bytes are addresses that move across builds), keeping the sketch build-invariant. fn string_bucket(img: &CodeImage, target: u64) -> Option { let bytes = img.rodata_at(target, 32)?; let run: &[u8] = { let end = bytes .iter() .position(|&c| !(0x20..0x7f).contains(&c)) .unwrap_or(bytes.len()); &bytes[..end] }; if run.len() < 3 { return None; } // FNV-1a over the printable run let mut h: u64 = 0xcbf29ce484222325; for &b in run { h = (h ^ b as u64).wrapping_mul(0x100000001b3); } Some((h % NREF as u64) as usize) } fn category(insn: &Instruction) -> usize { match insn.flow_control() { FlowControl::Call | FlowControl::IndirectCall => return 10, FlowControl::UnconditionalBranch | FlowControl::IndirectBranch => return 11, FlowControl::ConditionalBranch => return 12, FlowControl::Return => return 13, _ => {} } if insn.op0_register().is_xmm() || insn.op0_register().is_ymm() || insn.op1_register().is_xmm() || insn.op1_register().is_ymm() { return 17; } use Mnemonic::*; match insn.mnemonic() { Mov | Movzx | Movsx | Movsxd | Xchg => 0, Lea => 1, Push => 2, Pop => 3, Add | Adc | Sub | Sbb | Inc | Dec | Neg => 4, Imul | Mul | Idiv | Div => 5, And | Or | Xor | Not => 6, Shl | Shr | Sar | Rol | Ror | Shld | Shrd => 7, Cmp => 8, Test => 9, Nop | Int3 => 14, Leave => 15, _ => 16, } } fn near_target(insn: &Instruction) -> Option { match insn.op0_kind() { OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64 => { Some(insn.near_branch_target()) } _ => None, } } fn in_span(t: u64, entry: u64, cap: usize) -> bool { t >= entry && (t - entry) as usize <= cap } struct Base { f: Fingerprint, targets: Vec, // outgoing call + tail-jump destinations } /// Cheap, build-invariant summary of a callee — (instructions, calls, branches) over a bounded /// linear scan from its entry. Enough to tell one thunk's target from another's. fn light_summary(img: &CodeImage, addr: u64) -> (u32, u32, u32) { let Some(code) = img.code_at(addr) else { return (0, 0, 0); }; let mut dec = Decoder::with_ip(64, code, addr, DecoderOptions::NONE); let (mut ins, mut calls, mut br) = (0u32, 0u32, 0u32); while dec.can_decode() && ins < 48 { let insn = dec.decode(); if insn.len() == 0 || insn.is_invalid() { break; } ins += 1; match insn.flow_control() { FlowControl::Call | FlowControl::IndirectCall => calls += 1, FlowControl::ConditionalBranch | FlowControl::UnconditionalBranch | FlowControl::IndirectBranch => br += 1, FlowControl::Return => break, _ => {} } } (ins, calls, br) } /// Extract the fingerprint of `entry` — base structure plus one hop of call-graph context. pub fn extract(img: &CodeImage, entry: u64) -> Option { let base = extract_base(img, entry)?; let mut f = base.f; let mut seen = HashSet::new(); for &t in &base.targets { if t != entry && img.is_code(t) && seen.insert(t) { let (ins, calls, br) = light_summary(img, t); f.ctx_ins = f.ctx_ins.wrapping_add(ins); f.ctx_calls = f.ctx_calls.wrapping_add(calls); f.ctx_br = f.ctx_br.wrapping_add(br); } } Some(f) } /// Base structural features + the function's outgoing call/tail-jump targets. fn extract_base(img: &CodeImage, entry: u64) -> Option { let code = img.code_at(entry)?; const MAX_SPAN: usize = 96 * 1024; const MAX_INSNS: u32 = 8000; let cap = code.len().min(MAX_SPAN); let mut f = Fingerprint::default(); let mut visited: HashSet = HashSet::new(); let mut leaders: HashSet = HashSet::new(); let mut callees: HashSet = HashSet::new(); let mut targets: Vec = Vec::new(); let mut hi = entry; let mut work = vec![entry]; leaders.insert(entry); while let Some(start) = work.pop() { let mut ip = start; loop { if ip < entry || (ip - entry) as usize >= cap || f.insns >= MAX_INSNS { break; } if !visited.insert(ip) { break; // this path merged into already-decoded code } let off = (ip - entry) as usize; let mut dec = Decoder::with_ip(64, &code[off..], ip, DecoderOptions::NONE); if !dec.can_decode() { break; } let insn = dec.decode(); let ilen = insn.len() as u64; if ilen == 0 || insn.is_invalid() { break; } f.insns += 1; f.cats[category(&insn)] += 1; if insn.is_ip_rel_memory_operand() { f.data_refs += 1; if let Some(bucket) = string_bucket(img, insn.memory_displacement64()) { f.refs[bucket] += 1; } } if ip + ilen > hi { hi = ip + ilen; } let next = ip + ilen; match insn.flow_control() { FlowControl::Return => { f.rets += 1; break; } FlowControl::Call => { f.calls += 1; match near_target(&insn) { Some(t) => { callees.insert(t); targets.push(t); } None => f.indirect += 1, } ip = next; } FlowControl::IndirectCall => { f.calls += 1; f.indirect += 1; ip = next; } FlowControl::ConditionalBranch => { f.cond_branches += 1; if let Some(t) = near_target(&insn) && in_span(t, entry, cap) { leaders.insert(t); work.push(t); } leaders.insert(next); ip = next; } FlowControl::UnconditionalBranch => { f.uncond_branches += 1; match near_target(&insn) { Some(t) if in_span(t, entry, cap) => { leaders.insert(t); ip = t; } Some(t) => { targets.push(t); // tail call (thunk target) break; } None => break, // indirect jump } } FlowControl::IndirectBranch => { f.indirect += 1; break; } _ => ip = next, } } } if f.insns == 0 { return None; } f.blocks = leaders.len() as u32; f.distinct_callees = callees.len() as u32; f.size_bytes = (hi - entry) as u32; Some(Base { f, targets }) }