diff --git a/decompiler/Cargo.lock b/decompiler/Cargo.lock index 157e7bd2..6caa4604 100644 --- a/decompiler/Cargo.lock +++ b/decompiler/Cargo.lock @@ -23,6 +23,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.0" @@ -265,6 +271,8 @@ dependencies = [ name = "kuna-cli" version = "0.1.0" dependencies = [ + "base64", + "flate2", "kuna-analysis", "kuna-base", "kuna-console", diff --git a/decompiler/crates/kuna-analysis/src/analyzers/noreturn_disc/flow_tests.rs b/decompiler/crates/kuna-analysis/src/analyzers/noreturn_disc/flow_tests.rs new file mode 100644 index 00000000..34158718 --- /dev/null +++ b/decompiler/crates/kuna-analysis/src/analyzers/noreturn_disc/flow_tests.rs @@ -0,0 +1,115 @@ +use super::{call_site_has_no_fallthrough, discover_noreturn}; +use crate::listing::{DiscoveredFunction, FlowKind, FlowType, Insn, Listing}; + +fn instruction(at: u64, kind: FlowKind, fall: Option, targets: &[u64]) -> Insn { + Insn { + addr: at, + len: 4, + fall_through: fall, + flow: FlowType { + kind, + is_call: kind == FlowKind::Call, + is_jump: matches!( + kind, + FlowKind::ConditionalBranch + | FlowKind::UnconditionalBranch + | FlowKind::ComputedJump + ), + is_terminal: kind == FlowKind::Return, + is_conditional: kind == FlowKind::ConditionalBranch, + is_computed: kind == FlowKind::ComputedJump, + is_indirect: kind == FlowKind::ComputedJump, + has_fallthrough: fall.is_some(), + }, + flows: targets.to_vec(), + mnemonic: String::new(), + operands: String::new(), + pcode: None, + } +} + +fn listing(insns: Vec) -> Listing { + let funcs = [0x100, 0x200, 0x300] + .into_iter() + .map(|entry| DiscoveredFunction { + entry, + name: None, + from_symbol: true, + has_no_return: entry == 0x200, + call_fixup: None, + }) + .collect(); + Listing::from_model_for_test(insns, funcs, vec![], vec![(0x100, 0x400)]) +} + +fn concludes(listing: &Listing) -> bool { + discover_noreturn(listing, true) + .iter() + .any(|f| f.addr == 0x100) +} + +#[test] +fn returning_tail_and_fatal_alternative_do_not_make_a_noreturn_function() { + // OpenSSH warning shape: one branch restores LR/SP and tail-transfers to + // free, while an allocation-failure branch calls a known fatal wrapper. + let model = listing(vec![ + instruction(0x100, FlowKind::ConditionalBranch, Some(0x104), &[0x110]), + instruction(0x104, FlowKind::UnconditionalBranch, None, &[0x300]), + instruction(0x110, FlowKind::Call, None, &[0x200]), + ]); + assert!(!concludes(&model)); +} + +#[test] +fn unknown_exits_cannot_be_hidden_by_another_fatal_path() { + for other in [ + instruction(0x104, FlowKind::ComputedJump, None, &[]), + instruction(0x104, FlowKind::Call, None, &[0x300]), + instruction(0x104, FlowKind::Fallthrough, Some(0x108), &[]), + instruction(0x104, FlowKind::UnconditionalBranch, None, &[0x500]), + ] { + let model = listing(vec![ + instruction(0x100, FlowKind::ConditionalBranch, Some(0x104), &[0x110]), + other, + instruction(0x110, FlowKind::Call, None, &[0x200]), + ]); + assert!(!concludes(&model)); + } +} + +#[test] +fn genuine_terminal_paths_and_dead_return_remain_discoverable() { + let both = listing(vec![ + instruction(0x100, FlowKind::ConditionalBranch, Some(0x104), &[0x110]), + instruction(0x104, FlowKind::UnconditionalBranch, None, &[0x200]), + instruction(0x110, FlowKind::Call, None, &[0x200]), + ]); + assert!(concludes(&both)); + let dead_return = listing(vec![ + instruction(0x100, FlowKind::Call, Some(0x104), &[0x200]), + instruction(0x104, FlowKind::Return, None, &[]), + ]); + assert!(concludes(&dead_return)); +} + +#[test] +fn wrapper_fixpoint_keeps_real_noreturn_transitive() { + let model = listing(vec![ + instruction(0x100, FlowKind::Call, None, &[0x300]), + instruction(0x300, FlowKind::Call, None, &[0x200]), + ]); + assert!(concludes(&model)); +} + +#[test] +fn missing_call_successor_is_unknown_rather_than_a_noreturn_vote() { + let model = listing(vec![instruction(0x100, FlowKind::Call, None, &[0x300])]); + assert!(!call_site_has_no_fallthrough(&model, 0x100, 0x300, true)); + let positive = listing(vec![instruction( + 0x100, + FlowKind::Call, + Some(0x200), + &[0x300], + )]); + assert!(call_site_has_no_fallthrough(&positive, 0x100, 0x300, true)); +} diff --git a/decompiler/crates/kuna-analysis/src/analyzers/noreturn_disc/kuna_discstrict.rs b/decompiler/crates/kuna-analysis/src/analyzers/noreturn_disc/kuna_discstrict.rs index f735bea7..78bee36d 100644 --- a/decompiler/crates/kuna-analysis/src/analyzers/noreturn_disc/kuna_discstrict.rs +++ b/decompiler/crates/kuna-analysis/src/analyzers/noreturn_disc/kuna_discstrict.rs @@ -39,9 +39,9 @@ //! - the successor is **another function's entry** — the compiler placed the next //! function immediately after the call, leaving the caller no tail at all. //! -//! The terminal arm (a call with no fall-through VMA at all — a tail jump lowered -//! to a call) stays in the caller, ahead of this test: it is a property of the call -//! instruction, not of its successor. +//! A call with no recorded successor contributes no new vote: a returning tail +//! call also has no local continuation. The caller handles that unknown before +//! this positive-successor test. //! //! Dropping the bare arm is what makes the data arm *live*. `is_data(fall)` implies //! `!is_instruction_start(fall)`, so under the legacy order the data arm was diff --git a/decompiler/crates/kuna-analysis/src/analyzers/noreturn_disc/mod.rs b/decompiler/crates/kuna-analysis/src/analyzers/noreturn_disc/mod.rs index 52134fc3..d8107ba2 100644 --- a/decompiler/crates/kuna-analysis/src/analyzers/noreturn_disc/mod.rs +++ b/decompiler/crates/kuna-analysis/src/analyzers/noreturn_disc/mod.rs @@ -17,8 +17,9 @@ //! the `call` — the compiler knows control never comes back, so it does not emit a //! sane fall-through there. When **≥3** call sites of a callee show this signal //! (Ghidra's evidence threshold), the callee is concluded no-return. The result is -//! iterated to a fixpoint: a function whose last act is a call to an -//! already-discovered-no-return function is itself no-return. +//! iterated to a fixpoint. Promotion requires every reachable path to end in +//! known no-return code, using the same walk as structural propagation. A +//! terminal alternative alone cannot erase another returning or unknown exit. //! //! This was **infeasible at the kuna-analysis tier until the Listing/xref tier** //! (`docs/history/listing-tier-design.md`): it needs the program-wide disassembled Listing @@ -51,9 +52,8 @@ //! //! # Faithful scope / LOSS //! -//! - **Evidence threshold = 3**, matching Ghidra's `EVIDENCE_THRESHOLD`. Below the -//! threshold a callee is left alone (a custom wrapper called only once or twice -//! is not concluded no-return — the heuristic needs corroboration). +//! - **Evidence threshold = 3** for the call-site heuristic. Below it, only +//! separate all-path promotion can prove a known-terminal wrapper no-return. //! - The "no-valid-fall-through" predicate is the Listing-faithful subset Ghidra's //! `checkForNoReturn` reduces to at this tier: the fall-through VMA landed in //! data (`is_data` — outside every executable range), or it is another @@ -71,11 +71,15 @@ use std::collections::{BTreeMap, BTreeSet}; +use super::noreturn_propagate::known_noreturn_paths; use crate::listing::{Listing, RefKind}; use crate::pass::{AnalysisCtx, AnalysisOutput, AnalysisPass, NoReturnFact, Phase}; pub mod kuna_discstrict; +#[cfg(test)] +mod flow_tests; + /// Ghidra's `FindNoReturnFunctionsAnalyzer` evidence threshold: a callee is /// concluded no-return once at least this many of its call sites show no valid /// fall-through (`EVIDENCE_THRESHOLD` / the operand-vote count). @@ -117,14 +121,14 @@ impl AnalysisPass for NoReturnDiscoveredPass { /// The instruction after the call (`call_vma + len`) has no valid fall-through /// when it is not a decoded instruction start, OR it landed in data, OR it is the /// entry of *another* function (the compiler placed the next function immediately -/// after the dead call). A call instruction with no recorded fall-through VMA -/// (terminal — e.g. a tail-call lowered to a jump) is itself evidence. +/// after the dead call). A missing successor alone is unknown: tail calls to +/// returning functions also have no local fall-through. /// /// With `strict` (`noreturn_discstrict`, GH-312) the bare "not a decoded /// instruction start" arm is dropped: the Listing walk always attempts a call's /// successor, so that arm is a decode-failure detector and votes a callee -/// no-return on kuna's own spec gaps. Only the terminal arm and the two positive -/// arms survive — see [`kuna_discstrict`]. +/// no-return on kuna's own spec gaps. Only the two positive +/// successor arms survive — see [`kuna_discstrict`]. fn call_site_has_no_fallthrough( listing: &Listing, call_vma: u64, @@ -136,8 +140,10 @@ fn call_site_has_no_fallthrough( return false; }; let Some(fall) = insn.fall_through else { - // A call with no fall-through at all (a lowered tail jump) is evidence. - return true; + // No local successor is compatible with a returning tail call, an + // incomplete flow model, or a known no-return override. None can supply + // new evidence about a callee whose return behaviour is still unknown. + return false; }; if strict { return kuna_discstrict::successor_is_positive_evidence(listing, fall, callee); @@ -187,48 +193,6 @@ fn skip_callee(listing: &Listing, callee: u64, terminal: &BTreeSet) -> bool } } -/// Does function `entry` flow ONLY into an already-terminal (no-return) function -/// — the fixpoint promotion rule (a function whose every terminal path is a call -/// to an already-discovered-no-return function, with no returning path, is itself -/// no-return)? -/// -/// Sound guard against false positives: we require BOTH (a) the function's -/// instruction span `[entry, next_entry)` contains a terminal call to a function -/// in `terminal` (a no-fall-through call to a no-return callee), AND (b) the -/// function contains NO `RETURN` instruction (no path returns). A function with a -/// `ret` somewhere returns on that path and must not be promoted by this rule -/// (the call-site evidence rule still applies to it independently). This is the -/// cheap, Listing-faithful form of Ghidra's worklist feedback (a newly no-return -/// function re-flags its tail-calling callers). -fn last_act_is_terminal_call( - listing: &Listing, - entry: u64, - terminal: &BTreeSet, - strict: bool, -) -> bool { - let next = listing.next_function_after(entry).map(|f| f.entry); - let mut has_terminal_call = false; - for (&vma, insn) in listing.instructions_in_range(entry, next) { - // (b) Any returning path disqualifies the function from this rule. - if insn.flow.is_terminal { - return false; - } - if !insn.flow.is_call { - continue; - } - // (a) A call whose static callee is already terminal AND which itself has - // no valid fall-through (the call is a terminal flow of this function). - for &callee in &insn.flows { - if terminal.contains(&callee) - && call_site_has_no_fallthrough(listing, vma, callee, strict) - { - has_terminal_call = true; - } - } - } - has_terminal_call -} - /// Run the evidence-tally + fixpoint over the Listing and return the discovered /// no-return functions as [`NoReturnFact`]s (design §6 + Ghidra's /// `FindNoReturnFunctionsAnalyzer`). @@ -236,9 +200,9 @@ fn last_act_is_terminal_call( /// Two rules, iterated to a fixpoint: /// 1. **Call-site evidence**: a callee with `>= EVIDENCE_THRESHOLD` (3) call sites /// showing no valid fall-through is no-return. -/// 2. **Last-act promotion** (the fixpoint feedback): a function whose terminal -/// instruction is a call to an already-discovered-no-return function is itself -/// no-return — so a wrapper-of-a-wrapper converges. +/// 2. **Reachable-path promotion**: the shared propagation walk proves every +/// reachable exit is to an already-no-return callee. Returning tails, missing +/// successors, unresolved jumps and escaping branches remain unknown. /// /// The result is keyed by entry VMA (a `BTreeMap` so the emitted facts are /// address-ordered and deduped). The `terminal` set seeds with the Known-list @@ -246,8 +210,8 @@ fn last_act_is_terminal_call( fn discover_noreturn(listing: &Listing, strict: bool) -> Vec { let mut discovered: BTreeMap = BTreeMap::new(); // The "already no-return" anchor set: the Known-seeded functions plus - // everything discovered so far. A call to one of these is a no-fall-through - // site for its caller (rule 2 reads this). + // everything discovered so far. The shared path walk treats a transfer to + // one of these as ending that path (rule 2 reads this). let mut terminal: BTreeSet = listing .functions() .filter(|(_, f)| f.has_no_return) @@ -272,9 +236,9 @@ fn discover_noreturn(listing: &Listing, strict: bool) -> Vec { if skip_callee(listing, entry, &terminal) { continue; } - // Rule 1: direct call-site evidence. Rule 2: terminal-call promotion. + // Rule 1: direct call-site evidence. Rule 2: shared all-path promotion. let concluded = no_fallthrough_evidence(listing, entry, strict) >= EVIDENCE_THRESHOLD - || last_act_is_terminal_call(listing, entry, &terminal, strict); + || known_noreturn_paths(listing, entry, &terminal); if concluded { let name = listing .function_at(entry) @@ -294,12 +258,7 @@ fn discover_noreturn(listing: &Listing, strict: bool) -> Vec { mod tests { use super::*; - // The fixpoint convergence + threshold logic is exercised end-to-end by the - // cross-crate `verify_noreturn_disc.rs` gate (a real x86-64 fixture with a - // custom `die()` wrapper called >=3x). A unit test here would need a hand-built - // `Listing`, whose private fields are not constructible outside the `listing` - // module; the threshold/predicate are pinned by that e2e + the predicate - // helpers below stay tiny and obviously-correct by inspection. + // Flow semantics and the actual fixpoint are exercised in flow_tests. #[test] fn threshold_is_three() { diff --git a/decompiler/crates/kuna-analysis/src/analyzers/noreturn_propagate/mod.rs b/decompiler/crates/kuna-analysis/src/analyzers/noreturn_propagate/mod.rs index f334a5a2..5b72c223 100644 --- a/decompiler/crates/kuna-analysis/src/analyzers/noreturn_propagate/mod.rs +++ b/decompiler/crates/kuna-analysis/src/analyzers/noreturn_propagate/mod.rs @@ -457,6 +457,18 @@ fn function_reaches_only_noreturn( error_recog: &ErrorRecognizer, ) -> bool { let next = listing.next_function_after(entry).map(|f| f.entry); + // A terminal transfer is necessary. Most functions have none: avoid a + // per-function map allocation and CFG walk for them in both consumers. + let has_terminal_transfer = listing.instructions_in_range(entry, next).any(|(_, insn)| { + (insn.flow.is_call || insn.flow.is_jump) + && insn.flows.iter().any(|target| { + terminal.contains(target) + || (insn.flow.is_call && error_recog.is_error(*target)) + }) + }); + if !has_terminal_transfer { + return false; + } // Address-indexed body for O(log n) successor lookup. let body: BTreeMap = listing .instructions_in_range(entry, next) @@ -465,6 +477,18 @@ fn function_reaches_only_noreturn( reaches_only_noreturn_walk(&body, entry, next, terminal, error_recog) } +/// Discovered-no-return promotion shares the conservative CFG owner. The +/// discovery pass has no per-call error(status) recognition authority, so only +/// its current known-terminal set can end a path. A tail transfer to anything +/// else preserves the caller's continuation as possibly returning. +pub(super) fn known_noreturn_paths( + listing: &Listing, + entry: u64, + terminal: &BTreeSet, +) -> bool { + function_reaches_only_noreturn(listing, entry, terminal, &ErrorRecognizer::disabled()) +} + /// The reachability walk core (extracted for unit testing): from `entry`, walk the /// instruction-level reachable graph of `body` (bounded `[entry, next)`), treating /// a call/jump to a callee in `terminal` (or a value-conditional `error(nonzero,…)` diff --git a/decompiler/crates/kuna-analysis/src/listing/kuna_pc_relative.rs b/decompiler/crates/kuna-analysis/src/listing/kuna_pc_relative.rs new file mode 100644 index 00000000..5385192d --- /dev/null +++ b/decompiler/crates/kuna-analysis/src/listing/kuna_pc_relative.rs @@ -0,0 +1,493 @@ +//! Compose bounded, native p-code PC-relative address formations for xrefs. +//! +//! Pool words require a later contribution from the instruction's own PC. +//! AArch64 page bases require an ADRP mnemonic from SLEIGH plus its exact +//! constant COPY; a later COPY/ADD/SUB can then materialize the full address. +//! Unknown writes, overlapping slices, calls, jumps and returns terminate the +//! affected knowledge. Space identity is retained. State is function-local, +//! follows only fall-through addresses, and retains the existing eight-hop / +//! eight-value bounds. This is candidate static address formation, not runtime +//! feasibility, alias analysis or a complete data-flow fixed point. + +use std::collections::HashMap; +use std::rc::Rc; + +use kuna_base::space::{spacetype, AddrSpace}; +use kuna_num::opcodes::OpCode; +use kuna_num::pcoderaw::VarnodeData; + +use super::kuna_poolref::PoolImage; +use super::xrefs::{in_range, looks_like_address, FullOp, XrefKind}; + +/// How many instructions a pool value may travel before the `add` that composes +/// it. GCC and Clang emit the pair adjacent (the displacement is measured from +/// the `add`'s own label, so the two are one template), but instruction +/// scheduling separates them when several references are formed at once. Bounded +/// because every hop is one more instruction over which the tracked register is +/// believed. +const MAX_HOPS: u8 = 8; + +/// How far past an instruction's own address a constant may lie and still be its +/// materialized PC. A32 reads `pc` as `inst_start + 8`, Thumb as `+ 4`, x86 `rip` +/// as the fall-through; nothing needs more slack than that. +const PC_WINDOW: u64 = 16; + +/// How many pool values may be tracked into one instruction. A join point can be +/// reached from several predecessors, each carrying its own; the cap keeps that +/// from accumulating on a pathological CFG. +const MAX_TRACKED: usize = 8; + +/// A provenanced value in processor storage; spaces are pointer identities +/// owned by the live architecture, never serialized or compared across models. +#[derive(Debug, Clone, Copy)] +struct Pending { + key: Key, + known: Known, +} + +/// One instruction and its separately validated architecture-specific seed. +pub(super) struct Instruction<'a> { + pub vma: u64, + pub len: u32, + pub ops: &'a [FullOp], + pub pc_page: Option, +} + +/// Shape of an exact constant page COPY, not sufficient authority by itself. +/// The caller must check SLEIGH's mnemonic for this same decoded instruction. +pub(super) struct PcPageCopy(Pending); +impl PcPageCopy { + pub(super) fn candidate(ops: &[FullOp], data: Option<&Rc>) -> Option { + let [op] = ops else { return None }; + if op.opcode != OpCode::CPUI_COPY { + return None; + } + let out = op.out.as_ref()?; + let key = key_of(out, data)?; + let [input] = op.ins.as_slice() else { + return None; + }; + if !matches!(key, Key::Reg(..)) + || out.size != 8 + || input.size != 8 + || !is_constant(input) + || input.offset & 0xfff != 0 + { + return None; + } + Some(Self(Pending { + key, + known: Known::seed(input.offset, true), + })) + } + pub(super) fn for_mnemonic(self, mnemonic: &str) -> Option { + mnemonic.eq_ignore_ascii_case("ADRP").then_some(self) + } +} + +/// The pool values one function's walk is carrying, keyed by the address of the +/// instruction they reach. +/// +/// Keyed by address rather than held as "the previous instruction's state" +/// because [`super::xrefs::build_with_focus`] walks a function breadth-first: at +/// a conditional branch the queue interleaves the two successors, so "the +/// instruction decoded before this one" is not the instruction that precedes it. +#[derive(Default)] +pub(super) struct PcRelativeValues { + at: HashMap>, +} + +impl PcRelativeValues { + /// Consume one decoded instruction, and answer the addresses it composed. + /// + /// Called for every instruction of the function in whatever order the walk + /// reaches them; the answer is empty for all but the `add` of a PIC pair. + pub(super) fn step( + &mut self, + instruction: Instruction<'_>, + pool: Option<&PoolImage>, + mapped: &[(u64, u64)], + data_space: Option<&Rc>, + ) -> Vec<(u64, XrefKind)> { + let Instruction { + vma, + len, + ops, + pc_page, + } = instruction; + // Hashing every instruction's address would be this walk's whole cost on + // an architecture that never forms one of these pairs; nothing is in + // flight until a pool load puts it there. + let pending = if self.at.is_empty() { + Vec::new() + } else { + self.at.remove(&vma).unwrap_or_default() + }; + let (mut refs, mut carry) = if pending.is_empty() { + (Vec::new(), Vec::new()) + } else { + evaluate(vma, ops, &pending, mapped, data_space) + }; + + // A load out of the pool starts (or restarts) the value its register + // holds, so the pair `ldr; add` needs no separate entry point. + if let Some(fresh) = pc_page + .map(|p| p.0) + .or_else(|| pool.and_then(|p| seed(ops, p, mapped, data_space))) + { + carry.retain(|p| !overlaps(p, &fresh)); + carry.push(fresh); + } + + if !carry.is_empty() && !leaves_the_run(ops) { + let next = vma.wrapping_add(u64::from(len)); + let slot = self.at.entry(next).or_default(); + for mut p in carry { + p.known.hops += 1; + if p.known.hops <= MAX_HOPS && slot.len() < MAX_TRACKED { + slot.push(p); + } + } + if slot.is_empty() { + self.at.remove(&next); + } + } + + refs.sort_unstable(); + refs.dedup(); + refs + } +} + +/// The composed addresses this instruction forms, and the tracked values that +/// survive it. +fn evaluate( + vma: u64, + ops: &[FullOp], + pending: &[Pending], + mapped: &[(u64, u64)], + data: Option<&Rc>, +) -> (Vec<(u64, XrefKind)>, Vec) { + let mut vals: Vec<(Key, Known)> = pending.iter().map(|p| (p.key, p.known)).collect(); + let mut found: Vec<(u64, XrefKind)> = Vec::new(); + + let predicated = internal_predicate(ops); + for op in ops { + if !predicated { + if let Some(reference) = memory_reference(op, vma, &vals, data, mapped) { + found.push(reference); + } + } + let folded = if predicated { + None + } else { + fold(op, vma, &vals, data) + }; + let Some(out) = &op.out else { continue }; + let key = key_of(out, data); + invalidate(&mut vals, key); + let (Some(key), Some(mut val)) = (key, folded) else { + continue; + }; + val.v &= mask(out.size); + if matches!(key, Key::Reg(..)) && val.seed && val.pc && in_range(mapped, val.v) { + found.push((val.v, XrefKind::Data)); + } + vals.push((key, val)); + } + + // Carry register copies as well as original seeds; instruction-local + // temporaries never survive this boundary. Unknown/partial writes have + // already removed every overlapping value in that same native space. + let survivors = vals + .into_iter() + .filter(|(key, val)| matches!(key, Key::Reg(..)) && val.seed) + .map(|(key, known)| Pending { key, known }) + .collect(); + (found, survivors) +} + +/// The pool word this instruction loads into a register, when it loads one that +/// [`super::kuna_poolref`] declined to follow as a pointer. +/// +/// The first such load, which on every architecture that forms these pairs is +/// also the only one: a literal load's address is a decode-time constant, and a +/// multi-register load (`ldm`) computes its addresses from a base register. +fn seed( + ops: &[FullOp], + pool: &PoolImage, + mapped: &[(u64, u64)], + data: Option<&Rc>, +) -> Option { + let data_index = data.map(|d| d.get_index()); + if internal_predicate(ops) { + return None; + } + for (index, op) in ops.iter().enumerate() { + if op.opcode != OpCode::CPUI_LOAD { + continue; + } + let Some(out) = &op.out else { continue }; + if !matches!(key_of(out, data), Some(Key::Reg(..))) || out.size != pool.ptr_width() { + continue; + } + // `in0` is the space id: a load through some other space is not a read of + // the image. + let through_image = op + .ins + .first() + .zip(data_index) + .is_some_and(|(vn, idx)| vn.offset == u64::try_from(idx).unwrap_or(u64::MAX)); + if !through_image { + continue; + } + let Some(addr) = op.ins.get(1).filter(|vn| is_constant(vn)) else { + continue; + }; + let Some(word) = pool.word_at(addr.offset) else { + continue; + }; + if looks_like_address(word) && in_range(mapped, word) { + continue; // a pointer, which is `poolref`'s to follow + } + let key = key_of(out, data)?; + let pending = Pending { + key, + known: Known::seed(word, false), + }; + if ops[index + 1..] + .iter() + .filter_map(|op| op.out.as_ref()) + .filter_map(|out| key_of(out, data)) + .any(|key| { + overlaps( + &pending, + &Pending { + key, + known: pending.known, + }, + ) + }) + { + continue; + } + return Some(pending); + } + None +} + +/// An instruction-local conditional may skip its writes. Do not pretend all +/// p-code paths execute in sequence; invalidate outputs and retain only values +/// it cannot change. Machine conditional branches target code space instead. +fn internal_predicate(ops: &[FullOp]) -> bool { + ops.iter() + .any(|op| op.opcode == OpCode::CPUI_CBRANCH && op.ins.first().is_some_and(is_constant)) +} + +/// Does control leave the straight-line run here, so the fall-through is not the +/// next thing executed (or a call has clobbered the registers in between)? +/// +/// A `CBRANCH` is deliberately not in the set: its fall-through IS a successor, +/// it writes nothing, and ARM spells a predicated instruction that way. +fn leaves_the_run(ops: &[FullOp]) -> bool { + ops.iter().any(|op| { + matches!( + op.opcode, + OpCode::CPUI_BRANCH + | OpCode::CPUI_BRANCHIND + | OpCode::CPUI_CALL + | OpCode::CPUI_CALLIND + | OpCode::CPUI_CALLOTHER + | OpCode::CPUI_RETURN + ) + }) +} + +// --- the one-instruction constant fold --------------------------------------- + +/// A value the fold knows, and where it came from. Both provenance bits must be +/// set for the result to be a PIC address: `seed` alone is the pool word being +/// used as a number, `pc` alone is any PC-relative arithmetic. +#[derive(Debug, Clone, Copy)] +struct Known { + v: u64, + /// A validated image displacement or PC-page COPY contributed to this value. + seed: bool, + /// A PC contribution is known (possibly from an earlier ADRP). + pc: bool, + hops: u8, +} + +impl Known { + fn seed(v: u64, pc: bool) -> Self { + Known { + v, + seed: true, + pc, + hops: 0, + } + } +} + +/// A varnode's identity, in the two classes the fold models. `register` and `ram` +/// are both processor spaces, so the space type alone cannot tell them apart — +/// the default data space is what separates them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Key { + Reg(usize, u64, u32), + Tmp(usize, u64, u32), +} + +fn key_of(vn: &VarnodeData, data: Option<&Rc>) -> Option { + let space = vn.space.as_ref()?; + match space.get_type() { + spacetype::IPTR_INTERNAL => Some(Key::Tmp(Rc::as_ptr(space) as usize, vn.offset, vn.size)), + spacetype::IPTR_PROCESSOR if !matches!(data, Some(d) if Rc::ptr_eq(space, d)) => { + Some(Key::Reg(Rc::as_ptr(space) as usize, vn.offset, vn.size)) + } + _ => None, + } +} + +fn is_constant(vn: &VarnodeData) -> bool { + vn.space + .as_ref() + .is_some_and(|s| s.get_type() == spacetype::IPTR_CONSTANT) +} + +/// The value this op produces, or `None` when the fold does not model it. +fn fold( + op: &FullOp, + vma: u64, + vals: &[(Key, Known)], + data: Option<&Rc>, +) -> Option { + if !(1..=8).contains(&op.out.as_ref()?.size) { + return None; + } + let get = |i: usize| known_value(op.ins.get(i)?, vma, vals, data); + let a = get(0)?; + match op.opcode { + OpCode::CPUI_COPY => Some(a), + OpCode::CPUI_INT_ADD => { + let b = get(1)?; + Some(Known { + v: a.v.wrapping_add(b.v), + seed: a.seed || b.seed, + pc: a.pc || b.pc, + hops: a.hops.max(b.hops), + }) + } + OpCode::CPUI_INT_SUB => { + let b = get(1)?; + Some(Known { + v: a.v.wrapping_sub(b.v), + seed: a.seed || b.seed, + pc: a.pc || b.pc, + hops: a.hops.max(b.hops), + }) + } + _ => None, + } +} + +fn known_value( + vn: &VarnodeData, + vma: u64, + vals: &[(Key, Known)], + data: Option<&Rc>, +) -> Option { + if !(1..=8).contains(&vn.size) { + return None; + } + if is_constant(vn) { + return Some(Known { + v: vn.offset & mask(vn.size), + seed: false, + pc: is_own_pc(vn.offset, vma), + hops: 0, + }); + } + let key = key_of(vn, data)?; + vals.iter().rev().find(|(k, _)| *k == key).map(|(_, v)| *v) +} + +/// Only the native default-data space's byte-addressed LOAD/STORE operand +/// is an access. Space selectors and stored integer values are not pointers. +/// The cell is referenced; no mutable cell/GOT content is propagated as live. +fn memory_reference( + op: &FullOp, + vma: u64, + vals: &[(Key, Known)], + data: Option<&Rc>, + mapped: &[(u64, u64)], +) -> Option<(u64, XrefKind)> { + let kind = match op.opcode { + OpCode::CPUI_LOAD if op.ins.len() == 2 && op.out.as_ref().is_some_and(|v| v.size > 0) => { + XrefKind::Read + } + OpCode::CPUI_STORE if op.ins.len() == 3 && op.out.is_none() && op.ins[2].size > 0 => { + XrefKind::Write + } + _ => return None, + }; + let data = data?; + if data.get_word_size() != 1 { + return None; + } + let selector = op.ins.first()?; + if !is_constant(selector) || u64::try_from(data.get_index()).ok()? != selector.offset { + return None; + } + let pointer = known_value(op.ins.get(1)?, vma, vals, Some(data))?; + (pointer.seed && pointer.pc && in_range(mapped, pointer.v)).then_some((pointer.v, kind)) +} + +/// Is `c` this instruction materializing its own program counter? +fn is_own_pc(c: u64, vma: u64) -> bool { + c >= vma && c <= vma.wrapping_add(PC_WINDOW) +} + +/// Drop every tracked value whose bytes overlap the write to `key`. Keying on the +/// exact triple would let a write to `r0` leave a stale two-byte view of it +/// behind, which is the one way this fold could hand out a value the program does +/// not hold. +fn invalidate(vals: &mut Vec<(Key, Known)>, key: Option) { + let Some(key) = key else { return }; + let (cls, off, size) = split(key); + vals.retain(|(k, _)| { + let (c, o, s) = split(*k); + c != cls || !spans_overlap(o, s, off, size) + }); +} + +fn split(key: Key) -> ((u8, usize), u64, u32) { + match key { + Key::Reg(space, off, size) => ((0, space), off, size), + Key::Tmp(space, off, size) => ((1, space), off, size), + } +} + +fn spans_overlap(a_off: u64, a_size: u32, b_off: u64, b_size: u32) -> bool { + let (a, b) = (u128::from(a_off), u128::from(b_off)); + a < b + u128::from(b_size) && b < a + u128::from(a_size) +} + +fn overlaps(a: &Pending, b: &Pending) -> bool { + let (ac, ao, az) = split(a.key); + let (bc, bo, bz) = split(b.key); + ac == bc && spans_overlap(ao, az, bo, bz) +} + +/// The byte mask of an `n`-byte value. +fn mask(size: u32) -> u64 { + if size == 0 || size >= 8 { + u64::MAX + } else { + (1u64 << (8 * size)) - 1 + } +} + +#[cfg(test)] +#[path = "kuna_pc_relative/tests.rs"] +mod tests; diff --git a/decompiler/crates/kuna-analysis/src/listing/kuna_pc_relative/tests.rs b/decompiler/crates/kuna-analysis/src/listing/kuna_pc_relative/tests.rs new file mode 100644 index 00000000..d52c80c8 --- /dev/null +++ b/decompiler/crates/kuna-analysis/src/listing/kuna_pc_relative/tests.rs @@ -0,0 +1,698 @@ +use super::*; + +/// The reduction of the filing image: a read-only pool at 0x6a0 holding the +/// displacement -0x1c1, and `.rodata` at 0x400. +const POOL: [u8; 8] = [ + 0x3f, 0xfe, 0xff, 0xff, // 0x6a0 -> 0xfffffe3f (= -0x1c1) + 0x00, 0x05, 0x00, 0x00, // 0x6a4 -> 0x500 (a mapped address) +]; +const MAPPED: [(u64, u64); 2] = [(0x400, 0x4c3), (0x4c4, 0x6b0)]; + +fn image() -> PoolImage<'static> { + PoolImage::from_ranges(vec![(0x6a0, 0x6a8, &POOL[..])], 4, true).unwrap() +} + +/// A throwaway space set: `ram` stands in for the default data space, +/// `register` for any other processor space, as the two are told apart in +/// the engine. +struct Spaces { + konst: Rc, + ram: Rc, + reg: Rc, + uniq: Rc, +} + +fn spaces() -> Spaces { + Spaces { + konst: Rc::new(AddrSpace::new_for_decode(spacetype::IPTR_CONSTANT)), + ram: Rc::new(AddrSpace::new_for_decode(spacetype::IPTR_PROCESSOR)), + reg: Rc::new(AddrSpace::new_for_decode(spacetype::IPTR_PROCESSOR)), + uniq: Rc::new(AddrSpace::new_for_decode(spacetype::IPTR_INTERNAL)), + } +} + +/// The space-id constant a `LOAD` through `ram` carries in `in0`. +fn ram_id(s: &Spaces) -> u64 { + u64::try_from(s.ram.get_index()).unwrap() +} + +fn vn(space: &Rc, offset: u64, size: u32) -> VarnodeData { + VarnodeData { + space: Some(Rc::clone(space)), + offset, + size, + } +} + +fn op(opcode: OpCode, out: Option, ins: Vec) -> FullOp { + FullOp { opcode, out, ins } +} + +/// `ldr r0,[pc,#imm]` — SLEIGH resolves the PC-relative address at decode +/// time, so the load reads a constant location. +fn ldr(s: &Spaces, reg: u64, at: u64) -> Vec { + vec![op( + OpCode::CPUI_LOAD, + Some(vn(&s.reg, reg, 4)), + vec![vn(&s.konst, ram_id(s), 8), vn(&s.konst, at, 4)], + )] +} + +/// `add rN,pc,rN` at `vma`, exactly as the ARM SLEIGH lifts it: the PC is +/// `inst_start + 8`, formed in a temporary first. +fn add_pc(s: &Spaces, reg: u64, vma: u64) -> Vec { + vec![ + op( + OpCode::CPUI_INT_ADD, + Some(vn(&s.uniq, 0x2f00, 4)), + vec![vn(&s.konst, vma, 4), vn(&s.konst, 8, 4)], + ), + op( + OpCode::CPUI_INT_ADD, + Some(vn(&s.reg, reg, 4)), + vec![vn(&s.uniq, 0x2f00, 4), vn(&s.reg, reg, 4)], + ), + ] +} + +/// The defect: the address of the literal is in no instruction and in no pool +/// word — only in the two put together. +#[test] +fn a_pool_displacement_added_to_the_pc_is_the_address_it_forms() { + let s = spaces(); + let mut pp = PcRelativeValues::default(); + assert!(pp + .step( + Instruction { + vma: 0x660, + len: 4, + ops: &ldr(&s, 0x20, 0x6a0), + pc_page: None + }, + Some(&image()), + &MAPPED, + Some(&s.ram) + ) + .is_empty()); + assert_eq!( + pp.step( + Instruction { + vma: 0x664, + len: 4, + ops: &add_pc(&s, 0x20, 0x664), + pc_page: None + }, + Some(&image()), + &MAPPED, + Some(&s.ram) + ), + vec![(0x4ab, XrefKind::Data)], + "0x66c + (-0x1c1)" + ); +} + +/// The pair need not be adjacent: instruction scheduling separates the load +/// from the `add` when a function forms several references at once. +#[test] +fn the_add_may_sit_a_few_instructions_past_the_load() { + let s = spaces(); + let mut pp = PcRelativeValues::default(); + let nop = vec![op( + OpCode::CPUI_INT_ADD, + Some(vn(&s.reg, 0x30, 4)), + vec![vn(&s.reg, 0x30, 4), vn(&s.konst, 4, 4)], + )]; + pp.step( + Instruction { + vma: 0x660, + len: 4, + ops: &ldr(&s, 0x20, 0x6a0), + pc_page: None, + }, + Some(&image()), + &MAPPED, + Some(&s.ram), + ); + pp.step( + Instruction { + vma: 0x664, + len: 4, + ops: &nop, + pc_page: None, + }, + Some(&image()), + &MAPPED, + Some(&s.ram), + ); + assert_eq!( + pp.step( + Instruction { + vma: 0x668, + len: 4, + ops: &add_pc(&s, 0x20, 0x668), + pc_page: None + }, + Some(&image()), + &MAPPED, + Some(&s.ram) + ), + vec![(0x4af, XrefKind::Data)] + ); +} + +/// Overwriting the register between the load and the `add` ends the value: +/// what the `add` composes is whatever was written last. +#[test] +fn a_write_to_the_register_ends_the_tracked_word() { + let s = spaces(); + let mut pp = PcRelativeValues::default(); + let clobber = vec![op( + OpCode::CPUI_COPY, + Some(vn(&s.reg, 0x20, 4)), + vec![vn(&s.konst, 0, 4)], + )]; + pp.step( + Instruction { + vma: 0x660, + len: 4, + ops: &ldr(&s, 0x20, 0x6a0), + pc_page: None, + }, + Some(&image()), + &MAPPED, + Some(&s.ram), + ); + pp.step( + Instruction { + vma: 0x664, + len: 4, + ops: &clobber, + pc_page: None, + }, + Some(&image()), + &MAPPED, + Some(&s.ram), + ); + assert!(pp + .step( + Instruction { + vma: 0x668, + len: 4, + ops: &add_pc(&s, 0x20, 0x668), + pc_page: None + }, + Some(&image()), + &MAPPED, + Some(&s.ram) + ) + .is_empty()); +} + +/// A call clobbers the caller-saved registers, so nothing survives it. +#[test] +fn a_call_ends_the_tracked_word() { + let s = spaces(); + let mut pp = PcRelativeValues::default(); + let mut call = ldr(&s, 0x20, 0x6a0); + call.push(op(OpCode::CPUI_CALL, None, vec![vn(&s.ram, 0x700, 4)])); + pp.step( + Instruction { + vma: 0x660, + len: 4, + ops: &call, + pc_page: None, + }, + Some(&image()), + &MAPPED, + Some(&s.ram), + ); + assert!(pp + .step( + Instruction { + vma: 0x664, + len: 4, + ops: &add_pc(&s, 0x20, 0x664), + pc_page: None + }, + Some(&image()), + &MAPPED, + Some(&s.ram) + ) + .is_empty()); +} + +/// The three refusals, each of which would be a fabricated reference. +#[test] +fn arithmetic_without_the_pc_a_pointer_word_and_an_unmapped_sum_are_declined() { + let s = spaces(); + + // `add r0,r0,#8` — the pool word used as a number, not as a base. + let mut pp = PcRelativeValues::default(); + pp.step( + Instruction { + vma: 0x660, + len: 4, + ops: &ldr(&s, 0x20, 0x6a0), + pc_page: None, + }, + Some(&image()), + &MAPPED, + Some(&s.ram), + ); + let no_pc = vec![op( + OpCode::CPUI_INT_ADD, + Some(vn(&s.reg, 0x20, 4)), + vec![vn(&s.reg, 0x20, 4), vn(&s.konst, 8, 4)], + )]; + assert!(pp + .step( + Instruction { + vma: 0x664, + len: 4, + ops: &no_pc, + pc_page: None + }, + Some(&image()), + &MAPPED, + Some(&s.ram) + ) + .is_empty()); + + // The word at 0x6a4 IS a mapped address, so it is `poolref`'s pointer to + // follow and never a displacement. + let mut pp = PcRelativeValues::default(); + pp.step( + Instruction { + vma: 0x660, + len: 4, + ops: &ldr(&s, 0x20, 0x6a4), + pc_page: None, + }, + Some(&image()), + &MAPPED, + Some(&s.ram), + ); + assert!(pp + .step( + Instruction { + vma: 0x664, + len: 4, + ops: &add_pc(&s, 0x20, 0x664), + pc_page: None + }, + Some(&image()), + &MAPPED, + Some(&s.ram) + ) + .is_empty()); + + // A sum landing in no section is not an address, whatever formed it. + let mut pp = PcRelativeValues::default(); + pp.step( + Instruction { + vma: 0x660, + len: 4, + ops: &ldr(&s, 0x20, 0x6a0), + pc_page: None, + }, + Some(&image()), + &MAPPED, + Some(&s.ram), + ); + assert!(pp + .step( + Instruction { + vma: 0x664, + len: 4, + ops: &add_pc(&s, 0x20, 0x664), + pc_page: None + }, + Some(&image()), + &[(0x9000, 0x9100)], + Some(&s.ram) + ) + .is_empty()); +} + +/// The GOT idiom `ldr r1,[pc,#imm]; ldr r1,[pc,r1]` composes its address into +/// a temporary and dereferences it. The address is never held in a register, +/// so it is a cell read, not an address-taken relation or a claim about the +/// value loaded out of that cell. +#[test] +fn a_composed_temporary_load_references_the_cell_without_an_address_taken_claim() { + let s = spaces(); + let mut pp = PcRelativeValues::default(); + pp.step( + Instruction { + vma: 0x660, + len: 4, + ops: &ldr(&s, 0x24, 0x6a0), + pc_page: None, + }, + Some(&image()), + &MAPPED, + Some(&s.ram), + ); + let indirect = vec![ + op( + OpCode::CPUI_INT_ADD, + Some(vn(&s.uniq, 0x2f00, 4)), + vec![vn(&s.konst, 0x664, 4), vn(&s.konst, 8, 4)], + ), + op( + OpCode::CPUI_INT_ADD, + Some(vn(&s.uniq, 0x10900, 4)), + vec![vn(&s.uniq, 0x2f00, 4), vn(&s.reg, 0x24, 4)], + ), + op( + OpCode::CPUI_LOAD, + Some(vn(&s.reg, 0x24, 4)), + vec![vn(&s.konst, ram_id(&s), 8), vn(&s.uniq, 0x10900, 4)], + ), + ]; + assert_eq!( + pp.step( + Instruction { + vma: 0x664, + len: 4, + ops: &indirect, + pc_page: None + }, + Some(&image()), + &MAPPED, + Some(&s.ram) + ), + vec![(0x4ab, XrefKind::Read)] + ); +} + +/// A narrow or unaligned pool read is not a slot at all, which is +/// [`PoolImage`]'s rule and is inherited here. +#[test] +fn a_narrow_pool_read_is_not_a_displacement() { + let s = spaces(); + let mut pp = PcRelativeValues::default(); + let narrow = vec![op( + OpCode::CPUI_LOAD, + Some(vn(&s.reg, 0x20, 2)), + vec![vn(&s.konst, ram_id(&s), 8), vn(&s.konst, 0x6a0, 4)], + )]; + pp.step( + Instruction { + vma: 0x660, + len: 4, + ops: &narrow, + pc_page: None, + }, + Some(&image()), + &MAPPED, + Some(&s.ram), + ); + assert!(pp + .step( + Instruction { + vma: 0x664, + len: 4, + ops: &add_pc(&s, 0x20, 0x664), + pc_page: None + }, + Some(&image()), + &MAPPED, + Some(&s.ram) + ) + .is_empty()); +} + +/// Nothing is tracked past the hop bound. +#[test] +fn a_word_is_not_believed_forever() { + let s = spaces(); + let mut pp = PcRelativeValues::default(); + let nothing: Vec = Vec::new(); + pp.step( + Instruction { + vma: 0x600, + len: 4, + ops: &ldr(&s, 0x20, 0x6a0), + pc_page: None, + }, + Some(&image()), + &MAPPED, + Some(&s.ram), + ); + let mut vma = 0x604; + for _ in 0..MAX_HOPS { + pp.step( + Instruction { + vma: vma, + len: 4, + ops: ¬hing, + pc_page: None, + }, + Some(&image()), + &MAPPED, + Some(&s.ram), + ); + vma += 4; + } + assert!(pp + .step( + Instruction { + vma: vma, + len: 4, + ops: &add_pc(&s, 0x20, vma), + pc_page: None + }, + Some(&image()), + &MAPPED, + Some(&s.ram) + ) + .is_empty()); +} + +fn page_copy(s: &Spaces, reg: u64) -> Vec { + vec![op( + OpCode::CPUI_COPY, + Some(vn(&s.reg, reg, 8)), + vec![vn(&s.konst, 0xb0000, 8)], + )] +} +fn page_add(s: &Spaces, source: u64, output: u64, width: u32) -> Vec { + vec![op( + OpCode::CPUI_INT_ADD, + Some(vn(&s.reg, output, width)), + vec![vn(&s.reg, source, 8), vn(&s.konst, 0x468, 8)], + )] +} +fn begin_page(pp: &mut PcRelativeValues, s: &Spaces) { + let ops = page_copy(s, 0x4030); + let seed = PcPageCopy::candidate(&ops, Some(&s.ram)) + .unwrap() + .for_mnemonic("ADRP"); + assert!(pp + .step( + Instruction { + vma: 0x2574c, + len: 4, + ops: &ops, + pc_page: seed + }, + None, + &[(0xb0000, 0xb2000)], + Some(&s.ram) + ) + .is_empty()); +} +fn advance( + pp: &mut PcRelativeValues, + s: &Spaces, + vma: u64, + ops: &[FullOp], +) -> Vec<(u64, XrefKind)> { + pp.step( + Instruction { + vma, + len: 4, + ops, + pc_page: None, + }, + None, + &[(0xb0000, 0xb2000)], + Some(&s.ram), + ) +} +#[test] +fn adrp_add_composes_without_a_literal_pool_or_a_register_offset_table() { + let s = spaces(); + let mut pp = PcRelativeValues::default(); + begin_page(&mut pp, &s); + assert_eq!( + advance(&mut pp, &s, 0x25750, &page_add(&s, 0x4030, 0x4030, 8)), + vec![(0xb0468, XrefKind::Data)] + ); +} +#[test] +fn a_plain_constant_copy_is_not_a_pc_page_authority() { + let s = spaces(); + let ops = page_copy(&s, 0x4030); + assert!(PcPageCopy::candidate(&ops, Some(&s.ram)) + .unwrap() + .for_mnemonic("MOV") + .is_none()); + let mut pp = PcRelativeValues::default(); + advance(&mut pp, &s, 0x2574c, &ops); + assert!(advance(&mut pp, &s, 0x25750, &page_add(&s, 0x4030, 0x4030, 8)).is_empty()); + let mut narrow = page_copy(&s, 0x4030); + narrow[0].out.as_mut().unwrap().size = 4; + assert!(PcPageCopy::candidate(&narrow, Some(&s.ram)).is_none()); +} +#[test] +fn scheduled_register_copies_carry_identity_and_width() { + let s = spaces(); + let mut pp = PcRelativeValues::default(); + begin_page(&mut pp, &s); + advance( + &mut pp, + &s, + 0x25750, + &[op( + OpCode::CPUI_COPY, + Some(vn(&s.reg, 0x4040, 8)), + vec![vn(&s.reg, 0x4030, 8)], + )], + ); + assert_eq!( + advance(&mut pp, &s, 0x25754, &page_add(&s, 0x4040, 0x4040, 8)), + vec![(0xb0468, XrefKind::Data)] + ); +} +#[test] +fn narrow_overwrites_end_the_page_but_other_spaces_do_not_clobber_it() { + let s = spaces(); + for foreign in [false, true] { + let mut pp = PcRelativeValues::default(); + begin_page(&mut pp, &s); + let other = Rc::new(AddrSpace::new_for_decode(spacetype::IPTR_PROCESSOR)); + let space = if foreign { &other } else { &s.reg }; + advance( + &mut pp, + &s, + 0x25750, + &[op( + OpCode::CPUI_COPY, + Some(vn(space, 0x4034, 4)), + vec![vn(&s.konst, 0, 4)], + )], + ); + let refs = advance(&mut pp, &s, 0x25754, &page_add(&s, 0x4030, 0x4030, 8)); + assert_eq!( + refs, + if foreign { + vec![(0xb0468, XrefKind::Data)] + } else { + vec![] + } + ); + } +} +#[test] +fn control_barriers_and_unmodelled_widths_do_not_invent_addresses() { + let s = spaces(); + for opcode in [ + OpCode::CPUI_CALL, + OpCode::CPUI_CALLIND, + OpCode::CPUI_BRANCH, + OpCode::CPUI_BRANCHIND, + OpCode::CPUI_RETURN, + ] { + let mut pp = PcRelativeValues::default(); + begin_page(&mut pp, &s); + advance(&mut pp, &s, 0x25750, &[op(opcode, None, vec![])]); + assert!(advance(&mut pp, &s, 0x25754, &page_add(&s, 0x4030, 0x4030, 8)).is_empty()); + } + for width in [0, 16] { + let mut pp = PcRelativeValues::default(); + begin_page(&mut pp, &s); + assert!(advance(&mut pp, &s, 0x25750, &page_add(&s, 0x4030, 0x4030, width)).is_empty()); + } +} +#[test] +fn internal_predicates_and_same_instruction_pool_clobbers_are_unknown() { + let s = spaces(); + let mut pp = PcRelativeValues::default(); + begin_page(&mut pp, &s); + let mut conditional = vec![op( + OpCode::CPUI_CBRANCH, + None, + vec![vn(&s.konst, 2, 8), vn(&s.reg, 0, 1)], + )]; + conditional.extend(page_add(&s, 0x4030, 0x4030, 8)); + assert!(advance(&mut pp, &s, 0x25750, &conditional).is_empty()); + assert!(advance(&mut pp, &s, 0x25754, &page_add(&s, 0x4030, 0x4030, 8)).is_empty()); + let mut load = ldr(&s, 0x20, 0x6a0); + load.push(op( + OpCode::CPUI_COPY, + Some(vn(&s.reg, 0x20, 4)), + vec![vn(&s.konst, 0, 4)], + )); + assert!(seed(&load, &image(), &MAPPED, Some(&s.ram)).is_none()); +} +#[test] +fn the_last_u64_byte_is_still_a_real_overlapping_storage_byte() { + assert!(spans_overlap(u64::MAX, 1, u64::MAX, 1)); + assert!(spans_overlap(u64::MAX - 1, 2, u64::MAX, 1)); + assert!(!spans_overlap(u64::MAX - 1, 1, u64::MAX, 1)); +} + +#[test] +fn page_based_load_and_store_reference_the_cell_without_claiming_its_contents() { + let s = spaces(); + for opcode in [OpCode::CPUI_LOAD, OpCode::CPUI_STORE] { + let mut pp = PcRelativeValues::default(); + begin_page(&mut pp, &s); + let mut ops = vec![op( + OpCode::CPUI_INT_ADD, + Some(vn(&s.uniq, 0x10, 8)), + vec![vn(&s.reg, 0x4030, 8), vn(&s.konst, 0x468, 8)], + )]; + ops.push(op( + opcode, + if opcode == OpCode::CPUI_LOAD { + Some(vn(&s.reg, 0x4040, 8)) + } else { + None + }, + if opcode == OpCode::CPUI_LOAD { + vec![vn(&s.konst, ram_id(&s), 8), vn(&s.uniq, 0x10, 8)] + } else { + vec![ + vn(&s.konst, ram_id(&s), 8), + vn(&s.uniq, 0x10, 8), + vn(&s.reg, 0, 8), + ] + }, + )); + let kind = if opcode == OpCode::CPUI_LOAD { + XrefKind::Read + } else { + XrefKind::Write + }; + assert_eq!(advance(&mut pp, &s, 0x25750, &ops), vec![(0xb0468, kind)]); + // LOAD content stays unknown even when its address was proven. + assert!(advance(&mut pp, &s, 0x25754, &page_add(&s, 0x4040, 0x4040, 8)).is_empty()); + } +} +#[test] +fn foreign_or_nonconstant_space_selectors_do_not_create_data_accesses() { + let s = spaces(); + for selector in [vn(&s.konst, ram_id(&s) + 1, 8), vn(&s.reg, ram_id(&s), 8)] { + let mut pp = PcRelativeValues::default(); + begin_page(&mut pp, &s); + let ops = vec![op( + OpCode::CPUI_LOAD, + Some(vn(&s.reg, 0x4040, 8)), + vec![selector, vn(&s.reg, 0x4030, 8)], + )]; + assert!(advance(&mut pp, &s, 0x25750, &ops).is_empty()); + } +} diff --git a/decompiler/crates/kuna-analysis/src/listing/kuna_picpool.rs b/decompiler/crates/kuna-analysis/src/listing/kuna_picpool.rs deleted file mode 100644 index 053f04d8..00000000 --- a/decompiler/crates/kuna-analysis/src/listing/kuna_picpool.rs +++ /dev/null @@ -1,618 +0,0 @@ -//! (kuna) `picpool` — compose a literal-pool word with the PC that adds it, so a -//! position-independent string reference has an owning function. -//! -//! # The gap -//! -//! [`super::kuna_poolref`] follows a pool word as an **absolute** pointer. In a -//! position-independent ARM image the pool holds no pointer at all: it holds the -//! signed distance from the `add` that consumes it to the datum, and the address -//! only exists once the two are put together. -//! -//! ```text -//! 0x660 ldr r0,[0x6a0] ; r0 = the word at 0x6a0 = 0xfffffe3f -//! 0x664 add r0,pc,r0 ; r0 = 0x66c + (-0x1c1) = 0x4ab, "Benar! Flag: ..." -//! 0x668 bl printf -//! ... -//! 0x6a0 3f fe ff ff -//! ``` -//! -//! `poolref` correctly declines 0xfffffe3f — it is not an address and lands in no -//! section — so nothing in the program references 0x4ab, `kuna xrefs --to` it -//! answers zero and `kuna strings --json` reports it with no owning function, on -//! a binary whose disassembly plainly shows which function prints it. The idiom -//! is not one site either: the filing image forms every one of its four string -//! references this way. -//! -//! This is [`super::kuna_picbase`]'s defect with a per-site base instead of a -//! module-wide one. There the PIC base is a register a prologue establishes once -//! and the whole function inherits; here each reference carries its own base — -//! the PC of its own `add` — so there is nothing to detect once and nothing to -//! scope. -//! -//! # The rule -//! -//! Two decode-time constants, composed, and nothing else. A pool word is admitted -//! as a *displacement* only when -//! -//! * it comes out of the same read-only, pointer-sized, pointer-aligned slot -//! [`PoolImage`] already vouches for — the image's copy of it is what runs; -//! * `poolref` **declined** it, so the two mechanisms never claim one word: a -//! value that is already a mapped address is a pointer, not a displacement; -//! * a later instruction in the same straight-line run folds it, through -//! `COPY`/`INT_ADD`/`INT_SUB` alone, together with a constant this -//! instruction materialized from its **own address** (SLEIGH spells a PC read -//! that way: `inst_start + 8` on A32, `+ 4` on Thumb, the fall-through on -//! x86); -//! * and the folded value lands in a **register** and in a mapped section. -//! -//! Then the composed address is filed as [`XrefKind::Data`](super::xrefs::XrefKind::Data) -//! from the instruction that completed it — the address-taken case, which is -//! exactly what the `add` did, and attributing it to an instruction rather than -//! to the pool word is what gives the reference an owning function. -//! -//! `ScalarOperandAnalyzer.checkOperands`' "below 4096 could be a number" floor -//! is deliberately **not** applied to the composed value. That floor asks whether -//! an *immediate* is an address or an integer; here the question is already -//! settled by the PC, and applying it would discard every reference in an image -//! mapped low — which the filing Android PIE is, its whole layout living under -//! 0x2828. - -use std::collections::HashMap; -use std::rc::Rc; - -use kuna_base::space::{spacetype, AddrSpace}; -use kuna_num::opcodes::OpCode; -use kuna_num::pcoderaw::VarnodeData; - -use super::kuna_poolref::PoolImage; -use super::xrefs::{in_range, looks_like_address, FullOp}; - -/// How many instructions a pool value may travel before the `add` that composes -/// it. GCC and Clang emit the pair adjacent (the displacement is measured from -/// the `add`'s own label, so the two are one template), but instruction -/// scheduling separates them when several references are formed at once. Bounded -/// because every hop is one more instruction over which the tracked register is -/// believed. -const MAX_HOPS: u8 = 8; - -/// How far past an instruction's own address a constant may lie and still be its -/// materialized PC. A32 reads `pc` as `inst_start + 8`, Thumb as `+ 4`, x86 `rip` -/// as the fall-through; nothing needs more slack than that. -const PC_WINDOW: u64 = 16; - -/// How many pool values may be tracked into one instruction. A join point can be -/// reached from several predecessors, each carrying its own; the cap keeps that -/// from accumulating on a pathological CFG. -const MAX_TRACKED: usize = 8; - -/// A pool word in flight: the register holding it, its value, and how far it has -/// travelled from the load. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct Pending { - /// Register-space offset and size of the varnode holding the word. - off: u64, - size: u32, - /// The word, zero-extended out of the image. The composition is a wrapping - /// add masked to the register width, so a negative displacement needs no - /// separate sign extension. - word: u64, - hops: u8, -} - -/// The pool values one function's walk is carrying, keyed by the address of the -/// instruction they reach. -/// -/// Keyed by address rather than held as "the previous instruction's state" -/// because [`super::xrefs::build_with_focus`] walks a function breadth-first: at -/// a conditional branch the queue interleaves the two successors, so "the -/// instruction decoded before this one" is not the instruction that precedes it. -#[derive(Default)] -pub(super) struct PicPool { - at: HashMap>, -} - -impl PicPool { - /// Consume one decoded instruction, and answer the addresses it composed. - /// - /// Called for every instruction of the function in whatever order the walk - /// reaches them; the answer is empty for all but the `add` of a PIC pair. - pub(super) fn step( - &mut self, - vma: u64, - len: u32, - ops: &[FullOp], - pool: &PoolImage, - mapped: &[(u64, u64)], - data_space: Option<&Rc>, - ) -> Vec { - // Hashing every instruction's address would be this walk's whole cost on - // an architecture that never forms one of these pairs; nothing is in - // flight until a pool load puts it there. - let pending = - if self.at.is_empty() { Vec::new() } else { self.at.remove(&vma).unwrap_or_default() }; - let (mut refs, mut carry) = if pending.is_empty() { - (Vec::new(), Vec::new()) - } else { - evaluate(vma, ops, &pending, mapped, data_space) - }; - - // A load out of the pool starts (or restarts) the value its register - // holds, so the pair `ldr; add` needs no separate entry point. - if let Some(fresh) = seed(ops, pool, mapped, data_space) { - carry.retain(|p| !overlaps(p, &fresh)); - carry.push(fresh); - } - - if !carry.is_empty() && !leaves_the_run(ops) { - let next = vma.wrapping_add(u64::from(len)); - let slot = self.at.entry(next).or_default(); - for mut p in carry { - p.hops += 1; - if p.hops <= MAX_HOPS && slot.len() < MAX_TRACKED { - slot.push(p); - } - } - if slot.is_empty() { - self.at.remove(&next); - } - } - - refs.sort_unstable(); - refs.dedup(); - refs - } -} - -/// The composed addresses this instruction forms, and the tracked values that -/// survive it. -fn evaluate( - vma: u64, - ops: &[FullOp], - pending: &[Pending], - mapped: &[(u64, u64)], - data: Option<&Rc>, -) -> (Vec, Vec) { - let mut vals: Vec<(Key, Known)> = - pending.iter().map(|p| (Key::Reg(p.off, p.size), Known::seed(p.word))).collect(); - let mut written: Vec<(u64, u32)> = Vec::new(); - let mut found: Vec = Vec::new(); - - for op in ops { - let folded = fold(op, vma, &vals, data); - let Some(out) = &op.out else { continue }; - let key = key_of(out, data); - if let Some(Key::Reg(off, size)) = key { - written.push((off, size)); - } - invalidate(&mut vals, key); - let (Some(key), Some(mut val)) = (key, folded) else { continue }; - val.v &= mask(out.size); - if matches!(key, Key::Reg(..)) && val.seed && val.pc && in_range(mapped, val.v) { - found.push(val.v); - } - vals.push((key, val)); - } - - let survivors = pending - .iter() - .filter(|p| { - !written.iter().any(|&(off, size)| { - spans_overlap(p.off, p.size, off, size) - }) - }) - .copied() - .collect(); - (found, survivors) -} - -/// The pool word this instruction loads into a register, when it loads one that -/// [`super::kuna_poolref`] declined to follow as a pointer. -/// -/// The first such load, which on every architecture that forms these pairs is -/// also the only one: a literal load's address is a decode-time constant, and a -/// multi-register load (`ldm`) computes its addresses from a base register. -fn seed( - ops: &[FullOp], - pool: &PoolImage, - mapped: &[(u64, u64)], - data: Option<&Rc>, -) -> Option { - let data_index = data.map(|d| d.get_index()); - for op in ops { - if op.opcode != OpCode::CPUI_LOAD { - continue; - } - let Some(out) = &op.out else { continue }; - if !matches!(key_of(out, data), Some(Key::Reg(..))) || out.size != pool.ptr_width() { - continue; - } - // `in0` is the space id: a load through some other space is not a read of - // the image. - let through_image = op - .ins - .first() - .zip(data_index) - .is_some_and(|(vn, idx)| vn.offset == u64::try_from(idx).unwrap_or(u64::MAX)); - if !through_image { - continue; - } - let Some(addr) = op.ins.get(1).filter(|vn| is_constant(vn)) else { continue }; - let Some(word) = pool.word_at(addr.offset) else { continue }; - if looks_like_address(word) && in_range(mapped, word) { - continue; // a pointer, which is `poolref`'s to follow - } - return Some(Pending { off: out.offset, size: out.size, word, hops: 0 }); - } - None -} - -/// Does control leave the straight-line run here, so the fall-through is not the -/// next thing executed (or a call has clobbered the registers in between)? -/// -/// A `CBRANCH` is deliberately not in the set: its fall-through IS a successor, -/// it writes nothing, and ARM spells a predicated instruction that way. -fn leaves_the_run(ops: &[FullOp]) -> bool { - ops.iter().any(|op| { - matches!( - op.opcode, - OpCode::CPUI_BRANCH - | OpCode::CPUI_BRANCHIND - | OpCode::CPUI_CALL - | OpCode::CPUI_CALLIND - | OpCode::CPUI_CALLOTHER - | OpCode::CPUI_RETURN - ) - }) -} - -// --- the one-instruction constant fold --------------------------------------- - -/// A value the fold knows, and where it came from. Both provenance bits must be -/// set for the result to be a PIC address: `seed` alone is the pool word being -/// used as a number, `pc` alone is any PC-relative arithmetic. -#[derive(Debug, Clone, Copy)] -struct Known { - v: u64, - /// A pool word contributed to this value. - seed: bool, - /// This instruction's own PC contributed to this value. - pc: bool, -} - -impl Known { - fn seed(v: u64) -> Self { - Known { v, seed: true, pc: false } - } -} - -/// A varnode's identity, in the two classes the fold models. `register` and `ram` -/// are both processor spaces, so the space type alone cannot tell them apart — -/// the default data space is what separates them. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Key { - Reg(u64, u32), - Tmp(u64, u32), -} - -fn key_of(vn: &VarnodeData, data: Option<&Rc>) -> Option { - let space = vn.space.as_ref()?; - match space.get_type() { - spacetype::IPTR_INTERNAL => Some(Key::Tmp(vn.offset, vn.size)), - spacetype::IPTR_PROCESSOR if !matches!(data, Some(d) if Rc::ptr_eq(space, d)) => { - Some(Key::Reg(vn.offset, vn.size)) - } - _ => None, - } -} - -fn is_constant(vn: &VarnodeData) -> bool { - vn.space.as_ref().is_some_and(|s| s.get_type() == spacetype::IPTR_CONSTANT) -} - -/// The value this op produces, or `None` when the fold does not model it. -fn fold( - op: &FullOp, - vma: u64, - vals: &[(Key, Known)], - data: Option<&Rc>, -) -> Option { - let get = |i: usize| -> Option { - let vn = op.ins.get(i)?; - if is_constant(vn) { - return Some(Known { - v: vn.offset & mask(vn.size), - seed: false, - pc: is_own_pc(vn.offset, vma), - }); - } - let key = key_of(vn, data)?; - vals.iter().rev().find(|(k, _)| *k == key).map(|(_, v)| *v) - }; - let a = get(0)?; - match op.opcode { - OpCode::CPUI_COPY => Some(a), - OpCode::CPUI_INT_ADD => { - let b = get(1)?; - Some(Known { v: a.v.wrapping_add(b.v), seed: a.seed || b.seed, pc: a.pc || b.pc }) - } - OpCode::CPUI_INT_SUB => { - let b = get(1)?; - Some(Known { v: a.v.wrapping_sub(b.v), seed: a.seed || b.seed, pc: a.pc || b.pc }) - } - _ => None, - } -} - -/// Is `c` this instruction materializing its own program counter? -fn is_own_pc(c: u64, vma: u64) -> bool { - c >= vma && c <= vma.wrapping_add(PC_WINDOW) -} - -/// Drop every tracked value whose bytes overlap the write to `key`. Keying on the -/// exact triple would let a write to `r0` leave a stale two-byte view of it -/// behind, which is the one way this fold could hand out a value the program does -/// not hold. -fn invalidate(vals: &mut Vec<(Key, Known)>, key: Option) { - let Some(key) = key else { return }; - let (cls, off, size) = split(key); - vals.retain(|(k, _)| { - let (c, o, s) = split(*k); - c != cls || !spans_overlap(o, s, off, size) - }); -} - -fn split(key: Key) -> (u8, u64, u32) { - match key { - Key::Reg(off, size) => (0, off, size), - Key::Tmp(off, size) => (1, off, size), - } -} - -fn spans_overlap(a_off: u64, a_size: u32, b_off: u64, b_size: u32) -> bool { - let a_end = a_off.saturating_add(u64::from(a_size)); - let b_end = b_off.saturating_add(u64::from(b_size)); - a_off < b_end && b_off < a_end -} - -fn overlaps(a: &Pending, b: &Pending) -> bool { - spans_overlap(a.off, a.size, b.off, b.size) -} - -/// The byte mask of an `n`-byte value. -fn mask(size: u32) -> u64 { - if size == 0 || size >= 8 { - u64::MAX - } else { - (1u64 << (8 * size)) - 1 - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The reduction of the filing image: a read-only pool at 0x6a0 holding the - /// displacement -0x1c1, and `.rodata` at 0x400. - const POOL: [u8; 8] = [ - 0x3f, 0xfe, 0xff, 0xff, // 0x6a0 -> 0xfffffe3f (= -0x1c1) - 0x00, 0x05, 0x00, 0x00, // 0x6a4 -> 0x500 (a mapped address) - ]; - const MAPPED: [(u64, u64); 2] = [(0x400, 0x4c3), (0x4c4, 0x6b0)]; - - fn image() -> PoolImage<'static> { - PoolImage::from_ranges(vec![(0x6a0, 0x6a8, &POOL[..])], 4, true).unwrap() - } - - /// A throwaway space set: `ram` stands in for the default data space, - /// `register` for any other processor space, as the two are told apart in - /// the engine. - struct Spaces { - konst: Rc, - ram: Rc, - reg: Rc, - uniq: Rc, - } - - fn spaces() -> Spaces { - Spaces { - konst: Rc::new(AddrSpace::new_for_decode(spacetype::IPTR_CONSTANT)), - ram: Rc::new(AddrSpace::new_for_decode(spacetype::IPTR_PROCESSOR)), - reg: Rc::new(AddrSpace::new_for_decode(spacetype::IPTR_PROCESSOR)), - uniq: Rc::new(AddrSpace::new_for_decode(spacetype::IPTR_INTERNAL)), - } - } - - /// The space-id constant a `LOAD` through `ram` carries in `in0`. - fn ram_id(s: &Spaces) -> u64 { - u64::try_from(s.ram.get_index()).unwrap() - } - - fn vn(space: &Rc, offset: u64, size: u32) -> VarnodeData { - VarnodeData { space: Some(Rc::clone(space)), offset, size } - } - - fn op(opcode: OpCode, out: Option, ins: Vec) -> FullOp { - FullOp { opcode, out, ins } - } - - /// `ldr r0,[pc,#imm]` — SLEIGH resolves the PC-relative address at decode - /// time, so the load reads a constant location. - fn ldr(s: &Spaces, reg: u64, at: u64) -> Vec { - vec![op( - OpCode::CPUI_LOAD, - Some(vn(&s.reg, reg, 4)), - vec![vn(&s.konst, ram_id(s), 8), vn(&s.konst, at, 4)], - )] - } - - /// `add rN,pc,rN` at `vma`, exactly as the ARM SLEIGH lifts it: the PC is - /// `inst_start + 8`, formed in a temporary first. - fn add_pc(s: &Spaces, reg: u64, vma: u64) -> Vec { - vec![ - op( - OpCode::CPUI_INT_ADD, - Some(vn(&s.uniq, 0x2f00, 4)), - vec![vn(&s.konst, vma, 4), vn(&s.konst, 8, 4)], - ), - op( - OpCode::CPUI_INT_ADD, - Some(vn(&s.reg, reg, 4)), - vec![vn(&s.uniq, 0x2f00, 4), vn(&s.reg, reg, 4)], - ), - ] - } - - /// The defect: the address of the literal is in no instruction and in no pool - /// word — only in the two put together. - #[test] - fn a_pool_displacement_added_to_the_pc_is_the_address_it_forms() { - let s = spaces(); - let mut pp = PicPool::default(); - assert!(pp.step(0x660, 4, &ldr(&s, 0x20, 0x6a0), &image(), &MAPPED, Some(&s.ram)).is_empty()); - assert_eq!( - pp.step(0x664, 4, &add_pc(&s, 0x20, 0x664), &image(), &MAPPED, Some(&s.ram)), - vec![0x4ab], - "0x66c + (-0x1c1)" - ); - } - - /// The pair need not be adjacent: instruction scheduling separates the load - /// from the `add` when a function forms several references at once. - #[test] - fn the_add_may_sit_a_few_instructions_past_the_load() { - let s = spaces(); - let mut pp = PicPool::default(); - let nop = vec![op( - OpCode::CPUI_INT_ADD, - Some(vn(&s.reg, 0x30, 4)), - vec![vn(&s.reg, 0x30, 4), vn(&s.konst, 4, 4)], - )]; - pp.step(0x660, 4, &ldr(&s, 0x20, 0x6a0), &image(), &MAPPED, Some(&s.ram)); - pp.step(0x664, 4, &nop, &image(), &MAPPED, Some(&s.ram)); - assert_eq!( - pp.step(0x668, 4, &add_pc(&s, 0x20, 0x668), &image(), &MAPPED, Some(&s.ram)), - vec![0x4af] - ); - } - - /// Overwriting the register between the load and the `add` ends the value: - /// what the `add` composes is whatever was written last. - #[test] - fn a_write_to_the_register_ends_the_tracked_word() { - let s = spaces(); - let mut pp = PicPool::default(); - let clobber = - vec![op(OpCode::CPUI_COPY, Some(vn(&s.reg, 0x20, 4)), vec![vn(&s.konst, 0, 4)])]; - pp.step(0x660, 4, &ldr(&s, 0x20, 0x6a0), &image(), &MAPPED, Some(&s.ram)); - pp.step(0x664, 4, &clobber, &image(), &MAPPED, Some(&s.ram)); - assert!(pp - .step(0x668, 4, &add_pc(&s, 0x20, 0x668), &image(), &MAPPED, Some(&s.ram)) - .is_empty()); - } - - /// A call clobbers the caller-saved registers, so nothing survives it. - #[test] - fn a_call_ends_the_tracked_word() { - let s = spaces(); - let mut pp = PicPool::default(); - let mut call = ldr(&s, 0x20, 0x6a0); - call.push(op(OpCode::CPUI_CALL, None, vec![vn(&s.ram, 0x700, 4)])); - pp.step(0x660, 4, &call, &image(), &MAPPED, Some(&s.ram)); - assert!(pp - .step(0x664, 4, &add_pc(&s, 0x20, 0x664), &image(), &MAPPED, Some(&s.ram)) - .is_empty()); - } - - /// The three refusals, each of which would be a fabricated reference. - #[test] - fn arithmetic_without_the_pc_a_pointer_word_and_an_unmapped_sum_are_declined() { - let s = spaces(); - - // `add r0,r0,#8` — the pool word used as a number, not as a base. - let mut pp = PicPool::default(); - pp.step(0x660, 4, &ldr(&s, 0x20, 0x6a0), &image(), &MAPPED, Some(&s.ram)); - let no_pc = vec![op( - OpCode::CPUI_INT_ADD, - Some(vn(&s.reg, 0x20, 4)), - vec![vn(&s.reg, 0x20, 4), vn(&s.konst, 8, 4)], - )]; - assert!(pp.step(0x664, 4, &no_pc, &image(), &MAPPED, Some(&s.ram)).is_empty()); - - // The word at 0x6a4 IS a mapped address, so it is `poolref`'s pointer to - // follow and never a displacement. - let mut pp = PicPool::default(); - pp.step(0x660, 4, &ldr(&s, 0x20, 0x6a4), &image(), &MAPPED, Some(&s.ram)); - assert!(pp - .step(0x664, 4, &add_pc(&s, 0x20, 0x664), &image(), &MAPPED, Some(&s.ram)) - .is_empty()); - - // A sum landing in no section is not an address, whatever formed it. - let mut pp = PicPool::default(); - pp.step(0x660, 4, &ldr(&s, 0x20, 0x6a0), &image(), &MAPPED, Some(&s.ram)); - assert!(pp - .step(0x664, 4, &add_pc(&s, 0x20, 0x664), &image(), &[(0x9000, 0x9100)], Some(&s.ram)) - .is_empty()); - } - - /// The GOT idiom `ldr r1,[pc,#imm]; ldr r1,[pc,r1]` composes its address into - /// a temporary and dereferences it. The address is never held in a register, - /// so nothing is filed — the double indirection is not this mechanism's. - #[test] - fn a_composed_address_that_stays_in_a_temporary_files_nothing() { - let s = spaces(); - let mut pp = PicPool::default(); - pp.step(0x660, 4, &ldr(&s, 0x24, 0x6a0), &image(), &MAPPED, Some(&s.ram)); - let indirect = vec![ - op( - OpCode::CPUI_INT_ADD, - Some(vn(&s.uniq, 0x2f00, 4)), - vec![vn(&s.konst, 0x664, 4), vn(&s.konst, 8, 4)], - ), - op( - OpCode::CPUI_INT_ADD, - Some(vn(&s.uniq, 0x10900, 4)), - vec![vn(&s.uniq, 0x2f00, 4), vn(&s.reg, 0x24, 4)], - ), - op( - OpCode::CPUI_LOAD, - Some(vn(&s.reg, 0x24, 4)), - vec![vn(&s.konst, ram_id(&s), 8), vn(&s.uniq, 0x10900, 4)], - ), - ]; - assert!(pp.step(0x664, 4, &indirect, &image(), &MAPPED, Some(&s.ram)).is_empty()); - } - - /// A narrow or unaligned pool read is not a slot at all, which is - /// [`PoolImage`]'s rule and is inherited here. - #[test] - fn a_narrow_pool_read_is_not_a_displacement() { - let s = spaces(); - let mut pp = PicPool::default(); - let narrow = vec![op( - OpCode::CPUI_LOAD, - Some(vn(&s.reg, 0x20, 2)), - vec![vn(&s.konst, ram_id(&s), 8), vn(&s.konst, 0x6a0, 4)], - )]; - pp.step(0x660, 4, &narrow, &image(), &MAPPED, Some(&s.ram)); - assert!(pp - .step(0x664, 4, &add_pc(&s, 0x20, 0x664), &image(), &MAPPED, Some(&s.ram)) - .is_empty()); - } - - /// Nothing is tracked past the hop bound. - #[test] - fn a_word_is_not_believed_forever() { - let s = spaces(); - let mut pp = PicPool::default(); - let nothing: Vec = Vec::new(); - pp.step(0x600, 4, &ldr(&s, 0x20, 0x6a0), &image(), &MAPPED, Some(&s.ram)); - let mut vma = 0x604; - for _ in 0..MAX_HOPS { - pp.step(vma, 4, ¬hing, &image(), &MAPPED, Some(&s.ram)); - vma += 4; - } - assert!(pp - .step(vma, 4, &add_pc(&s, 0x20, vma), &image(), &MAPPED, Some(&s.ram)) - .is_empty()); - } -} diff --git a/decompiler/crates/kuna-analysis/src/listing/kuna_switchtable.rs b/decompiler/crates/kuna-analysis/src/listing/kuna_switchtable.rs index 1b2ef57d..0691dca7 100644 --- a/decompiler/crates/kuna-analysis/src/listing/kuna_switchtable.rs +++ b/decompiler/crates/kuna-analysis/src/listing/kuna_switchtable.rs @@ -428,7 +428,7 @@ pub(super) enum Val { } /// A varnode's identity, in the two classes the fold models — the same split -/// [`super::kuna_picpool`] makes, since `register` and `ram` are both processor +/// [`super::kuna_pc_relative`] makes, since `register` and `ram` are both processor /// spaces and only the default data space tells them apart. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum Key { diff --git a/decompiler/crates/kuna-analysis/src/listing/mod.rs b/decompiler/crates/kuna-analysis/src/listing/mod.rs index e0f09af5..f69ee298 100644 --- a/decompiler/crates/kuna-analysis/src/listing/mod.rs +++ b/decompiler/crates/kuna-analysis/src/listing/mod.rs @@ -48,7 +48,7 @@ mod kuna_armwalkmode; pub(crate) mod kuna_framemode; pub(crate) mod kuna_decodereuse; mod kuna_picbase; -mod kuna_picpool; +mod kuna_pc_relative; mod kuna_poolref; mod kuna_ppclocalentry; mod kuna_switchtable; diff --git a/decompiler/crates/kuna-analysis/src/listing/xrefs.rs b/decompiler/crates/kuna-analysis/src/listing/xrefs.rs index b39058b1..9dc7ec0f 100644 --- a/decompiler/crates/kuna-analysis/src/listing/xrefs.rs +++ b/decompiler/crates/kuna-analysis/src/listing/xrefs.rs @@ -78,7 +78,7 @@ use object::SectionKind; use super::classify::classify; use super::context::ContextPainter; use super::kuna_picbase::{self, Ctx as PicCtx, PicBase}; -use super::kuna_picpool::PicPool; +use super::kuna_pc_relative::{Instruction, PcPageCopy, PcRelativeValues}; use super::kuna_poolref::PoolImage; use super::kuna_switchtable; use super::model::{FlowKind, RawOp}; @@ -554,10 +554,13 @@ impl PcodeEmit for FullCapture { #[derive(Default)] struct AsmCapture { text: String, + mnemonic: String, } impl AssemblyEmit for AsmCapture { fn dump(&mut self, _addr: &Address, mnem: &str, body: &str) { + self.mnemonic.clear(); + self.mnemonic.push_str(mnem); self.text.clear(); self.text.push_str(mnem); if !body.is_empty() { @@ -698,6 +701,7 @@ fn descend( // processor). `None` when the program has no data space: no varnode can // match it, so the direct-access projection simply contributes nothing. let data_space = arch.manage().get_default_data_space().cloned(); + let aarch64 = arch.get_description().starts_with("AARCH64:"); // (kuna) `picbase`: the module's PIC base register, when the program has one // this can prove. Detected before the walk because the base a function @@ -962,10 +966,10 @@ fn descend( // body before any of it can be attributed (`kuna_picbase::scope`), and // buffering it costs nothing on the overwhelmingly common `None` path. let mut body: Vec = Vec::new(); - // (kuna) `picpool`: the pool words this body is carrying towards the + // Bounded PC-relative values this body is carrying towards the // `add` that turns each into an address. Per function, because the values // it tracks are live only inside one straight-line run. - let mut picpool = PicPool::default(); + let mut pc_relative = PcRelativeValues::default(); while let Some((vma, mode)) = insn_queue.pop_front() { if st.decoded.contains(&vma) { continue; // already decoded (the VisitStat dedup) @@ -1031,26 +1035,26 @@ fn descend( let fall_through = vma.wrapping_add(len as u64); data_refs(cap.ops(), data_space.as_ref(), &mapped, fall_through, pool.as_ref()) }; - // (kuna) `picpool`: the address a PC-relative literal-pool pair forms, - // which is in neither of its two instructions on its own. See - // [`super::kuna_picpool`]. - let picrefs = match pool.as_ref() { - Some(p) => picpool.step( - vma, - len, - cap.ops(), - p, - &mapped, - data_space.as_ref(), - ), - None => Vec::new(), - }; - // Every row this instruction produces carries the same render, and an - // instruction that produces none needs no render at all. + // Reuse the bounded PC-relative owner for pool displacements and + // AArch64 split page addresses. SLEIGH supplies the mnemonic; no + // assembly operand parsing or register-offset table is duplicated. + let mut rendered = None; + let pc_page = if aarch64 && len == 4 { + PcPageCopy::candidate(cap.ops(), data_space.as_ref()).and_then(|candidate| { + let decoded = assembly(translate, vma, &code_space); + let seed = candidate.for_mnemonic(&decoded.mnemonic); + rendered = Some(decoded.text); + seed + }) + } else { None }; + let picrefs = pc_relative.step( + Instruction {vma, len, ops: cap.ops(), pc_page}, + pool.as_ref(), &mapped, data_space.as_ref(), + ); let mut text = if c.flows.is_empty() && drefs.is_empty() && picrefs.is_empty() { String::new() } else { - assembly(translate, vma, &code_space) + rendered.unwrap_or_else(|| assembly(translate, vma, &code_space).text) }; if cap.ops().iter().any(|op| op.opcode == OpCode::CPUI_CALLIND) { @@ -1150,7 +1154,7 @@ fn descend( // and a row whose instruction column is blank does not say // which jump reached the case body. if !cases.is_empty() && text.is_empty() { - text = assembly(translate, vma, &code_space); + text = assembly(translate, vma, &code_space).text; } for target in cases { st.file(vma, target, XrefKind::Jump, &text); @@ -1162,8 +1166,8 @@ fn descend( for &(to, kind) in &drefs { st.file(vma, to, kind, &text); } - for &to in &picrefs { - st.file(vma, to, XrefKind::Data, &text); + for &(to, kind) in &picrefs { + st.file(vma, to, kind, &text); } // Both halves the deferred base-relative pass needs are pure // functions of this instruction's ops, so they are computed here @@ -1201,7 +1205,7 @@ fn descend( // parse of the whole program on any image with a PIC base // (an i386 `__x86.get_pc_thunk` binary: 154,608 renders to // file 151 references). - let text = assembly(translate, cand.vma, &code_space); + let text = assembly(translate, cand.vma, &code_space).text; for &(to, kind) in &cand.refs { st.file(cand.vma, to, kind, &text); } @@ -1336,7 +1340,7 @@ fn render_fingerprints( let mut vma = entry; for _ in 0..2 { let Some(insn) = listing.insns.get_mut(&vma) else { break }; - let text = assembly(translate, vma, code_space); + let text = assembly(translate, vma, code_space).text; insn.mnemonic = text.split_whitespace().next().unwrap_or_default().to_string(); vma = vma.wrapping_add(u64::from(insn.len)); } @@ -1606,14 +1610,14 @@ pub(super) fn decode( /// will actually carry the text. On a 466 KB obfuscated i386 image (154,638 /// instructions) rendering every decode cost 0.22 s of a 1.3 s walk, and the /// large majority of instructions file no reference at all. -fn assembly(translate: &dyn Translate, vma: u64, code_space: &Rc) -> String { +fn assembly(translate: &dyn Translate, vma: u64, code_space: &Rc) -> AsmCapture { let addr = Address::new(Rc::clone(code_space), vma); let mut asm = AsmCapture::default(); - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _ = translate.print_assembly(&mut asm, &addr); + let decoded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + translate.print_assembly(&mut asm, &addr) })); - asm.text + if matches!(decoded, Ok(Ok(len)) if len > 0) { asm } else { AsmCapture::default() } } /// The data references one instruction's p-code carries. diff --git a/decompiler/crates/kuna-analysis/src/loader/elf_plt.rs b/decompiler/crates/kuna-analysis/src/loader/elf_plt.rs index 4ab6cbca..abcf8c5b 100644 --- a/decompiler/crates/kuna-analysis/src/loader/elf_plt.rs +++ b/decompiler/crates/kuna-analysis/src/loader/elf_plt.rs @@ -43,6 +43,8 @@ use std::collections::{HashMap, HashSet}; use object::read::{Object, ObjectSection, ObjectSymbol}; use object::{Architecture, RelocationTarget}; +mod aarch64; + /// One resolved PLT-stub import: the stub entry address (the CALL target the /// decompiler sees) and the clean imported name. pub(crate) struct PltSym { @@ -395,7 +397,7 @@ fn decode_plt_section( } Architecture::I386 => decode_i386(vma, data, i386_got_base, map, out, named_got), Architecture::Aarch64 | Architecture::Aarch64_Ilp32 => { - decode_aarch64(vma, data, map, out, named_got) + aarch64::decode(vma, data, map, out, named_got) } Architecture::Arm => decode_arm(vma, data, map, out, named_got), Architecture::Riscv64 | Architecture::Riscv32 => { @@ -470,7 +472,7 @@ fn decode_x86_64( /// `jmp *disp32(%ebx)`, GOT-relative, where `%ebx` holds the GOT base (the PIC /// prologue's `call __x86.get_pc_thunk.bx; add $_,%ebx` sets it to /// `_GLOBAL_OFFSET_TABLE_`). The slot is `base + sign(disp32)`. This is the -/// i386-PIE analog of [`decode_x86_64`] (RIP-relative) / [`decode_aarch64`] +/// i386-PIE analog of [`decode_x86_64`] (RIP-relative) / [`aarch64::decode`] /// (adrp/ldr veneer); naming these stubs lets the no-return pass flag `exit` /// and collapses the spurious fall-through loop (angr /// `test_decompiling_nl_i386_pie`). `i386_got_base` is `None` when the GOT @@ -564,62 +566,6 @@ fn x86_stub_start(data: &[u8], i: usize, endbr: &[u8; 4]) -> usize { // no-op, so those arches keep the pre-fix behavior. // --------------------------------------------------------------------------- -/// AArch64 standard GNU `ld` import veneer (16-byte entry): -/// ```text -/// adrp x16, GOT_page ; PC-relative page of the GOT slot -/// ldr x17, [x16, #lo12] ; 64-bit unsigned-offset load (scaled by 8) -/// add x16, x16, #lo12 -/// br x17 -/// ``` -/// `got = (adrp_addr & !0xFFF) + (sign_extend_21(immhi:immlo) << 12) + imm12*8`. -/// A64 is fixed 32-bit little-endian, so we scan at 4-byte alignment matching the -/// `adrp x16` + `ldr x17,[x16]` pair; the 32-byte PLT0 header and any leading -/// `bti c` fall out of the pattern, and the GOT-vs-map cross-check drops the rest. -fn decode_aarch64( - vma: u64, - data: &[u8], - map: &HashMap>, - out: &mut Vec, - named_got: &mut HashSet, -) { - let mut off = 0usize; - while off + 8 <= data.len() { - let adrp = u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]); - let ldr = - u32::from_le_bytes([data[off + 4], data[off + 5], data[off + 6], data[off + 7]]); - - // adrp Xd, imm: op=1 (bit31), bits[28:24]=10000; immlo=bits[30:29], - // immhi=bits[23:5], Rd=bits[4:0]. - let is_adrp = (adrp >> 31) & 1 == 1 && (adrp >> 24) & 0x1f == 0b1_0000; - let adrp_rd = adrp & 0x1f; - // ldr Xt,[Xn,#imm] 64-bit unsigned offset: size=11(bits[31:30]), - // bits[29:24]=111001, opc=01(bits[23:22]=LDR), imm12=bits[21:10], - // Rn=bits[9:5], Rt=bits[4:0]. - let is_ldr = (ldr >> 30) & 0x3 == 0b11 - && (ldr >> 24) & 0x3f == 0b11_1001 - && (ldr >> 22) & 0x3 == 0b01; - let ldr_rn = (ldr >> 5) & 0x1f; - let ldr_rt = ldr & 0x1f; - - if is_adrp && adrp_rd == 16 && is_ldr && ldr_rn == 16 && ldr_rt == 17 { - let immlo = ((adrp >> 29) & 0x3) as i64; - let immhi = ((adrp >> 5) & 0x7_ffff) as i64; // 19 bits - let mut imm21 = (immhi << 2) | immlo; - if imm21 & (1 << 20) != 0 { - imm21 -= 1 << 21; // sign-extend the 21-bit page offset - } - let page_off = imm21 << 12; - let ldr_off = (((ldr >> 10) & 0xfff) as u64) * 8; // 64-bit scale - let adrp_addr = vma.wrapping_add(off as u64); - let got = (adrp_addr & !0xFFF) - .wrapping_add(page_off as u64) - .wrapping_add(ldr_off); - record(adrp_addr, got, map, out, named_got); - } - off += 4; - } -} - /// ARM A32 "modified immediate" (data-processing operand2): an 8-bit value /// `imm8` rotated right by `2*rot`. `op2` is the low 12 bits of the instruction. fn arm_modified_immediate(op2: u32) -> u32 { @@ -1159,32 +1105,6 @@ mod tests { //! in `loadimage_object`'s tests. use super::*; - #[test] - fn aarch64_plt_decode() { - // clang bytes: `adrp x16,0x3000; ldr x17,[x16,#0xff8]; add x16,x16,#0xff8; br x17`. - let mut data: Vec = vec![0u8; 32]; // dummy PLT0 header - data.extend_from_slice(&0xf0000010u32.to_le_bytes()); // adrp x16, +0x3000 - data.extend_from_slice(&0xf947fe11u32.to_le_bytes()); // ldr x17,[x16,#0xff8] - data.extend_from_slice(&0x913fe210u32.to_le_bytes()); // add x16,x16,#0xff8 - data.extend_from_slice(&0xd61f0220u32.to_le_bytes()); // br x17 - - let vma = 0x10000u64; - let adrp_addr = vma + 32; - // got = (adrp_addr & !0xFFF) + page(0x3000) + ldr_off(0xff8) - let expected_got = (adrp_addr & !0xFFF) + 0x3000 + 0xff8; - assert_eq!(expected_got, 0x13ff8); - - let mut map: HashMap> = HashMap::new(); - map.insert(expected_got, b"read".to_vec()); - let mut out: Vec = Vec::new(); - let mut named: HashSet = HashSet::new(); - decode_aarch64(vma, &data, &map, &mut out, &mut named); - - assert_eq!(out.len(), 1); - assert_eq!(out[0].addr, adrp_addr); - assert_eq!(out[0].name, b"read".to_vec()); - } - #[test] fn arm_plt_decode() { // clang bytes: `add ip,pc,#0x600000; add ip,ip,#0x3000; ldr pc,[ip,#0xff8]!`. diff --git a/decompiler/crates/kuna-analysis/src/loader/elf_plt/aarch64.rs b/decompiler/crates/kuna-analysis/src/loader/elf_plt/aarch64.rs new file mode 100644 index 00000000..ee493912 --- /dev/null +++ b/decompiler/crates/kuna-analysis/src/loader/elf_plt/aarch64.rs @@ -0,0 +1,237 @@ +//! AArch64 PLT entry identity, shared by section and PT_DYNAMIC scans. +//! +//! Recognize the complete standard GNU/LLVM veneer, with optional BTI c and +//! AUTIA1716 as described in Arm's System V ABI, "Sample PLT sequences": +//! https://github.com/ARM-software/abi-aa/blob/main/sysvabi64/sysvabi64.rst#sample-plt-sequences +//! Correlate the decoded GOT slot with the relocation owner in the parent. +//! Prefixes belong to the entry; the ADRP's own PC supplies the page. An +//! ordinary preceding instruction is never absorbed into the entry. Other +//! veneer forms remain unrecognized rather than inheriting a guessed name. + +use std::collections::{HashMap, HashSet}; + +use super::{record, PltSym}; + +const BTI_C: u32 = 0xd503245f; +const AUTIA1716: u32 = 0xd503219f; +const BR_X17: u32 = 0xd61f0220; + +fn word(data: &[u8], off: usize) -> Option { + let bytes = data.get(off..)?.get(..4)?; + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + +pub(super) fn decode( + vma: u64, + data: &[u8], + map: &HashMap>, + out: &mut Vec, + named_got: &mut HashSet, +) { + if vma & 3 != 0 { + return; + } + for off in (0..data.len()).step_by(4) { + let Some(adrp) = word(data, off) else { break }; + // ADRP X16, page; opcode plus destination, with both immediate fields free. + if adrp & 0x9f00001f != 0x90000010 { + continue; + } + let body = &data[off..]; + let Some(ldr) = word(body, 4) else { continue }; + // LDR X17,[X16,#imm12*8], not a 32-bit load, another register or a call. + if ldr & 0xffc003ff != 0xf9400211 { + continue; + } + let lo = ((ldr >> 10) & 0xfff) * 8; + // The ADD must form exactly the same GOT slot for the lazy resolver + // (and for PAC's modifier); neither an unrelated load nor an incomplete + // ADRP/LDR pair establishes an imported function. + if lo > 0xfff || word(body, 8) != Some(0x91000210 | (lo << 10)) { + continue; + } + let transfer = if word(body, 12) == Some(AUTIA1716) { + 16 + } else { + 12 + }; + if word(body, transfer) != Some(BR_X17) { + continue; + } + let Some(adrp_addr) = vma.checked_add(off as u64) else { + break; + }; + if adrp_addr.checked_add(transfer as u64 + 3).is_none() { + continue; + } + let imm21 = (((adrp >> 5) & 0x7ffff) << 2) | ((adrp >> 29) & 3); + let pages = ((imm21 as i32) << 11 >> 11) as i64; + let got = (adrp_addr & !0xfff) + .wrapping_add((pages << 12) as u64) + .wrapping_add(lo as u64); + let prefix = off.checked_sub(4).and_then(|at| word(data, at)) == Some(BTI_C); + let entry = if prefix { adrp_addr - 4 } else { adrp_addr }; + record(entry, got, map, out, named_got); + } +} + +#[cfg(test)] +mod tests { + use super::{decode, PltSym}; + use std::collections::{HashMap, HashSet}; + + // Actual Debian arm64 OpenSSH entry: BTI; ADRP 0x10f000; LDR #0x390; + // ADD #0x390; BR x17. Relocation 0x10f390 is free, not the neighbouring + // name printed by GNU objdump's synthetic fixed-stride PLT labels. + const BTI_C: u32 = 0xd503245f; + const AUTIA1716: u32 = 0xd503219f; + const BODY: [u32; 4] = [0x90000830, 0xf941ca11, 0x910e4210, 0xd61f0220]; + + fn words(words: &[u32]) -> Vec { + words.iter().flat_map(|word| word.to_le_bytes()).collect() + } + + fn names(vma: u64, data: &[u8]) -> Vec<(u64, Vec)> { + let map = HashMap::from([(0x10f390, b"free".to_vec())]); + let mut out: Vec = Vec::new(); + decode(vma, data, &map, &mut out, &mut HashSet::new()); + out.into_iter().map(|s| (s.addr, s.name)).collect() + } + + #[test] + fn aarch64_plt_decode() { + // clang bytes: `adrp x16,0x3000; ldr x17,[x16,#0xff8]; add x16,x16,#0xff8; br x17`. + let mut data: Vec = vec![0u8; 32]; // dummy PLT0 header + data.extend_from_slice(&0xf0000010u32.to_le_bytes()); // adrp x16, +0x3000 + data.extend_from_slice(&0xf947fe11u32.to_le_bytes()); // ldr x17,[x16,#0xff8] + data.extend_from_slice(&0x913fe210u32.to_le_bytes()); // add x16,x16,#0xff8 + data.extend_from_slice(&0xd61f0220u32.to_le_bytes()); // br x17 + + let vma = 0x10000u64; + let adrp_addr = vma + 32; + // got = (adrp_addr & !0xFFF) + page(0x3000) + ldr_off(0xff8) + let expected_got = (adrp_addr & !0xFFF) + 0x3000 + 0xff8; + assert_eq!(expected_got, 0x13ff8); + + let mut map: HashMap> = HashMap::new(); + map.insert(expected_got, b"read".to_vec()); + let mut out: Vec = Vec::new(); + let mut named: HashSet = HashSet::new(); + decode(vma, &data, &map, &mut out, &mut named); + + assert_eq!(out.len(), 1); + assert_eq!(out[0].addr, adrp_addr); + assert_eq!(out[0].name, b"read".to_vec()); + } + + #[test] + fn bti_and_plain_entries_share_the_relocation_name_not_the_body_identity() { + let mut entry = vec![BTI_C]; + entry.extend_from_slice(&BODY); + assert_eq!(names(0xbad0, &words(&entry)), [(0xbad0, b"free".to_vec())]); + assert_eq!(names(0xbad4, &words(&BODY)), [(0xbad4, b"free".to_vec())]); + } + + #[test] + fn pac_and_bti_pac_entries_keep_their_entry_address() { + let mut entry = BODY[..3].to_vec(); + entry.extend([AUTIA1716, BODY[3]]); + assert_eq!(names(0xbad4, &words(&entry)), [(0xbad4, b"free".to_vec())]); + entry.insert(0, BTI_C); + assert_eq!(names(0xbad0, &words(&entry)), [(0xbad0, b"free".to_vec())]); + } + + #[test] + fn only_the_exact_immediate_bti_call_prefix_extends_the_entry() { + for prefix in [0xd503201f, 0xd503249f, 0xd50324df, 0xd503245e, 0] { + let mut entry = vec![prefix]; + entry.extend_from_slice(&BODY); + assert_eq!(names(0xbad0, &words(&entry)), [(0xbad4, b"free".to_vec())]); + } + let mut entry = vec![BTI_C, 0xd503201f]; + entry.extend_from_slice(&BODY); + assert_eq!(names(0xbacc, &words(&entry)), [(0xbad4, b"free".to_vec())]); + } + + #[test] + fn incomplete_or_corrupt_veneer_never_consumes_a_relocation() { + for pac in [false, true] { + let mut entry = vec![BTI_C]; + entry.extend_from_slice(&BODY[..3]); + if pac { + entry.push(AUTIA1716); + } + entry.push(BODY[3]); + let data = words(&entry); + for len in 0..data.len() { + assert!( + names(0xbad0, &data[..len]).is_empty(), + "truncated at {len}, PAC={pac}" + ); + } + } + for (index, word) in [ + (0, BODY[0] ^ 1), // wrong page register + (1, BODY[1] ^ 1), // wrong load register + (1, BODY[1] ^ (1 << 30)), // 32-bit load + (2, 0xd503201f), // no ADD + (2, BODY[2] ^ (1 << 10)), // inconsistent GOT modifier + (2, BODY[2] | (1 << 22)), // ADD's shifted immediate + (3, 0xd63f0220), // BLR is a call, not the veneer transfer + (3, 0xd61f0200), // BR x16 is not the loaded x17 + ] { + let mut body = BODY; + body[index] = word; + assert!( + names(0xbad4, &words(&body)).is_empty(), + "corrupt index {index}" + ); + } + let mut body = BODY[..3].to_vec(); + body.extend([0xd503219e, BODY[3]]); // not AUTIA1716 + assert!(names(0xbad4, &words(&body)).is_empty()); + } + + #[test] + fn adjacent_entries_use_adrp_pc_and_exact_slots_without_stride_assumptions() { + // First ADRP sits on the next page after its BTI, with a negative page + // delta. The second plain entry is adjacent: no fixed 16/24-byte stride. + let first = [BTI_C, 0xf0fffff0, 0xf947fe11, 0x913fe210, 0xd61f0220]; + let second = [0xf0fffff0, 0xf947fa11, 0x913fc210, 0xd61f0220]; + let mut data = words(&first); + data.extend(words(&second)); + let map = HashMap::from([(0x30ff8, b"one".to_vec()), (0x30ff0, b"two".to_vec())]); + let mut out = Vec::new(); + let mut named = HashSet::new(); + decode(0x30ffc, &data, &map, &mut out, &mut named); + assert_eq!( + out.iter() + .map(|s| (s.addr, s.name.as_slice())) + .collect::>(), + [(0x30ffc, b"one".as_slice()), (0x31010, b"two".as_slice())] + ); + decode(0x30ffc, &data, &map, &mut out, &mut named); + assert_eq!(out.len(), 2, "shared GOT deduplication remains intact"); + } + + #[test] + fn absent_slot_misalignment_and_overflow_cannot_create_entries() { + let data = words(&BODY); + assert!(names(0xbad5, &data).is_empty()); + assert!(names(0xbbad4, &data).is_empty()); + let mut out = Vec::new(); + let map = HashMap::from([(0x104390, b"wrapped".to_vec())]); + decode( + 0xbad4, + &data, + &HashMap::new(), + &mut out, + &mut HashSet::new(), + ); + assert!(out.is_empty()); + let mut overflow = words(&[BTI_C, BTI_C]); + overflow.extend_from_slice(&data); + decode(u64::MAX - 3, &overflow, &map, &mut out, &mut HashSet::new()); + assert!(out.is_empty()); + } +} diff --git a/decompiler/crates/kuna-cli/Cargo.toml b/decompiler/crates/kuna-cli/Cargo.toml index 06231a77..db79599c 100644 --- a/decompiler/crates/kuna-cli/Cargo.toml +++ b/decompiler/crates/kuna-cli/Cargo.toml @@ -9,6 +9,8 @@ license.workspace = true publish.workspace = true [dependencies] +base64 = "=0.22.1" +flate2 = "=1.1.9" kuna-decomp.workspace = true kuna-base.workspace = true kuna-console.workspace = true diff --git a/decompiler/crates/kuna-cli/src/atomic_compression.rs b/decompiler/crates/kuna-cli/src/atomic_compression.rs new file mode 100644 index 00000000..48631d39 --- /dev/null +++ b/decompiler/crates/kuna-cli/src/atomic_compression.rs @@ -0,0 +1,122 @@ +//! Bounded lossless wire encoding of logical schema-3 atomic graphs. +use crate::jsonfmt::{dumps_compact_bounded, Json}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use flate2::{write::GzEncoder, Compression}; +use std::io::{self, Write}; + +const MAX_RAW_BYTES: usize = 32 * 1024 * 1024; +struct BoundedBytes { + data: Vec, + limit: usize, +} +impl Write for BoundedBytes { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let next = self + .data + .len() + .checked_add(bytes.len()) + .filter(|n| *n <= self.limit) + .ok_or_else(|| io::Error::other("atomic compression capacity exceeded"))?; + if next > self.data.capacity() { + let capacity = next + .max(self.data.capacity().saturating_mul(2)) + .min(self.limit); + self.data + .try_reserve_exact(capacity - self.data.len()) + .map_err(|_| io::Error::other("atomic compression allocation failed"))?; + } + self.data.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +pub(crate) fn encode(graph: &Json, limit: usize) -> Option<(Json, usize)> { + let Json::Object(fields) = graph else { + return None; + }; + if !fields + .iter() + .any(|(key, v)| key == "schema_version" && *v == Json::Number("3".into())) + { + return None; + } + let raw = dumps_compact_bounded(graph, MAX_RAW_BYTES)?; + let mut envelope = Json::Object(vec![ + ("schema_version".into(), Json::Number("4".into())), + ("encoding".into(), Json::Str("gzip-base64".into())), + ("raw_bytes".into(), Json::Number(raw.len().to_string())), + ("data".into(), Json::Str(String::new())), + ]); + let overhead = dumps_compact_bounded(&envelope, limit)?.len(); + let payload_limit = limit.checked_sub(overhead)?; + let compressed_limit = (payload_limit / 4).checked_mul(3)?; + let mut gzip = GzEncoder::new( + BoundedBytes { + data: Vec::new(), + limit: compressed_limit, + }, + Compression::fast(), + ); + gzip.write_all(raw.as_bytes()).ok()?; + let compressed = gzip.finish().ok()?.data; + let encoded_len = compressed + .len() + .checked_add(2)? + .checked_div(3)? + .checked_mul(4)?; + if encoded_len > payload_limit { + return None; + } + let encoded = STANDARD.encode(compressed); + if let Json::Object(fields) = &mut envelope { + let (_, data) = fields.iter_mut().find(|(key, _)| key == "data")?; + *data = Json::Str(encoded); + } + Some((envelope, overhead + encoded_len)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + #[test] + fn lossless_frame_accounts_for_escaping_and_base64_expansion() { + let graph = Json::Object(vec![ + ("schema_version".into(), Json::Number("3".into())), + ("statement".into(), Json::Str("中文😀\0".repeat(1000))), + ]); + let (encoded, size) = encode(&graph, 2000).unwrap(); + let text = dumps_compact_bounded(&encoded, 2000).unwrap(); + assert_eq!(text.len(), size); + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + let bytes = STANDARD.decode(frame["data"].as_str().unwrap()).unwrap(); + let mut restored = String::new(); + flate2::read::GzDecoder::new(bytes.as_slice()) + .read_to_string(&mut restored) + .unwrap(); + assert_eq!( + restored, + dumps_compact_bounded(&graph, MAX_RAW_BYTES).unwrap() + ); + assert_eq!(frame["raw_bytes"].as_u64().unwrap(), restored.len() as u64); + assert!(encode(&graph, 1).is_none()); + assert!(encode(&graph, size - 1).is_none()); + } + #[test] + fn archive_is_single_layer_and_raw_allocation_has_an_independent_bound() { + assert!(encode(&Json::Str("not a graph".into()), 1000).is_none()); + let nested = Json::Object(vec![("schema_version".into(), Json::Number("4".into()))]); + assert!(encode(&nested, 1000).is_none()); + let oversized = Json::Object(vec![ + ("schema_version".into(), Json::Number("3".into())), + ( + "statement".into(), + Json::Str("\0".repeat(MAX_RAW_BYTES / 6 + 1)), + ), + ]); + assert!(encode(&oversized, 1000).is_none()); + } +} diff --git a/decompiler/crates/kuna-cli/src/atomic_json.rs b/decompiler/crates/kuna-cli/src/atomic_json.rs new file mode 100644 index 00000000..08deb2e5 --- /dev/null +++ b/decompiler/crates/kuna-cli/src/atomic_json.rs @@ -0,0 +1,497 @@ +//! Opt-in atomic documents share the ordinary record producer and JSON writer. +//! A graph that cannot fit must not discard otherwise readable reconstructed C. + +use crate::jsonfmt::{dumps_compact_bounded, Json}; + +// Ownership is moved out while trying bounded alternatives, never cloned. +struct RemovedFacet { + function: usize, + value: (String, Json), + added_error: bool, +} +// JSON object member order is not facet identity. Restore owned entries by key, +// never by an ordinal invalidated when another independent facet was removed. +#[derive(Clone, Copy)] +enum Omission { + Capacity, + RedundantWithGraph, +} +fn function_records(document: &mut Json) -> Option<&mut Vec> { + let Json::Object(fields) = document else { + return None; + }; + let (_, value) = fields.iter_mut().find(|(key, _)| key == "functions")?; + let Json::Array(functions) = value else { + return None; + }; + Some(functions) +} +fn take_facet(document: &mut Json, name: &str, limit: usize, why: Omission) -> Vec { + let mut removed = Vec::new(); + if let Some(functions) = function_records(document) { + for (function, record) in functions.iter_mut().enumerate() { + if let Json::Object(fields) = record { + if matches!(why, Omission::RedundantWithGraph) + && !fields.iter().any(|(key, _)| key == "atomic_facts") + { + continue; + } + if let Some(field) = fields.iter().position(|(key, _)| key == name) { + let value = fields.remove(field); + let error = format!("{name}_error"); + let added_error = matches!(why, Omission::Capacity) + && !fields.iter().any(|(key, _)| key == &error); + if added_error { + fields.push((error, Json::Str(format!("atomic encoded document exceeds {limit} byte capacity; {name} omitted, code retained")))); + } + removed.push(RemovedFacet { + function, + value, + added_error, + }); + } + } + } + } + removed +} +fn restore_facet(document: &mut Json, removed: Vec) { + if let Some(functions) = function_records(document) { + for facet in removed { + if let Json::Object(fields) = &mut functions[facet.function] { + if facet.added_error { + let error = format!("{}_error", facet.value.0); + fields.retain(|(key, _)| key != &error); + } + fields.push(facet.value); + } + } + } +} +struct GraphAlternative { + function: usize, + value: Json, +} +fn compress_graphs(document: &mut Json, budget: usize) -> Vec { + let mut originals = Vec::new(); + let mut remaining = budget; + if let Some(functions) = function_records(document) { + for (function, record) in functions.iter_mut().enumerate() { + if let Json::Object(fields) = record { + if let Some((_, graph)) = fields.iter_mut().find(|(key, _)| key == "atomic_facts") { + if let Some((encoded, size)) = + crate::atomic_compression::encode(graph, remaining) + { + remaining -= size; + originals.push(GraphAlternative { + function, + value: std::mem::replace(graph, encoded), + }); + } + } + } + } + } + originals +} +fn exchange_graphs( + document: &mut Json, + alternatives: Vec, +) -> Vec { + let mut previous = Vec::new(); + if let Some(functions) = function_records(document) { + for alternative in alternatives { + if let Json::Object(fields) = &mut functions[alternative.function] { + if let Some((_, graph)) = fields.iter_mut().find(|(key, _)| key == "atomic_facts") { + previous.push(GraphAlternative { + function: alternative.function, + value: std::mem::replace(graph, alternative.value), + }); + } + } + } + } + previous +} +fn bounded(document: &Json, budget: usize) -> Option { + let mut text = dumps_compact_bounded(document, budget)?; + text.push('\n'); + Some(text) +} +// Reclaim a saved independent facet when other omissions have made room. +// Only bounded encoded strings coexist here, never cloned graph/facet trees. +fn reclaim(document: &mut Json, budget: usize, name: &str, saved: &mut Vec, base: String) -> String { + if saved.is_empty() { return base; } + restore_facet(document, std::mem::take(saved)); + if let Some(complete) = bounded(document, budget) { return complete; } + *saved = take_facet(document, name, budget + 1, Omission::Capacity); + base +} +fn bounded_reclaiming(document: &mut Json, budget: usize, name: &str, saved: &mut Vec) -> Option { + let base = bounded(document, budget)?; + Some(reclaim(document, budget, name, saved, base)) +} +pub(crate) fn render(mut document: Json, limit: usize) -> Result { + let budget = limit + .checked_sub(1) + .ok_or("atomic JSON output budget is zero")?; + // A newly added independent facet must not displace established graph/call + // coverage. Reclaim it only after the existing owner selects a fitting result. + let mut parameters = take_facet(&mut document, "atomic_parameters", limit, Omission::Capacity); + // A full graph already owns these operation/value facts. Keep its bounded + // projection aside in case encoded size later makes that graph unavailable. + let projection = take_facet( + &mut document, + "atomic_projection", + limit, + Omission::RedundantWithGraph, + ); + if let Some(text) = bounded(&document, budget) { + return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + let originals = compress_graphs(&mut document, budget); + let mut alternatives = if originals.is_empty() { + Vec::new() + } else { + if let Some(text) = bounded(&document, budget) { + return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + exchange_graphs(&mut document, originals) + }; + // New switch rows must not displace previously available graph/call facts. + // Retry without that independent facet before weakening existing coverage. + let mut switches = take_facet(&mut document, "atomic_switches", limit, Omission::Capacity); + if !switches.is_empty() { + if let Some(text) = bounded(&document, budget) { + return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + if !alternatives.is_empty() { + let originals = exchange_graphs(&mut document, alternatives); + if let Some(text) = bounded(&document, budget) { + return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + alternatives = exchange_graphs(&mut document, originals); + } + } + let calls = take_facet(&mut document, "atomic_calls", limit, Omission::Capacity); + if !calls.is_empty() { + if let Some(text) = + bounded_reclaiming(&mut document, budget, "atomic_switches", &mut switches) + { + return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + if !alternatives.is_empty() { + drop(exchange_graphs(&mut document, alternatives)); + if let Some(text) = + bounded_reclaiming(&mut document, budget, "atomic_switches", &mut switches) + { + return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + } + } + restore_facet(&mut document, calls); + take_facet(&mut document, "atomic_facts", limit, Omission::Capacity); + restore_facet(&mut document, projection); + restore_facet(&mut document, std::mem::take(&mut switches)); + if let Some(text) = bounded(&document, budget) { + return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + switches = take_facet(&mut document, "atomic_switches", limit, Omission::Capacity); + if !switches.is_empty() { + if let Some(text) = bounded(&document, budget) { + return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + } + // Independent calls retain priority when the combined fallback is too big. + let projection = take_facet( + &mut document, + "atomic_projection", + limit, + Omission::Capacity, + ); + if let Some(text) = bounded_reclaiming(&mut document, budget, "atomic_switches", &mut switches) + { + return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + restore_facet(&mut document, projection); + take_facet(&mut document, "atomic_calls", limit, Omission::Capacity); + if let Some(text) = bounded_reclaiming(&mut document, budget, "atomic_switches", &mut switches) + { + return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + take_facet( + &mut document, + "atomic_projection", + limit, + Omission::Capacity, + ); + bounded_reclaiming(&mut document, budget, "atomic_switches", &mut switches) + .map(|text| reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)).ok_or_else(|| { + format!("atomic JSON exceeds {limit} byte capacity even without recovered facets") + }) +} + +#[cfg(test)] +mod switch_facet_tests { + use super::*; + fn document(graph: usize, calls: usize, projection: usize, switches: usize) -> Json { + Json::Object(vec![( + "functions".into(), + Json::Array(vec![Json::Object(vec![ + ("code".into(), Json::Str("retained C".into())), + ("atomic_facts".into(), Json::Str("g".repeat(graph))), + ("atomic_calls".into(), Json::Str("c".repeat(calls))), + ( + "atomic_projection".into(), + Json::Str("p".repeat(projection)), + ), + ("atomic_switches".into(), Json::Str("s".repeat(switches))), + ])]), + )]) + } + #[test] + fn oversized_switches_do_not_displace_existing_graph_and_calls() { + let text = render(document(600, 20, 10, 5000), 1500).unwrap(); + let d: serde_json::Value = serde_json::from_str(&text).unwrap(); + let f = &d["functions"][0]; + assert_eq!(f["code"], "retained C"); + assert_eq!(f["atomic_facts"].as_str().unwrap().len(), 600); + assert_eq!(f["atomic_calls"].as_str().unwrap().len(), 20); + assert!(f.get("atomic_switches").is_none()); + assert!(f["atomic_switches_error"].is_string()); + } + #[test] + fn reclaim_switches_after_a_large_projection_is_omitted() { + let text = render(document(5000, 100, 3000, 100), 2000).unwrap(); + let d: serde_json::Value = serde_json::from_str(&text).unwrap(); + let f = &d["functions"][0]; + assert_eq!(f["atomic_calls"].as_str().unwrap().len(), 100); + assert_eq!(f["atomic_switches"].as_str().unwrap().len(), 100); + assert!(f.get("atomic_projection").is_none()); + assert!(f.get("atomic_switches_error").is_none()); + } + #[test] + fn independent_switch_rows_survive_when_all_other_facets_overflow() { + let text = render(document(5000, 5000, 5000, 1200), 2000).unwrap(); + let d: serde_json::Value = serde_json::from_str(&text).unwrap(); + let f = &d["functions"][0]; + assert_eq!(f["code"], "retained C"); + assert_eq!(f["atomic_switches"].as_str().unwrap().len(), 1200); + assert!(f.get("atomic_switches_error").is_none()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encoded_overflow_preserves_code_and_replaces_only_the_graph() { + let original = Json::Object(vec![( + "functions".into(), + Json::Array(vec![Json::Object(vec![ + ("code".into(), Json::Str("retained C".into())), + ("error".into(), Json::Null), + ("atomic_facts".into(), Json::Str("😀".repeat(1000))), + ])]), + )]); + let full = render(original.clone(), 20000).unwrap(); + assert!(full.len() > 12000); // ASCII surrogate escaping is accounted for. + let reduced = render(original.clone(), 256).unwrap(); + let document: serde_json::Value = serde_json::from_str(&reduced).unwrap(); + let function = &document["functions"][0]; + assert_eq!(function["code"], "retained C"); + assert_eq!(function["error"], serde_json::Value::Null); + assert!(function.get("atomic_facts").is_none()); + assert!(function["atomic_facts_error"] + .as_str() + .unwrap() + .contains("256")); + assert!(reduced.len() <= 256); + assert!(render(original, 1).is_err()); + } +} + +#[cfg(test)] +mod compressed_graph_tests { + use super::*; + use base64::{engine::general_purpose::STANDARD, Engine as _}; + use std::io::Read; + #[test] + fn compressed_graph_retains_calls_and_code_before_losing_any_facet() { + let graph = Json::Object(vec![ + ("schema_version".into(), Json::Number("3".into())), + ("payload".into(), Json::Str("x".repeat(100_000))), + ]); + let original = Json::Object(vec![( + "functions".into(), + Json::Array(vec![Json::Object(vec![ + ("code".into(), Json::Str("retained C".into())), + ("atomic_facts".into(), graph.clone()), + ("atomic_calls".into(), Json::Str("independent calls".into())), + ("atomic_projection".into(), Json::Str("fallback".into())), + ])]), + )]); + let uncompressed: serde_json::Value = + serde_json::from_str(&render(original.clone(), 200_000).unwrap()).unwrap(); + assert_eq!( + uncompressed["functions"][0]["atomic_facts"]["schema_version"], + 3 + ); + let text = render(original.clone(), 4096).unwrap(); + assert!(text.len() <= 4096); + let document: serde_json::Value = serde_json::from_str(&text).unwrap(); + let function = &document["functions"][0]; + assert_eq!(function["code"], "retained C"); + assert_eq!(function["atomic_calls"], "independent calls"); + assert!(function.get("atomic_projection").is_none()); + assert!(function.get("atomic_facts_error").is_none()); + let frame = &function["atomic_facts"]; + assert_eq!(frame["schema_version"], 4); + let compressed = STANDARD.decode(frame["data"].as_str().unwrap()).unwrap(); + let mut restored = String::new(); + flate2::read::GzDecoder::new(compressed.as_slice()) + .read_to_string(&mut restored) + .unwrap(); + assert_eq!(restored, dumps_compact_bounded(&graph, 200_000).unwrap()); + let tiny: serde_json::Value = + serde_json::from_str(&render(original, 512).unwrap()).unwrap(); + assert!(tiny["functions"][0].get("atomic_facts").is_none()); + assert_eq!(tiny["functions"][0]["atomic_projection"], "fallback"); + assert_eq!(tiny["functions"][0]["atomic_calls"], "independent calls"); + } +} + +#[cfg(test)] +mod independent_facet_tests { + use super::*; + fn document(graph: usize, calls: usize) -> Json { + Json::Object(vec![( + "functions".into(), + Json::Array(vec![Json::Object(vec![ + ("code".into(), Json::Str("retained C".into())), + ("ordinary".into(), Json::Number("42".into())), + ("atomic_facts".into(), Json::Str("x".repeat(graph))), + ("atomic_calls".into(), Json::Str("y".repeat(calls))), + ])]), + )]) + } + #[test] + fn each_facet_survives_the_others_encoded_overflow() { + for (graph, calls, keep) in [(2000, 1, "atomic_calls"), (1, 2000, "atomic_facts")] { + let rendered = render(document(graph, calls), 512).unwrap(); + assert!(rendered.len() <= 512); + let d: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + let f = &d["functions"][0]; + assert!(f.get(keep).is_some()); + assert_eq!(f["ordinary"], 42); + assert_eq!(f["code"], "retained C"); + } + } + #[test] + fn jointly_oversized_facets_preserve_the_existing_graph_when_it_fits() { + let rendered = render(document(1000, 1000), 1600).unwrap(); + let d: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert!(d["functions"][0].get("atomic_facts").is_some()); + assert!(d["functions"][0].get("atomic_calls").is_none()); + } + #[test] + fn base_record_survives_both_overflows_or_fails_explicitly() { + let rendered = render(document(2000, 2000), 512).unwrap(); + let d: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + let f = &d["functions"][0]; + assert!(f.get("atomic_facts").is_none() && f.get("atomic_calls").is_none()); + assert!(f["atomic_facts_error"].is_string() && f["atomic_calls_error"].is_string()); + assert_eq!(f["code"], "retained C"); + assert!(render(document(1, 1), 1).is_err()); + } +} + +#[cfg(test)] +mod projection_tests { + use super::*; + fn document(graph: usize, calls: usize, projection: usize) -> Json { + Json::Object(vec![( + "functions".into(), + Json::Array(vec![Json::Object(vec![ + ("code".into(), Json::Str("retained C".into())), + ("atomic_facts".into(), Json::Str("g".repeat(graph))), + ("atomic_calls".into(), Json::Str("c".repeat(calls))), + ( + "atomic_projection".into(), + Json::Str("p".repeat(projection)), + ), + ])]), + )]) + } + #[test] + fn full_graph_avoids_duplicate_projection_and_encoded_failure_restores_it() { + for (graph, retained) in [(10, "atomic_facts"), (3000, "atomic_projection")] { + let rendered = render(document(graph, 10, 10), 1024).unwrap(); + let d: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + let f = &d["functions"][0]; + assert!(f.get(retained).is_some()); + assert!(f.get("atomic_calls").is_some()); + assert_eq!(f["code"], "retained C"); + if retained == "atomic_facts" { + assert!(f.get("atomic_projection").is_none()); + assert!(f.get("atomic_projection_error").is_none()); + } + } + } + #[test] + fn fallback_facets_survive_independently_without_losing_code() { + for (calls, projection, retained) in + [(3000, 10, "atomic_projection"), (10, 3000, "atomic_calls")] + { + let rendered = render(document(3000, calls, projection), 1024).unwrap(); + let d: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert!(d["functions"][0].get(retained).is_some()); + assert_eq!(d["functions"][0]["code"], "retained C"); + assert!(rendered.len() <= 1024); + } + } +} + +#[cfg(test)] +mod parameter_facet_tests { + use super::*; + fn document(graph: usize, calls: usize, parameters: usize) -> Json { + Json::Object(vec![("functions".into(),Json::Array(vec![Json::Object(vec![ + ("code".into(),Json::Str("readable C".into())), + ("atomic_facts".into(),Json::Str("g".repeat(graph))), + ("atomic_calls".into(),Json::Str("c".repeat(calls))), + ("atomic_parameters".into(),Json::Str("p".repeat(parameters))), + ])]))]) + } + #[test] + fn oversized_parameters_do_not_displace_existing_graph_and_calls() { + let text=render(document(400,20,3000),900).unwrap(); + let value:serde_json::Value=serde_json::from_str(&text).unwrap();let f=&value["functions"][0]; + assert_eq!(f["atomic_facts"].as_str().unwrap().len(),400); + assert_eq!(f["atomic_calls"].as_str().unwrap().len(),20); + assert!(f.get("atomic_parameters").is_none()); + assert!(f["atomic_parameters_error"].is_string()); + } + #[test] + fn independent_small_parameters_survive_when_graph_and_calls_do_not_fit() { + let text=render(document(3000,3000,12),900).unwrap(); + assert!(text.len()<=900); + let value:serde_json::Value=serde_json::from_str(&text).unwrap();let f=&value["functions"][0]; + assert_eq!(f["code"],"readable C"); + assert_eq!(f["atomic_parameters"].as_str().unwrap().len(),12); + assert!(f.get("atomic_parameters_error").is_none()); + } + #[test] + fn restore_is_owned_by_field_identity_after_other_fields_are_removed() { + let mut doc=document(10,10,10); + let parameters=take_facet(&mut doc,"atomic_parameters",1000,Omission::Capacity); + take_facet(&mut doc,"atomic_facts",1000,Omission::Capacity); + take_facet(&mut doc,"atomic_calls",1000,Omission::Capacity); + restore_facet(&mut doc,parameters); + let text=bounded(&doc,1000).unwrap(); + let value:serde_json::Value=serde_json::from_str(&text).unwrap(); + assert_eq!(value["functions"][0]["atomic_parameters"],"p".repeat(10)); + assert!(value["functions"][0].get("atomic_parameters_error").is_none()); + } +} diff --git a/decompiler/crates/kuna-cli/src/decompile.rs b/decompiler/crates/kuna-cli/src/decompile.rs index ad249b04..62074996 100644 --- a/decompiler/crates/kuna-cli/src/decompile.rs +++ b/decompiler/crates/kuna-cli/src/decompile.rs @@ -841,6 +841,8 @@ pub fn main(argv: &[String]) -> Result { let mut target: Option = None; let mut addr = false; let mut json = false; + let mut atomic_facts = false; + let mut atomic_output_bytes = None; let mut bfd_target: Option = None; let mut raw = false; let mut regions = false; @@ -871,6 +873,13 @@ pub fn main(argv: &[String]) -> Result { match a { "--addr" => addr = true, "--json" => json = true, + "--atomic-facts" => atomic_facts = true, + "--atomic-output-bytes" => { + let value = take_value(argv, &mut i, "--atomic-output-bytes")?; + atomic_output_bytes = Some(value.parse::() + .ok().filter(|bytes| *bytes > 0) + .ok_or("--atomic-output-bytes requires a positive byte count")?); + } "--raw" => raw = true, "--raw-image" => raw_image = true, "--base" => base = Some(parse_cli_address(&take_value(argv, &mut i, "--base")?)?), @@ -979,7 +988,13 @@ pub fn main(argv: &[String]) -> Result { return Err("--base requires --raw-image".into()); } + if atomic_output_bytes.is_some() && !atomic_facts { + return Err("--atomic-output-bytes requires --atomic-facts".into()); + } if json { + if atomic_facts && (!cfg!(feature = "embed-specs") || sleighpath.is_some()) { + return Err("--atomic-facts requires embedded specs and does not accept --sleighpath".into()); + } // Refused, not ignored: each of these is a `decomp_dbg` transcript the // in-process JSON path never produces, and silently dropping half a // request is the failure mode `--json` exists to end. @@ -997,6 +1012,8 @@ pub fn main(argv: &[String]) -> Result { binary: &binary, target: &target, by_address: addr, + atomic_facts, + atomic_output_bytes: atomic_output_bytes.unwrap_or(8 * 1024 * 1024), forwarded: &forwarded, mode: mode.as_deref(), bfd_target: bfd_target.as_deref(), @@ -1007,6 +1024,10 @@ pub fn main(argv: &[String]) -> Result { })); } + if atomic_facts { + return Err("--atomic-facts requires --json".into()); + } + // (kuna outlang, DIV-80) The auto policy -- follow the binary when the caller // named no language. See `decompile_all::detected_output_language`. if !saw_language && !options.iter().any(|(n, _)| n == "setlanguage") { @@ -1106,6 +1127,8 @@ struct JsonRequest<'a> { binary: &'a str, target: &'a str, by_address: bool, + atomic_facts: bool, + atomic_output_bytes: usize, /// The `--option` / `--language` tokens, in argv order. forwarded: &'a [String], mode: Option<&'a str>, @@ -1127,6 +1150,18 @@ struct JsonRequest<'a> { /// drift. fn run_json(req: &JsonRequest) -> i32 { let mut argv: Vec = vec![req.binary.to_string(), "--json".into()]; + if req.atomic_facts { + match crate::embedded_specs::tree() { + Some(Ok(root)) => { + argv.push("--sleighpath".into()); + argv.push(root.to_string_lossy().into_owned()); + } + _ => { + eprintln!("error: atomic facts require readable embedded specs"); + return 2; + } + } + } if req.by_address { argv.push("--addr".into()); argv.push(if looks_like_addr(req.target) { @@ -1168,7 +1203,7 @@ fn run_json(req: &JsonRequest) -> i32 { // it has to reconcile against a prose stderr line — the missing error channel // is half of why this flag exists. The verdict follows the document, matching // `run`'s stdout-then-stderr ordering. - let (text, failure) = match decompile_json(&args, req.target) { + let (text, failure) = match decompile_json(&args, req.target, req.atomic_facts.then_some(req.atomic_output_bytes)) { Ok(answered) => answered, Err(message) => ( decompile_all::render_result_json( @@ -1197,7 +1232,7 @@ fn run_json(req: &JsonRequest) -> i32 { /// was asked about exactly that function — so `--json` cannot turn the text /// surface's non-zero verdict into a success; the reason is in the document too, /// as the record's `error` and as the run-level one. -fn decompile_json(args: &AllArgs, target: &str) -> Result<(String, Option), String> { +fn decompile_json(args: &AllArgs, target: &str, atomic_limit: Option) -> Result<(String, Option), String> { let mut prog = decompile_all::load_program(args, DriverDefaults::Decompile)?; let targets = decompile_all::resolve_targets(&prog, args)?; let interior = crate::interior::selections(&prog, args, &targets); @@ -1217,7 +1252,7 @@ fn decompile_json(args: &AllArgs, target: &str) -> Result<(String, Option Result<(String, Option Result<(), String> { park_recovered_proto: false, single_target: false, want_tokens: false, + want_atomic_facts: false, want_pointer_arguments: args.jobs_pointerargs, }; let mut pending = entries.into_iter(); @@ -1264,7 +1265,7 @@ fn decompile_all(args: &Args, filters: &Filters) -> Result { let funcs = if callee_first { decompile_entries_callee_first(&mut prog, args, targets, explicit) } else { - decompile_entries(&mut prog, args, targets, /* pointer_arguments= */ true) + decompile_entries(&mut prog, args, targets, /* pointer_arguments= */ true, false) }; Ok(AllRun { funcs, discovered, assertions: prog.assertion_outcomes(), interior }) } @@ -1319,6 +1320,7 @@ pub(crate) fn decompile_entries( args: &Args, targets: Vec, pointer_arguments: bool, + want_atomic_facts: bool, ) -> Vec { arm_fn_budgets(prog, args); let opts = DecompileOptions { @@ -1326,6 +1328,7 @@ pub(crate) fn decompile_entries( want_provenance: args.json, single_target: targets.len() == 1, want_pointer_arguments: pointer_arguments, + want_atomic_facts, ..DecompileOptions::default() }; kuna_console::project::decompile_targets_with(prog, targets, &opts) @@ -2003,6 +2006,19 @@ pub(crate) fn render_result_json( render_selected_json(binary, funcs, options, error, None, assertions, interior) } +pub(crate) fn render_atomic_result_json( + binary: &str, + funcs: &[FuncResult], + options: &[(String, String)], + error: Option<&str>, + assertions: &[kuna_console::assertions::Outcome], + interior: &crate::interior::Interiors, + limit: usize, +) -> Result { + let language = last_option_value(options, "setlanguage").unwrap_or("c-language"); + crate::atomic_json::render(result_json(binary, funcs, language, error, None, assertions, interior), limit) +} + /// [`render_result_json`] plus the inventory `total` a NARROWED whole-binary run /// answers with. /// @@ -2152,6 +2168,13 @@ fn result_json( // Always present, `[]` unless the option is on. ("types".into(), types), ]; + if let Some(detail) = f.detail.as_ref() { + append_atomic_facet(&mut record, "atomic_facts", detail.atomic_facts.as_ref()); + append_atomic_facet(&mut record, "atomic_calls", detail.atomic_calls.as_ref()); + append_atomic_facet(&mut record, "atomic_parameters", detail.atomic_parameters.as_ref()); + append_atomic_facet(&mut record, "atomic_switches", detail.atomic_switches.as_ref()); + append_atomic_facet(&mut record, "atomic_projection", detail.atomic_projection.as_ref()); + } if !f.stack_objects.is_empty() { record.push(("stack_objects".into(), Json::Array( f.stack_objects.iter().map(stack_object_json).collect(), @@ -3519,3 +3542,16 @@ mod triage_tests { } } + +// Named independent facets share serialization/failure semantics; a bad facet +// cannot erase the ordinary C record or another valid facet. +fn append_atomic_facet(record: &mut Vec<(String, Json)>, name: &str, facts: Option<&Result>) { + if let Some(facts) = facts { + let encoded = facts.as_ref().map_err(Clone::clone) + .and_then(|facts| serde_json::to_value(facts).map_err(|error| format!("atomic facet serialization failed: {error}"))); + match encoded { + Ok(value) => record.push((name.into(), Json::from(value))), + Err(reason) => record.push((format!("{name}_error"), Json::Str(reason))), + } + } +} diff --git a/decompiler/crates/kuna-cli/src/decompile_all/callee_first.rs b/decompiler/crates/kuna-cli/src/decompile_all/callee_first.rs index 3eb2a6e4..2b951ba2 100644 --- a/decompiler/crates/kuna-cli/src/decompile_all/callee_first.rs +++ b/decompiler/crates/kuna-cli/src/decompile_all/callee_first.rs @@ -28,6 +28,7 @@ pub(super) fn decompile_entries_callee_first( park_recovered_proto: false, single_target: targets.len() == 1, want_tokens: false, + want_atomic_facts: false, want_pointer_arguments: true, }; decompile_callee_first(prog, args, targets, explicit, base) diff --git a/decompiler/crates/kuna-cli/src/jsonfmt.rs b/decompiler/crates/kuna-cli/src/jsonfmt.rs index 198b85a6..71bd9675 100644 --- a/decompiler/crates/kuna-cli/src/jsonfmt.rs +++ b/decompiler/crates/kuna-cli/src/jsonfmt.rs @@ -28,6 +28,19 @@ pub enum Json { Object(Vec<(String, Json)>), } +impl From for Json { + fn from(value: serde_json::Value) -> Self { + match value { + serde_json::Value::Null => Json::Null, + serde_json::Value::Bool(value) => Json::Bool(value), + serde_json::Value::Number(value) => Json::Number(value.to_string()), + serde_json::Value::String(value) => Json::Str(value), + serde_json::Value::Array(values) => Json::Array(values.into_iter().map(Json::from).collect()), + serde_json::Value::Object(values) => Json::Object(values.into_iter().map(|(key, value)| (key, Json::from(value))).collect()), + } + } +} + /// Render `v` exactly as CPython's `json.dumps(v, indent=2)` would (ASCII-safe /// string escaping, `", "`/`": "` item/key separators collapsed to the indent /// form: items separated by `,\n`, keys by `": "`). @@ -50,6 +63,12 @@ pub fn dumps_compact(v: &Json) -> String { writer::render(v, Format::Compact) } +/// Enforce the caller's encoded-byte budget before growing the output buffer. +/// Uses the same writer and escaping as the other modes; no length estimator. +pub fn dumps_compact_bounded(v: &Json, limit: usize) -> Option { + writer::render_bounded(v, Format::Compact, limit) +} + /// Extract the JSON array/object span out of a `decomp_dbg` console transcript. /// /// Port of `kuna/catalog.py::_extract_json`: drop the fixed `[decomp]>` prompt diff --git a/decompiler/crates/kuna-cli/src/jsonfmt/writer.rs b/decompiler/crates/kuna-cli/src/jsonfmt/writer.rs index c4eca1db..06b9d024 100644 --- a/decompiler/crates/kuna-cli/src/jsonfmt/writer.rs +++ b/decompiler/crates/kuna-cli/src/jsonfmt/writer.rs @@ -1,7 +1,7 @@ //! JSON layout and ASCII-safe escaping shared by all CLI output modes. use super::Json; -use std::fmt::Write as _; +use std::fmt::{self, Write}; #[derive(Clone, Copy, PartialEq, Eq)] pub(super) enum Format { @@ -12,108 +12,142 @@ pub(super) enum Format { pub(super) fn render(value: &Json, format: Format) -> String { let mut out = String::new(); - write_value(&mut out, value, 0, format); + write_value(&mut out, value, 0, format).expect("String writer is infallible"); out } +struct BoundedString { + text: String, + limit: usize, +} + +impl Write for BoundedString { + fn write_str(&mut self, value: &str) -> fmt::Result { + let next = self.text.len().checked_add(value.len()).ok_or(fmt::Error)?; + if next > self.limit { + return Err(fmt::Error); + } + if next > self.text.capacity() { + let capacity = next + .max(self.text.capacity().saturating_mul(2)) + .min(self.limit); + self.text + .try_reserve_exact(capacity - self.text.len()) + .map_err(|_| fmt::Error)?; + } + self.text.push_str(value); + Ok(()) + } +} + +pub(super) fn render_bounded(value: &Json, format: Format, limit: usize) -> Option { + let mut out = BoundedString { + text: String::new(), + limit, + }; + write_value(&mut out, value, 0, format).ok()?; + Some(out.text) +} + impl Format { - fn line(self, out: &mut String, indent: usize) { + fn line(self, out: &mut impl Write, indent: usize) -> fmt::Result { if self != Self::Compact { - out.push('\n'); + out.write_char('\n')?; for _ in 0..indent { - out.push(' '); + out.write_char(' ')?; } } + Ok(()) } } -fn write_value(out: &mut String, value: &Json, indent: usize, format: Format) { +fn write_value(out: &mut impl Write, value: &Json, indent: usize, format: Format) -> fmt::Result { match value { - Json::Null => out.push_str("null"), - Json::Bool(true) => out.push_str("true"), - Json::Bool(false) => out.push_str("false"), - Json::Number(number) => out.push_str(number), + Json::Null => out.write_str("null"), + Json::Bool(true) => out.write_str("true"), + Json::Bool(false) => out.write_str("false"), + Json::Number(number) => out.write_str(number), Json::Str(text) => write_string(out, text), Json::Array(items) => { - out.push('['); + out.write_char('[')?; for (i, item) in items.iter().enumerate() { if i > 0 { - out.push(','); + out.write_char(',')?; } - format.line(out, indent + 2); - write_value(out, item, indent + 2, format); + format.line(out, indent + 2)?; + write_value(out, item, indent + 2, format)?; } if !items.is_empty() { - format.line(out, indent); + format.line(out, indent)?; } - out.push(']'); + out.write_char(']') } Json::Object(pairs) => { if format == Format::Sorted && pairs.len() > 1 { let mut ordered: Vec<_> = pairs.iter().collect(); ordered.sort_by(|a, b| a.0.cmp(&b.0)); - write_pairs(out, ordered.into_iter(), indent, format); + write_pairs(out, ordered.into_iter(), indent, format) } else { - write_pairs(out, pairs.iter(), indent, format); + write_pairs(out, pairs.iter(), indent, format) } } } } fn write_pairs<'a>( - out: &mut String, + out: &mut impl Write, pairs: impl ExactSizeIterator, indent: usize, format: Format, -) { +) -> fmt::Result { let nonempty = pairs.len() != 0; - out.push('{'); + out.write_char('{')?; for (i, (key, value)) in pairs.enumerate() { if i > 0 { - out.push(','); + out.write_char(',')?; } - format.line(out, indent + 2); - write_string(out, key); - out.push(':'); + format.line(out, indent + 2)?; + write_string(out, key)?; + out.write_char(':')?; if format != Format::Compact { - out.push(' '); + out.write_char(' ')?; } - write_value(out, value, indent + 2, format); + write_value(out, value, indent + 2, format)?; } if nonempty { - format.line(out, indent); + format.line(out, indent)?; } - out.push('}'); + out.write_char('}') } -fn write_string(out: &mut String, s: &str) { - out.push('"'); +fn write_string(out: &mut impl Write, s: &str) -> fmt::Result { + out.write_char('"')?; for ch in s.chars() { match ch { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - '\u{0008}' => out.push_str("\\b"), - '\u{000C}' => out.push_str("\\f"), + '"' => out.write_str("\\\""), + '\\' => out.write_str("\\\\"), + '\n' => out.write_str("\\n"), + '\r' => out.write_str("\\r"), + '\t' => out.write_str("\\t"), + '\u{0008}' => out.write_str("\\b"), + '\u{000C}' => out.write_str("\\f"), c if (c as u32) < 0x20 => { - let _ = write!(out, "\\u{:04x}", c as u32); + write!(out, "\\u{:04x}", c as u32) } - c if (c as u32) < 0x7F => out.push(c), + c if (c as u32) < 0x7F => out.write_char(c), c => { // ensure_ascii: emit \uXXXX (surrogate pair for astral planes). let cp = c as u32; if cp <= 0xFFFF { - let _ = write!(out, "\\u{:04x}", cp); + write!(out, "\\u{:04x}", cp) } else { let v = cp - 0x10000; let hi = 0xD800 + (v >> 10); let lo = 0xDC00 + (v & 0x3FF); - let _ = write!(out, "\\u{:04x}\\u{:04x}", hi, lo); + write!(out, "\\u{:04x}\\u{:04x}", hi, lo) } } - } + }?; } - out.push('"'); + out.write_char('"') } diff --git a/decompiler/crates/kuna-cli/src/jsonfmt/writer_tests.rs b/decompiler/crates/kuna-cli/src/jsonfmt/writer_tests.rs index 6710e12a..ef90468c 100644 --- a/decompiler/crates/kuna-cli/src/jsonfmt/writer_tests.rs +++ b/decompiler/crates/kuna-cli/src/jsonfmt/writer_tests.rs @@ -94,3 +94,21 @@ fn every_unicode_scalar_round_trips_through_ascii_output() { assert_eq!(serde_json::from_str::(&output).unwrap(), text); } } + +#[test] +fn bounded_writer_counts_actual_escaped_bytes_at_every_boundary() { + let value = Json::Object(vec![("escaped".into(), Json::Str("\0\"\\é😀".into()))]); + let complete = dumps_compact(&value); + assert!(complete.is_ascii()); + for limit in 0..complete.len() { + assert!(dumps_compact_bounded(&value, limit).is_none()); + } + assert_eq!( + dumps_compact_bounded(&value, complete.len()), + Some(complete.clone()) + ); + assert_eq!( + dumps_compact_bounded(&value, complete.len() + 1), + Some(complete) + ); +} diff --git a/decompiler/crates/kuna-cli/src/main.rs b/decompiler/crates/kuna-cli/src/main.rs index 2345743c..a16abb71 100644 --- a/decompiler/crates/kuna-cli/src/main.rs +++ b/decompiler/crates/kuna-cli/src/main.rs @@ -1,6 +1,8 @@ //! Command dispatch for the kuna CLI. mod args; +mod atomic_json; +mod atomic_compression; mod callgraph; mod catalog; mod crypto; @@ -31,6 +33,7 @@ mod runtime_hints; mod skill; mod specs; mod strings; +mod storage_map; mod test; mod unpack; mod xrefs; @@ -52,6 +55,7 @@ const COMMANDS: &[(&str, CommandHandler)] = &[ ("read", disassemble::run_read), ("xrefs", xrefs::run), ("strings", strings::run), + ("storage-map", storage_map::run), ("crypto", crypto::run), ("unpack", unpack::run), ("docs", docs::run), diff --git a/decompiler/crates/kuna-cli/src/project_stream.rs b/decompiler/crates/kuna-cli/src/project_stream.rs index e40b18c9..b8cae233 100644 --- a/decompiler/crates/kuna-cli/src/project_stream.rs +++ b/decompiler/crates/kuna-cli/src/project_stream.rs @@ -858,6 +858,7 @@ fn export( // every function of the export. single_target: false, want_tokens: false, + want_atomic_facts: false, want_pointer_arguments: false, }; diff --git a/decompiler/crates/kuna-cli/src/storage_map.rs b/decompiler/crates/kuna-cli/src/storage_map.rs new file mode 100644 index 00000000..b2a30345 --- /dev/null +++ b/decompiler/crates/kuna-cli/src/storage_map.rs @@ -0,0 +1,232 @@ +//! Read-only architecture storage metadata without discovery or decompilation. +use crate::decompile_all::{load_program, Args, DriverDefaults}; +use kuna_base::space::{AddrSpaceManager, VarnodeStorage}; +use serde::{ + ser::{SerializeSeq, SerializeStruct}, + Serialize, Serializer, +}; +use std::{ + collections::BTreeMap, + io::{self, Write}, +}; + +const OUTPUT_BYTES: usize = 8 * 1024 * 1024; +struct BoundedOutput { + bytes: Vec, + limit: usize, +} +impl Write for BoundedOutput { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let next = self + .bytes + .len() + .checked_add(bytes.len()) + .filter(|n| *n <= self.limit) + .ok_or_else(|| io::Error::other("storage map encoded-byte capacity exceeded"))?; + if next > self.bytes.capacity() { + let capacity = next + .checked_next_power_of_two() + .unwrap_or(self.limit) + .min(self.limit); + self.bytes + .try_reserve_exact(capacity - self.bytes.len()) + .map_err(io::Error::other)?; + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} +#[derive(Serialize)] +struct Space<'a> { + native_index: i32, + name: &'a str, + kind: String, + address_size_bytes: u32, + addressable_unit_bytes: u32, + endianness: &'static str, + highest_offset: String, +} +struct Spaces<'a>(&'a AddrSpaceManager); +impl Serialize for Spaces<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut rows = serializer.serialize_seq(None)?; + for index in 0..self.0.num_spaces() { + if let Some(space) = self.0.get_space(index) { + rows.serialize_element(&Space { + native_index: space.get_index(), + name: space.get_name(), + kind: format!("{:?}", space.get_type()), + address_size_bytes: space.get_addr_size(), + addressable_unit_bytes: space.get_word_size(), + endianness: if space.is_big_endian() { + "big" + } else { + "little" + }, + highest_offset: format!("0x{:x}", space.get_highest()), + })?; + } + } + rows.end() + } +} +#[derive(Serialize)] +struct Register<'a> { + native_row: usize, + space: Option<&'a str>, + offset: String, + size_bytes: u32, + name: Option<&'a str>, + name_bytes_hex: Option, +} +struct Registers<'a>(&'a BTreeMap>); +impl Serialize for Registers<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut rows = serializer.serialize_seq(Some(self.0.len()))?; + for (native_row, (location, bytes)) in self.0.iter().enumerate() { + let name = std::str::from_utf8(bytes).ok(); + let name_bytes_hex = if name.is_some() { + None + } else { + if bytes.len() > OUTPUT_BYTES / 2 { + return Err(serde::ser::Error::custom( + "native register name exceeds encoded budget", + )); + } + Some(bytes.iter().map(|b| format!("{b:02x}")).collect()) + }; + rows.serialize_element(&Register { + native_row, + space: location.space.as_ref().map(|s| s.get_name()), + offset: format!("0x{:x}", location.offset), + size_bytes: location.size, + name, + name_bytes_hex, + })?; + } + rows.end() + } +} +struct Catalog<'a> { + binary: &'a str, + program: &'a kuna_console::engine::ConsoleProgram, +} +impl Serialize for Catalog<'_> { + fn serialize(&self, serializer: S) -> Result { + let arch = self.program.arch(); + let sleigh = arch + .translate() + .as_sleigh() + .ok_or_else(|| serde::ser::Error::custom("native Sleigh register map unavailable"))?; + let registers = sleigh.base().get_all_registers(); + let manager = arch.manage(); + let mut doc = serializer.serialize_struct("StorageMap", 8)?; + doc.serialize_field("schema_version", &1)?; + doc.serialize_field("binary", self.binary)?; + doc.serialize_field( + "architecture", + &serde_json::json!({ + "native_id": arch.get_description(), + "default_code_space": manager.get_default_code_space().map(|s|s.get_name()), + }), + )?; + doc.serialize_field("space_index_capacity", &manager.num_spaces())?; + doc.serialize_field("register_count", ®isters.len())?; + doc.serialize_field("spaces", &Spaces(manager))?; + doc.serialize_field("registers", &Registers(registers))?; + doc.serialize_field("coverage", &serde_json::json!({ + "scope":"bootstrapped native architecture spaces and exact varnode_xref register-name map", + "native_exact_register_map":"complete", "alias_relationships":"unknown", + "dynamic_join_records":"not-collected", "memory_values":"not-returned", + "listing_discovery":false, "functions_decompiled":0, + }))?; + doc.end() + } +} +pub(crate) fn run(argv: &[String]) -> i32 { + if argv.len() != 2 || argv[1] != "--json" { + eprintln!("usage: kuna storage-map --json"); + return 2; + } + let embedded = match crate::embedded_specs::tree() { + Some(Ok(root)) => root.to_string_lossy().into_owned(), + Some(Err(error)) => { + eprintln!("error: embedded specs: {error}"); + return 1; + } + None => { + eprintln!("error: storage-map requires embedded specs"); + return 1; + } + }; + let args = Args { + binary: argv[0].clone(), + json: true, + sleighpath: Some(embedded), + options: vec![ + ("listing".into(), "off".into()), + ("fast_funcdisc".into(), "off".into()), + ], + ..Args::serial() + }; + let result = (|| { + let program = load_program(&args, DriverDefaults::Query)?; + let mut output = BoundedOutput { + bytes: Vec::new(), + limit: OUTPUT_BYTES, + }; + serde_json::to_writer( + &mut output, + &Catalog { + binary: &args.binary, + program: &program, + }, + ) + .map_err(|e| e.to_string())?; + output.write_all(b"\n").map_err(|e| e.to_string())?; + String::from_utf8(output.bytes).map_err(|e| e.to_string()) + })(); + match result { + Ok(text) => crate::output::emit_with_status(&text, 0), + Err(error) => { + eprintln!("error: {error}"); + 1 + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn output_admission_precedes_allocation_and_failed_writes_preserve_prefix() { + let mut output = BoundedOutput { + bytes: Vec::new(), + limit: 5, + }; + output.write_all(b"123").unwrap(); + assert!(output.write_all(b"456").is_err()); + assert_eq!(output.bytes, b"123"); + assert!(output.bytes.capacity() <= 5); + output.write_all(b"45").unwrap(); + assert_eq!(output.bytes, b"12345"); + } + #[test] + fn invalid_utf8_names_keep_exact_bytes_instead_of_lossy_text() { + let mut map = BTreeMap::new(); + map.insert( + VarnodeStorage { + space: None, + offset: u64::MAX, + size: 1, + }, + vec![0xff, 0x00], + ); + let value = serde_json::to_value(Registers(&map)).unwrap(); + assert!(value[0]["name"].is_null()); + assert_eq!(value[0]["name_bytes_hex"], "ff00"); + assert_eq!(value[0]["offset"], "0xffffffffffffffff"); + } +} diff --git a/decompiler/crates/kuna-console/src/inspect.rs b/decompiler/crates/kuna-console/src/inspect.rs index 74024b10..9e36d55f 100644 --- a/decompiler/crates/kuna-console/src/inspect.rs +++ b/decompiler/crates/kuna-console/src/inspect.rs @@ -24,6 +24,11 @@ pub const FUNCTION_ROW_CAP: usize = 4096; pub struct FuncDetail { pub tokens: Vec, pub tokens_error: Option, + pub atomic_facts: Option>, + pub atomic_calls: Option>, + pub atomic_parameters: Option>, + pub atomic_switches: Option>, + pub atomic_projection: Option>, } impl FuncDetail { @@ -40,13 +45,13 @@ impl FuncDetail { ) -> Self { let mut tokens = kuna_srcmap::resolve(fd, var_refs, raw, untrimmed, variables); if let Err(error) = kuna_srcmap::verify(&tokens, code) { - return FuncDetail { tokens: Vec::new(), tokens_error: Some(error) }; + return FuncDetail { tokens: Vec::new(), tokens_error: Some(error), ..Default::default() }; } for t in &mut tokens { t.address = t.address.map(|a| prog.output_code_offset(a)); t.callee = t.callee.map(|a| prog.output_code_offset(a)); } - FuncDetail { tokens, tokens_error: None } + FuncDetail { tokens, tokens_error: None, ..Default::default() } } } diff --git a/decompiler/crates/kuna-console/src/project.rs b/decompiler/crates/kuna-console/src/project.rs index bc401db2..2ef18e27 100644 --- a/decompiler/crates/kuna-console/src/project.rs +++ b/decompiler/crates/kuna-console/src/project.rs @@ -245,6 +245,8 @@ pub struct DecompileOptions { /// Capture the token source map ([`FuncResult::detail`]); implies the /// provenance render. pub want_tokens: bool, + /// Retain the read-only recovered IR from the same function instance. + pub want_atomic_facts: bool, /// Record [`FuncResult::pointerargs`] for a buffered driver. A batch that /// returns its results ([`decompile_targets_with`]) reconciles them itself; /// a pulled caller owns that step. @@ -278,6 +280,7 @@ pub fn decompile_targets( park_recovered_proto: false, single_target: targets.len() == 1, want_tokens: false, + want_atomic_facts: false, want_pointer_arguments: false, }; decompile_batch(prog, targets, &opts) @@ -321,6 +324,7 @@ pub fn export_options(single_target: bool) -> DecompileOptions { park_recovered_proto: false, single_target, want_tokens: false, + want_atomic_facts: false, want_pointer_arguments: true, } } @@ -509,6 +513,7 @@ pub fn decompile_pulled( header_carries_types, single_target, want_tokens, + want_atomic_facts, want_pointer_arguments, .. } = *opts; @@ -838,11 +843,24 @@ pub fn decompile_pulled( } } let var_refs = provenance.apply_to_variables_with_refs(&fd, &mut variables); - let detail = want_tokens.then(|| { + let mut detail = want_tokens.then(|| { Box::new(crate::inspect::FuncDetail::resolve( prog, &fd, &var_refs, &raw_tokens, &untrimmed, &code, &variables, )) }); + if want_atomic_facts { + let detail = detail.get_or_insert_with(Default::default); + detail.atomic_parameters = Some(kuna_decomp::atomic_parameters::collect(fd.get_func_proto())); + let membership = kuna_decomp::atomic_membership::inspect(&fd); + detail.atomic_calls = Some(membership.as_ref().map_err(Clone::clone) + .and_then(|m| kuna_decomp::atomic_calls::collect_all(&fd, m))); + detail.atomic_switches = Some(membership.as_ref().map_err(Clone::clone) + .and_then(|m| kuna_decomp::atomic_switches::collect(&fd, m))); + detail.atomic_facts = Some(membership.as_ref().map_err(Clone::clone) + .and_then(|m| kuna_decomp::atomic_facts::collect(prog.arch(), &fd, m))); + detail.atomic_projection = Some(membership.as_ref().map_err(Clone::clone) + .and_then(|m| kuna_decomp::atomic_projection::collect(prog.arch(), &fd, m))); + } for variable in &mut variables { for address in &mut variable.addresses { *address = prog.output_code_offset(*address); diff --git a/decompiler/crates/kuna-decomp/Cargo.toml b/decompiler/crates/kuna-decomp/Cargo.toml index 40b093b6..8f03b654 100644 --- a/decompiler/crates/kuna-decomp/Cargo.toml +++ b/decompiler/crates/kuna-decomp/Cargo.toml @@ -15,11 +15,11 @@ kuna-sleigh.workspace = true thiserror.workspace = true smallvec.workspace = true slotmap.workspace = true +serde = { version = "1", features = ["derive"] } [dev-dependencies] # Verifier-only: the datatest engine (item w10-structure-printbody). regex = { workspace = true } -serde = { version = "1", features = ["derive"] } toml = { version = "1.1", default-features = false, features = ["parse", "serde", "std"] } [build-dependencies] diff --git a/decompiler/crates/kuna-decomp/src/p2_lift/jumptable.rs b/decompiler/crates/kuna-decomp/src/p2_lift/jumptable.rs index b2677f6c..5e913130 100644 --- a/decompiler/crates/kuna-decomp/src/p2_lift/jumptable.rs +++ b/decompiler/crates/kuna-decomp/src/p2_lift/jumptable.rs @@ -5481,6 +5481,22 @@ impl JumpTable { self.label[index as usize] } + /// Borrow observed rows for read-only analysis without indexing absent labels. + /// Row order, labels and recovered out-edge positions are separate identities. + pub fn case_rows(&self) -> KunaResult)>> { + if self.label.len() > self.addresstable.len() { + return Err(KunaError::lowlevel("jump-table labels exceed address rows")); + } + Ok(self.addresstable.iter().enumerate().map(|(index, address)| { + (index, address, self.label.get(index).copied()) + })) + } + + /// Borrow the native row-to-out-edge mapping; never infer it from addresses. + pub fn case_edge_rows(&self) -> impl ExactSizeIterator + '_ { + self.block2addr.iter().map(|pair| (pair.address_index, pair.block_position)) + } + /// Force a given basic-block to be a switch destination /// (C++ `JumpTable::addBlockToSwitch`, `jumptable.cc:2663`). pub fn add_block_to_switch(&mut self, fd: &Funcdata, bl: BlockId, lab: uintb) -> KunaResult<()> { diff --git a/decompiler/crates/kuna-decomp/src/p2_lift/jumptable/tests.rs b/decompiler/crates/kuna-decomp/src/p2_lift/jumptable/tests.rs index 8aad0172..2f69df2d 100644 --- a/decompiler/crates/kuna-decomp/src/p2_lift/jumptable/tests.rs +++ b/decompiler/crates/kuna-decomp/src/p2_lift/jumptable/tests.rs @@ -725,3 +725,21 @@ fn guard_range_retry_keeps_both_wrapped_bounds() { model.calc_range(&fd, input, &mut range); assert_eq!((range.get_min(), range.get_end(), range.get_size()), (58, 62, 4)); } + +#[test] +fn atomic_case_rows_keep_ordinals_labels_and_edges_separate() { + let manage = build_manager(); + let ram = Rc::clone(manage.get_space_by_name("ram").unwrap()); + let mut table = JumpTable::new(Address::new(Rc::clone(&ram), 0x1000)); + table.kuna_push_entry(Address::new(Rc::clone(&ram), 0x3000), 2, 0x77); + table.kuna_push_entry(Address::new(Rc::clone(&ram), 0x4000), 0, NO_LABEL); + table.kuna_finalize(0); + let rows: Vec<_> = table.case_rows().unwrap() + .map(|(index, address, label)| (index, address.get_offset(), label)).collect(); + assert_eq!(rows, vec![(0, 0x3000, Some(0x77)), (1, 0x4000, Some(NO_LABEL))]); + assert_eq!(table.case_edge_rows().collect::>(), vec![(1, 0), (0, 2)]); + table.label.pop(); + assert_eq!(table.case_rows().unwrap().nth(1).unwrap().2, None); + table.label.extend([1, 2]); + assert!(table.case_rows().is_err()); +} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_calls.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_calls.rs new file mode 100644 index 00000000..fa04cafc --- /dev/null +++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_calls.rs @@ -0,0 +1,189 @@ +//! Recovered call relations. Argument values and passing storage are separate. +use crate::{ + atomic_membership::{Membership, MAX_CALLS}, + atomic_values::{self, operation_id, value_id, ValueFact}, + context::OpId, + atomic_storage::{location, storage, Location, Storage}, + fspec::{FuncCallSpecs, OFFSET_UNKNOWN}, + funcdata::Funcdata, +}; +use serde::Serialize; +use std::collections::{BTreeSet, HashMap}; + +#[derive(Debug, Clone, Serialize)] +pub struct CallFacts { + pub schema_version: u32, + pub items: Vec, + pub values: Vec, + pub coverage: CallCoverage, +} +#[derive(Debug, Clone, Serialize)] +pub struct CallCoverage { + pub scope: &'static str, + pub blocks_examined: i32, + pub operations_examined: usize, + pub value_scope: &'static str, + pub machine_call_equivalence: &'static str, +} + +pub fn collect_all(fd: &Funcdata, membership: &Membership) -> Result { + const MAX_VALUES: usize = 36_000; + const MAX_INPUTS: usize = 50_000; + if fd.bblocks_get_size() <= 0 { + return Err("recovered block membership is unavailable".into()); + } + if fd.num_calls() < 0 || fd.num_calls() as usize > MAX_CALLS { + return Err("atomic call-spec capacity exceeded".into()); + } + let mut specs = HashMap::new(); + for i in 0..fd.num_calls() { + let spec = fd.get_call_specs(i); + if specs.insert(spec.get_op(), spec).is_some() { + return Err("duplicate native call-spec operation".into()); + } + } + let mut items = Vec::new(); + let mut keys = BTreeSet::new(); + let mut inputs = 0usize; + let mut text = atomic_values::TextBudget::new(2 * 1024 * 1024); + for &key in &membership.calls { + let op = fd.obank().get(key).ok_or("stale call operation")?; + inputs = inputs + .checked_add(op.num_input() as usize) + .ok_or("atomic call input count overflow")?; + if inputs > MAX_INPUTS { + return Err("atomic call input capacity exceeded".into()); + } + let spec = specs.get(&key).copied(); + if let Some(spec) = spec { + text.charge(spec.get_name().len())?; + if spec.proto().has_model() { + text.charge(spec.proto().get_model_name().len())?; + } + } + for index in 0..op.num_input() { + if let Some(v) = op.get_in(index) { + keys.insert(v); + } + } + if let Some(v) = op.get_out() { + keys.insert(v); + } + if keys.len() > MAX_VALUES { + return Err("atomic call value capacity exceeded".into()); + } + items.push(collect(fd, key, spec)?); + } + let values = atomic_values::collect(fd, keys, &mut text)?; + Ok(CallFacts { + schema_version: 1, items, values, + coverage: CallCoverage { + scope: "all recovered block-member CALL/CALLIND operations", + blocks_examined: membership.blocks_examined, operations_examined: membership.operations_examined, + value_scope: "call inputs including target and outputs only; definitions may be outside this facet", + machine_call_equivalence: "unknown", + }, + }) +} + +#[derive(Debug, Clone, Serialize)] +pub struct Argument { + pub input_index: i32, + pub value_id: Option, + pub storage: Option, + pub storage_authority: Option<&'static str>, +} +#[derive(Debug, Clone, Serialize)] +pub struct Prototype { + pub model: Option, + pub model_unknown: Option, + pub inputs_locked: bool, + pub variadic: bool, + pub parameter_shift: i32, +} +#[derive(Debug, Clone, Serialize)] +pub struct CallFact { + pub id: String, + pub origin_address: String, + pub block: i32, + pub opcode: String, + pub is_dead: bool, + pub target_value_id: Option, + pub spec_available: bool, + pub target: Option, + pub target_name: Option, + pub arguments: Vec, + pub output: Option, + pub prototype: Option, + pub stack_offset: Option, +} +fn collect(fd: &Funcdata, key: OpId, spec: Option<&FuncCallSpecs>) -> Result { + let op = fd.obank().get(key).ok_or("stale call operation")?; + let arguments = (1..op.num_input()) + .map(|input_index| { + // The native owner stores finalized passing locations separately + // from SSA values. Admit that mapping only with matching arity. + // Placeholder/shifted inputs are not source parameter ordinals. + let binding = spec.and_then(|spec| { + if spec.get_paramshift() != 0 || spec.get_stack_placeholder_slot() == input_index { + return None; + } + if spec.final_input_storage().len() + 1 == op.num_input() as usize { + let (address, size) = &spec.final_input_storage()[(input_index - 1) as usize]; + return storage(address, *size).map(|s| (s, "final-input-storage")); + } + let proto = spec.proto(); + if proto.has_store() && proto.is_input_locked() { + return proto.get_param(input_index - 1).and_then(|param| { + storage(¶m.get_address(), param.get_size()) + .map(|s| (s, "locked-prototype")) + }); + } + None + }); + let (storage, storage_authority) = match binding { + Some((s, authority)) => (Some(s), Some(authority)), + None => (None, None), + }; + Argument { + input_index, + value_id: op.get_in(input_index).map(value_id), + storage, + storage_authority, + } + }) + .collect(); + Ok(CallFact { + id: operation_id(fd, key)?, + origin_address: format!("0x{:x}", op.get_seq_num().get_addr().get_offset()), + block: fd + .bblocks_ref() + .block(op.get_parent().ok_or("call has no recovered block")?) + .get_index(), + opcode: format!("{:?}", op.code()), + is_dead: op.is_dead(), + target_value_id: op.get_in(0).map(value_id), + spec_available: spec.is_some(), + target: spec.and_then(|s| location(s.get_entry_address())), + target_name: spec + .and_then(|s| (!s.get_name().is_empty()).then(|| s.get_name().to_string())), + arguments, + output: op.get_out().map(value_id), + prototype: spec.map(|s| { + let proto = s.proto(); + Prototype { + model: proto + .has_model() + .then(|| proto.get_model_name().to_string()), + model_unknown: proto.has_model().then(|| proto.is_model_unknown()), + inputs_locked: proto.is_input_locked(), + variadic: proto.is_dotdotdot(), + parameter_shift: s.get_paramshift(), + } + }), + stack_offset: spec.and_then(|s| { + (s.get_spacebase_offset() != OFFSET_UNKNOWN) + .then(|| format!("0x{:x}", s.get_spacebase_offset())) + }), + }) +} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_facts.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_facts.rs new file mode 100644 index 00000000..6b9afe69 --- /dev/null +++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_facts.rs @@ -0,0 +1,156 @@ +//! Opt-in, read-only export of the recovered function IR for the REA experiment. +//! IDs are local to this captured model; addresses alone are not SSA identities. +use crate::{ + architecture::Architecture, + atomic_membership::Membership, + atomic_operations::{OperationBudget, OperationFact}, + atomic_values::{operation_id, value_id, ValueFact}, + funcdata::Funcdata, +}; +use kuna_num::opcodes::OpCode; +use serde::Serialize; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Serialize)] +pub struct FunctionFacts { + pub schema_version: u32, + pub blocks: Vec, + pub operations: Vec, + pub values: Vec, + pub coverage: Coverage, +} +#[derive(Debug, Clone, Serialize)] +pub struct Coverage { + pub scope: &'static str, + pub machine_semantic_equivalence: &'static str, + pub indirect_branch_operations: Vec, + pub omitted_definition_ids: Vec, + pub issues: Vec, +} +#[derive(Debug, Clone, Serialize)] +pub struct BlockFact { + pub id: i32, + pub start: String, + pub operations: Vec, + pub predecessors: Vec, + pub successors: Vec, +} +#[derive(Debug, Clone, Serialize)] +pub struct EdgeFact { + pub id: String, + pub target: i32, + pub index: i32, + pub polarity: Option<&'static str>, + pub condition_value: Option, + pub condition_test: Option<&'static str>, +} +pub fn collect( + arch: &Architecture, + fd: &Funcdata, + membership: &Membership, +) -> Result { + // Bounds cover allocations before JSON serialization, independently of the + // transport byte budget. Overflow is an unavailable facet, not clipped IR. + const MAX_OPERATIONS: usize = 32_000; + const MAX_VALUES: usize = 36_000; + const MAX_INPUTS: usize = 100_000; + const MAX_DISPLAY_BYTES: usize = 4 * 1024 * 1024; + const MAX_CFG_EDGES: usize = 50_000; + if fd.bblocks_get_size() <= 0 { + return Err("recovered basic-block graph is unavailable".into()); + } + if fd.bblocks_get_size() > 12_000 { + return Err("atomic block capacity exceeded".into()); + } + if membership.operations_examined > MAX_OPERATIONS { + return Err("atomic operation capacity exceeded".into()); + } + let mut blocks = Vec::new(); + let mut operations = Vec::new(); + let mut retained_ops = BTreeSet::new(); + let mut edge_count = 0usize; + let mut budget = OperationBudget::new(MAX_VALUES, MAX_INPUTS, MAX_DISPLAY_BYTES); + let mut coverage = Coverage { + scope: "complete native recovered basic-block graph; block-member operations", + machine_semantic_equivalence: "unknown", + indirect_branch_operations: Vec::new(), + omitted_definition_ids: Vec::new(), + issues: Vec::new(), + }; + for block_index in 0..fd.bblocks_get_size() { + let block_key = fd.bblocks_get_block(block_index); + let block = fd.bblocks_ref().block(block_key); + let members = fd.bb_ops(block_key); + if members.len() > MAX_OPERATIONS - operations.len() { + return Err("atomic operation capacity exceeded".into()); + } + edge_count = edge_count + .checked_add(block.size_out() as usize) + .ok_or("atomic CFG edge count overflow")?; + if edge_count > MAX_CFG_EDGES { + return Err("atomic CFG edge capacity exceeded".into()); + } + let tail = members.last().and_then(|id| fd.obank().get(*id)); + let conditional = + tail.filter(|op| op.code() == OpCode::CPUI_CBRANCH && block.size_out() == 2); + let condition_value = conditional.and_then(|op| op.get_in(1)).map(value_id); + let condition_test = conditional.map(|op| { + if op.is_boolean_flip() { + "eq-zero" + } else { + "ne-zero" + } + }); + let successors = (0..block.size_out()) + .map(|i| EdgeFact { + id: format!("{}:{i}", block.get_index()), + target: fd.bblocks_ref().block(block.get_out(i)).get_index(), + index: i, + polarity: conditional.map(|_| if i == 0 { "false" } else { "true" }), + condition_value: condition_value.clone(), + condition_test, + }) + .collect(); + blocks.push(BlockFact { + id: block.get_index(), + start: format!("0x{:x}", fd.bblocks_block_start(block_key).get_offset()), + operations: members + .iter() + .map(|id| operation_id(fd, *id)) + .collect::>()?, + predecessors: (0..block.size_in()) + .map(|i| fd.bblocks_ref().block(block.get_in(i)).get_index()) + .collect(), + successors, + }); + for key in members { + if operations.len() >= MAX_OPERATIONS { + return Err("atomic operation capacity exceeded".into()); + } + let op = budget.collect(arch, fd, key)?; + if !retained_ops.insert(op.id.clone()) { + return Err("operation belongs to multiple recovered blocks".into()); + } + if op.opcode == "CPUI_BRANCHIND" { + coverage.indirect_branch_operations.push(op.id.clone()); + } + operations.push(op); + } + } + let values = budget.values(fd)?; + coverage.issues = budget.issues; + let missing: BTreeSet<_> = values + .iter() + .filter_map(|v| v.definition.as_ref()) + .filter(|id| !retained_ops.contains(*id)) + .cloned() + .collect(); + coverage.omitted_definition_ids = missing.into_iter().collect(); + Ok(FunctionFacts { + schema_version: 3, + blocks, + operations, + values, + coverage, + }) +} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_membership.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_membership.rs new file mode 100644 index 00000000..be11e472 --- /dev/null +++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_membership.rs @@ -0,0 +1,76 @@ +//! One validated membership scan for independent recovered fact facets. +use crate::{context::OpId, funcdata::Funcdata}; +use kuna_num::opcodes::OpCode; +use std::collections::HashSet; + +pub const MAX_CALLS: usize = 12_000; +const MAX_SWITCHES: usize = 12_000; +// Bound the optional membership index separately from call discovery. Crossing +// this resource budget disables projections, without discarding known calls. +const MAX_INDEXED_OPERATIONS: usize = 65_536; +pub struct Membership { + pub calls: Vec, + pub switches: Option>, + pub members: Option>, + pub blocks_examined: i32, + pub operations_examined: usize, +} +pub fn inspect(fd: &Funcdata) -> Result { + if fd.bblocks_get_size() <= 0 { + return Err("recovered block membership is unavailable".into()); + } + let mut result = Membership { + calls: Vec::new(), + switches: Some(Vec::new()), + members: Some(HashSet::new()), + blocks_examined: fd.bblocks_get_size(), + operations_examined: 0, + }; + for i in 0..fd.bblocks_get_size() { + let block = fd.bblocks_get_block(i); + let expected = fd.bb_op_len(block); + let mut examined = 0; + let mut cursor = fd.bb_op_head(block); + while let Some(key) = cursor { + if examined >= expected { + return Err("cyclic native block operation list".into()); + } + examined += 1; + result.operations_examined = result + .operations_examined + .checked_add(1) + .ok_or("native operation count overflow")?; + let op = fd.obank().get(key).ok_or("stale block operation")?; + if op.get_parent() != Some(block) { + return Err("native block ownership mismatch".into()); + } + cursor = op.basic_neighbours().1; + if let Some(members) = result.members.as_mut() { + if members.len() == MAX_INDEXED_OPERATIONS { + result.members = None; + } else if !members.insert(key) { + return Err("operation belongs to multiple recovered blocks".into()); + } + } + if matches!(op.code(), OpCode::CPUI_CALL | OpCode::CPUI_CALLIND) { + if result.calls.len() >= MAX_CALLS { + return Err("atomic call capacity exceeded".into()); + } + result.calls.push(key); + } + if op.code() == OpCode::CPUI_BRANCHIND { + if let Some(switches) = result.switches.as_mut() { + if switches.len() == MAX_SWITCHES { + result.switches = None; + } else { + switches.push(key); + } + } + } + } + if examined != expected { + return Err("native block operation count mismatch".into()); + } + } + Ok(result) +} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_operations.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_operations.rs new file mode 100644 index 00000000..30d6be39 --- /dev/null +++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_operations.rs @@ -0,0 +1,119 @@ +//! Shared read-only operation/value normalization for full and selected facts. +use crate::{ + architecture::Architecture, + atomic_values::{self, operation_id, value_id, ValueFact}, + context::{OpId, VarnodeId}, + funcdata::Funcdata, +}; +use kuna_num::opcodes::OpCode; +use serde::Serialize; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Serialize)] +pub struct OperationFact { + pub id: String, + pub address: String, + pub block: i32, + pub opcode: String, + pub statement: String, + pub is_dead: bool, + pub inputs: Vec, + pub output: Option, +} +#[derive(Debug, Clone, Serialize)] +pub struct InputFact { + pub input_index: i32, + pub value_id: Option, + pub phi_predecessor: Option, + pub phi_predecessor_edge: Option, +} +pub struct OperationBudget { + max_values: usize, + max_inputs: usize, + keys: BTreeSet, + input_count: usize, + text: atomic_values::TextBudget, + pub issues: Vec, +} +impl OperationBudget { + pub fn new(max_values: usize, max_inputs: usize, max_display: usize) -> Self { + Self { + max_values, + max_inputs, + keys: BTreeSet::new(), + input_count: 0, + text: atomic_values::TextBudget::new(max_display), + issues: Vec::new(), + } + } + pub fn collect( + &mut self, + arch: &Architecture, + fd: &Funcdata, + key: OpId, + ) -> Result { + let op = fd.obank().get(key).ok_or("stale operation")?; + let parent = op.get_parent().ok_or("operation has no recovered block")?; + let block = fd.bblocks_ref().block(parent); + let id = operation_id(fd, key)?; + if op.num_input() < 0 { + return Err("negative native input count".into()); + } + self.input_count = self + .input_count + .checked_add(op.num_input() as usize) + .ok_or("atomic input count overflow")?; + if self.input_count > self.max_inputs { + return Err("atomic input-edge capacity exceeded".into()); + } + let phi = op.code() == OpCode::CPUI_MULTIEQUAL; + let phi_bound = phi && block.size_in() == op.num_input(); + if phi && !phi_bound { + self.issues + .push(format!("{id}: phi/predecessor count mismatch")); + } + let inputs = (0..op.num_input()) + .map(|index| { + let value = op.get_in(index); + if let Some(v) = value { + self.keys.insert(v); + } + InputFact { + input_index: index, + value_id: value.map(value_id), + phi_predecessor: phi_bound + .then(|| fd.bblocks_ref().block(block.get_in(index)).get_index()), + phi_predecessor_edge: phi_bound.then(|| { + format!( + "{}:{}", + fd.bblocks_ref().block(block.get_in(index)).get_index(), + block.get_in_rev_index(index) + ) + }), + } + }) + .collect(); + if let Some(v) = op.get_out() { + self.keys.insert(v); + } + if self.keys.len() > self.max_values { + return Err("atomic value capacity exceeded".into()); + } + let mut statement = String::new(); + crate::funcdata_printraw::render_op(&mut statement, arch, fd, key)?; + self.text.charge(statement.len())?; + Ok(OperationFact { + id, + address: format!("0x{:x}", op.get_seq_num().get_addr().get_offset()), + block: block.get_index(), + opcode: format!("{:?}", op.code()), + statement, + is_dead: op.is_dead(), + inputs, + output: op.get_out().map(value_id), + }) + } + pub fn values(&mut self, fd: &Funcdata) -> Result, String> { + atomic_values::collect(fd, std::mem::take(&mut self.keys), &mut self.text) + } +} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_parameters.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_parameters.rs new file mode 100644 index 00000000..82a34a49 --- /dev/null +++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_parameters.rs @@ -0,0 +1,155 @@ +//! Native function prototype facts, independent of raw input varnodes/call count. +use crate::{ + atomic_storage::{storage, Storage}, + atomic_values::TextBudget, + dtype::type_metatype, + fspec::{FuncProto, ProtoParameter}, +}; +use serde::Serialize; + +// Bound row expansion independently of the encoded document/text budgets. +pub const MAX_PARAMETERS: usize = 12_000; +#[derive(Debug, Clone, Serialize)] +pub struct Parameter { + pub id: String, + pub native_index: Option, + pub name: String, + pub type_name: Option, + pub size_bytes: i32, + pub is_void: Option, + pub storage: Option, + pub type_locked: bool, + pub name_locked: bool, + pub size_locked: bool, + pub this_pointer: bool, + pub indirect_storage: bool, + pub hidden_return: bool, +} +#[derive(Debug, Clone, Serialize)] +pub struct Parameters { + pub schema_version: u32, + pub model: Option, + pub model_unknown: Option, + pub inputs_locked: bool, + pub output_locked: bool, + pub variadic: bool, + pub input_errors: bool, + pub output_errors: bool, + pub items: Vec, + pub output: Parameter, +} +fn parameter( + p: &dyn ProtoParameter, + index: Option, + text: &mut TextBudget, +) -> Result { + text.charge(p.get_name().len())?; + if let Some(t) = p.get_type() { + text.charge(t.get_name().len())?; + } + Ok(Parameter { + id: index.map_or_else(|| "return".into(), |i| format!("parameter:{i}")), + native_index: index, + name: p.get_name().into(), + type_name: p.get_type().map(|t| t.get_name().into()), + size_bytes: p.get_size(), + is_void: p + .get_type() + .map(|t| t.get_metatype() == type_metatype::TYPE_VOID), + storage: storage(&p.get_address(), p.get_size()), + type_locked: p.is_type_locked(), + name_locked: p.is_name_locked(), + size_locked: p.is_size_type_locked(), + this_pointer: p.is_this_pointer(), + indirect_storage: p.is_indirect_storage(), + hidden_return: p.is_hidden_return(), + }) +} +pub fn collect(proto: &FuncProto) -> Result { + if !proto.has_store() { + return Err("native function prototype store is unavailable".into()); + } + let count = + usize::try_from(proto.num_params()).map_err(|_| "invalid native parameter count")?; + if count > MAX_PARAMETERS { + return Err("atomic parameter row capacity exceeded".into()); + } + let mut text = TextBudget::new(2 * 1024 * 1024); + if proto.has_model() { + text.charge(proto.get_model_name().len())?; + } + let mut items = Vec::with_capacity(count); + for i in 0..count { + let p = proto + .get_param(i as i32) + .ok_or("missing native parameter slot")?; + items.push(parameter(p, Some(i as i32), &mut text)?); + } + Ok(Parameters { + schema_version: 1, + model: proto.has_model().then(|| proto.get_model_name().into()), + model_unknown: proto.has_model().then(|| proto.is_model_unknown()), + inputs_locked: proto.is_input_locked(), + output_locked: proto.is_output_locked(), + variadic: proto.is_dotdotdot(), + input_errors: proto.has_input_errors(), + output_errors: proto.has_output_errors(), + items, + output: parameter(proto.get_output(), None, &mut text)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{dtype::Datatype, fspec::ParameterPieces}; + use kuna_base::{ + address::Address, + space::{spacetype, AddrSpace}, + }; + use std::rc::Rc; + #[test] + fn absent_store_is_unknown_not_an_empty_signature() { + assert!(collect(&FuncProto::new()) + .unwrap_err() + .contains("unavailable")); + } + #[test] + fn native_slots_return_storage_void_and_locks_remain_distinct() { + let mut proto = FuncProto::new(); + proto.attach_internal_store(Rc::new(Datatype::new(0, type_metatype::TYPE_VOID))); + let empty = collect(&proto).unwrap(); + assert!(empty.items.is_empty()); + assert_eq!(empty.output.is_void, Some(true)); + assert!(empty.output.storage.is_none()); + let reg = Rc::new(AddrSpace::new( + spacetype::IPTR_PROCESSOR, + "register", + false, + 8, + 1, + 3, + 0, + 0, + 0, + )); + let pieces = ParameterPieces { + addr: Address::new(reg, 0x38), + type_: Some(Rc::new(Datatype::new(8, type_metatype::TYPE_UNKNOWN))), + flags: 0, + }; + proto.set_param(0, "display name", &pieces); + proto.set_input_lock(true); + proto.set_output(&pieces); + let facts = collect(&proto).unwrap(); + assert_eq!(facts.items.len(), 1); + assert_eq!(facts.items[0].id, "parameter:0"); + assert_eq!(facts.items[0].native_index, Some(0)); + assert!(facts.inputs_locked && facts.items[0].type_locked); + assert_eq!(facts.output.is_void, Some(false)); + let storage = facts.output.storage.unwrap(); + assert_eq!(storage.location.space, "register"); + assert_eq!(storage.location.offset, "0x38"); + assert_eq!(storage.size_bytes, 8); + } +} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_projection.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_projection.rs new file mode 100644 index 00000000..097b153a --- /dev/null +++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_projection.rs @@ -0,0 +1,117 @@ +//! Bounded backward SSA facts, independent of full CFG export availability. +use crate::{ + architecture::Architecture, + atomic_membership::Membership, + atomic_operations::{OperationBudget, OperationFact}, + atomic_values::{operation_id, ValueFact}, + funcdata::Funcdata, +}; +use serde::Serialize; +use std::collections::{BTreeMap, HashSet, VecDeque}; + +#[derive(Debug, Clone, Serialize)] +pub struct Projection { + pub schema_version: u32, + pub roots: Vec, + pub operations: Vec, + pub values: Vec, + pub coverage: Coverage, +} +#[derive(Debug, Clone, Serialize)] +pub struct Frontier { + pub definition: String, + pub reason: &'static str, +} +#[derive(Debug, Clone, Serialize)] +pub struct Coverage { + pub scope: &'static str, + pub operation_budget: usize, + pub frontier: Vec, + pub issues: Vec, + pub machine_semantic_equivalence: &'static str, +} +pub fn collect( + arch: &Architecture, + fd: &Funcdata, + membership: &Membership, +) -> Result { + const MAX_OPERATIONS: usize = 4096; + let members = membership + .members + .as_ref() + .ok_or("projection membership-index capacity exceeded")?; + if membership.calls.len() > MAX_OPERATIONS { + return Err("projection call-root capacity exceeded".into()); + } + let roots = membership + .calls + .iter() + .map(|key| operation_id(fd, *key)) + .collect::, _>>()?; + let mut queue: VecDeque<_> = membership.calls.iter().copied().collect(); + let mut queued: HashSet<_> = membership.calls.iter().copied().collect(); + let mut retained = HashSet::new(); + let mut operations = Vec::new(); + let mut budget = OperationBudget::new(12_288, 16_384, 2 * 1024 * 1024); + while let Some(key) = queue.pop_front() { + if !members.contains(&key) { + continue; + } + if retained.len() == MAX_OPERATIONS { + break; + } + if !retained.insert(key) { + continue; + } + operations.push(budget.collect(arch, fd, key)?); + let op = fd.obank().get(key).ok_or("stale projection operation")?; + for i in 0..op.num_input() { + if let Some(value) = op.get_in(i) { + let value = fd.vbank().get(value).ok_or("stale projection operand")?; + if let Some(definition) = value.get_def() { + if queued.insert(definition) { + queue.push_back(definition); + } + } + } + } + } + let values = budget.values(fd)?; + let retained_ids: HashSet<_> = operations.iter().map(|op| op.id.as_str()).collect(); + let mut reasons = BTreeMap::new(); + for key in queued.difference(&retained) { + reasons.insert( + operation_id(fd, *key)?, + if members.contains(key) { + "operation-budget" + } else { + "outside-recovered-membership" + }, + ); + } + let missing: HashSet<_> = values + .iter() + .filter_map(|v| v.definition.as_ref()) + .filter(|id| !retained_ids.contains(id.as_str())) + .cloned() + .collect(); + let frontier = reasons + .into_iter() + .filter(|(id, _)| missing.contains(id)) + .map(|(definition, reason)| Frontier { definition, reason }) + .collect(); + Ok(Projection { + schema_version: 1, + roots, + operations, + values, + coverage: Coverage { + scope: + "call-rooted bounded backward SSA definitions; not a CFG or complete use inventory", + operation_budget: MAX_OPERATIONS, + frontier, + issues: budget.issues, + machine_semantic_equivalence: "unknown", + }, + }) +} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_storage.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_storage.rs new file mode 100644 index 00000000..9d64d4c2 --- /dev/null +++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_storage.rs @@ -0,0 +1,30 @@ +//! Shared native storage representation; ABI/frame interpretation is not inferred. +use kuna_base::address::Address; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct Location { + pub space: String, + pub offset: String, +} +#[derive(Debug, Clone, Serialize)] +pub struct Storage { + #[serde(flatten)] + pub location: Location, + pub size_bytes: i32, +} +pub(crate) fn location(address: &Address) -> Option { + address.get_space().map(|space| Location { + space: space.get_name().to_string(), + offset: format!("0x{:x}", address.get_offset()), + }) +} +pub(crate) fn storage(address: &Address, size_bytes: i32) -> Option { + if size_bytes <= 0 { + return None; + } + location(address).map(|location| Storage { + location, + size_bytes, + }) +} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_switches.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_switches.rs new file mode 100644 index 00000000..e8992d5d --- /dev/null +++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_switches.rs @@ -0,0 +1,198 @@ +//! Read-only native jump-table facts; row ordinals are not case values. +use crate::{ + atomic_membership::Membership, + atomic_values::{self, operation_id, ValueFact}, + funcdata::Funcdata, + jumptable::NO_LABEL, +}; +use kuna_base::address::Address; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; + +const MAX_ROWS: usize = 100_000; +const MAX_MAPPINGS: usize = 100_000; +#[derive(Debug, Clone, Serialize)] +pub struct SwitchFacts { + pub schema_version: u32, + pub items: Vec, + pub coverage: Coverage, +} +#[derive(Debug, Clone, Serialize)] +pub struct Coverage { + pub scope: &'static str, + pub blocks_examined: i32, + pub operations_examined: usize, + pub machine_semantic_equivalence: &'static str, +} +#[derive(Debug, Clone, Serialize)] +pub struct SwitchFact { + pub id: String, + pub origin_address: String, + pub block: i32, + pub selector: Option, + pub table: Option, +} +#[derive(Debug, Clone, Serialize)] +pub struct Table { + pub partial: bool, + pub labelled: bool, + pub consumed_mask: String, + pub signed_render_hint: bool, + pub default_raw_index: i32, + pub default_edge: Option, + pub default_is_folded: bool, + pub rows: Vec, +} +#[derive(Debug, Clone, Serialize)] +pub struct Edge { + pub id: String, + pub index: i32, + pub target: i32, +} +#[derive(Debug, Clone, Serialize)] +pub struct Row { + pub index: usize, + pub label: Option