fix(experiment): preserve returning and unknown control-flow paths
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
1 parent
cda262bfc4
commit
bf9d3a6e47
3 files changed
+620
-9
No files matched your search
@@ -50,15 +50,15 @@ path and PT_DYNAMIC fallback using a separately hashed derived copy with only
|
||||
ELF section-header coordinates zeroed. Both produce the corrected entries.
|
||||
Neither input is executed. Both patch application directions pass.
|
||||
|
||||
| Executed check | Family wall s | Family CPU s | Kernel family peak MiB |
|
||||
| --- | --- | --- | --- |
|
||||
| Refreshed REA runtime build | 34.141 | 32.976667 | 3039.0 |
|
||||
| Six new groups against old decoder, expected failure | 123.335 | 122.063494 | 1031.2 |
|
||||
| All 13 PLT tests after correction, including compile | 68.475 | 68.217534 | 887.9 |
|
||||
| Corrected native CLI build | 102.486 | 102.098492 | 625.3 |
|
||||
| Old-engine SDK inventory/references/two captures/full source verification | 14.314 | 14.060418 | 389.6 |
|
||||
| PLT-only engine, equivalent SDK workflow | 14.312 | 14.080485 | 388.5 |
|
||||
| Real CLI, both loader paths | 4.045 | 3.744119 | 101.9 |
|
||||
| Executed check | Family wall s | Family CPU s | Kernel family peak MiB |
|
||||
| ------------------------------------------------------------------------- | ------------- | ------------ | ---------------------- |
|
||||
| Refreshed REA runtime build | 34.141 | 32.976667 | 3039.0 |
|
||||
| Six new groups against old decoder, expected failure | 123.335 | 122.063494 | 1031.2 |
|
||||
| All 13 PLT tests after correction, including compile | 68.475 | 68.217534 | 887.9 |
|
||||
| Corrected native CLI build | 102.486 | 102.098492 | 625.3 |
|
||||
| Old-engine SDK inventory/references/two captures/full source verification | 14.314 | 14.060418 | 389.6 |
|
||||
| PLT-only engine, equivalent SDK workflow | 14.312 | 14.080485 | 388.5 |
|
||||
| Real CLI, both loader paths | 4.045 | 3.744119 | 101.9 |
|
||||
|
||||
All listed scopes verify payload cleanup, observed OOM 0, one CPU and the existing
|
||||
4 GiB hard/3 GiB early-stop memory guard. Peaks include charged file cache and
|
||||
@@ -75,3 +75,76 @@ terminal-call promotion than the existing propagation walk: it observes a
|
||||
terminal call somewhere and absence of RET, without rejecting an escaping tail
|
||||
transfer to possibly returning code. Causality and the structural correction
|
||||
require their own checks; the entry fix alone is not claimed to solve it.
|
||||
|
||||
## No-return ownership checkpoint
|
||||
|
||||
With the PLT-only engine unchanged, disabling only `noreturn_disc` restores the
|
||||
caller call at `0x2854c` and removes the false no-return annotation from the
|
||||
warning call at `0x2841c`. That native CLI counterfactual takes 5.052 s wall,
|
||||
4.813974 s local family CPU, 175.6 MiB kernel family peak, with verified cleanup
|
||||
and observed OOM 0. It isolates the discovered pass; it is not the final fix or
|
||||
the configuration supplied to model evaluators.
|
||||
|
||||
The discovered pass's duplicate promotion scanned a whole function span for
|
||||
any terminal call and no RET. An alternative tail transfer to a returning
|
||||
import therefore failed to protect the caller's continuation. The correction
|
||||
removes that interpretation and shares the propagation pass's conservative
|
||||
reachable-path walk. Known terminal transfers stop a path; returning tails,
|
||||
missing successors, unresolved indirect jumps and escaping branches cannot
|
||||
prove the whole function no-return. A dead, unreachable RET no longer prevents
|
||||
legitimate promotion. Existing call-site threshold and strict-successor options
|
||||
remain, but an absent local successor contributes no new vote: it is compatible
|
||||
with a returning tail call. The inspected
|
||||
[Ghidra analyzer](https://github.com/NationalSecurityAgency/ghidra/blob/master/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/analysis/FindNoReturnFunctionsAnalyzer.java)
|
||||
also skips calls without fall-through when looking for new indicators; this is
|
||||
source comparison, not a real-Ghidra runtime verification.
|
||||
|
||||
The shared entry first checks for any terminal-transfer candidate before
|
||||
allocating a body map or visiting its CFG. That necessary-condition check also
|
||||
applies to the existing propagation consumer. Terminal sets still grow during
|
||||
the bounded fixpoint, so false results are not cached across changing sets.
|
||||
There is no added long-lived graph cache or duplicate adapter interpretation.
|
||||
|
||||
Five new semantic test groups cover the fatal/returning-tail alternative,
|
||||
four different unknown exits, genuine terminal paths with a dead RET, a
|
||||
transitive wrapper fixpoint, and missing versus positively identified
|
||||
successors. Against the old rule, four groups fail and the wrapper group passes
|
||||
(the two pre-existing smoke tests also pass). After the correction, all 39
|
||||
no-return-related tests pass, including those groups. Expected-failure run:
|
||||
68.371 s wall / 68.147478 s CPU / 889.2 MiB peak. Passing run including compile:
|
||||
73.808 s / 72.914925 s / 892.6 MiB. Both have verified cleanup and observed OOM 0.
|
||||
An earlier syntax-error attempt is not counted as a semantic regression run.
|
||||
|
||||
The final overlay SHA-256 is
|
||||
`597b5236c42b214b88da39afe0f2b2b1cd7c5ff82e512e91d6a3249bfef565f1`;
|
||||
the immutable engine SHA-256 is
|
||||
`0969be0c9c3ace9357acce1f802f12e8595bc57445da32937933de543ca77672`.
|
||||
Forward-index and reverse-worktree application checks pass. The public
|
||||
[SDK verifier](verify-aarch64-returns.mjs) now finds the strict call at
|
||||
`0x2854c`, removes the false warning-call annotation, retains the genuine fatal
|
||||
call annotation, verifies canonical import ownership and byte identity, hashes
|
||||
all retained sources, and checks complete C/source fidelity and warm reuse.
|
||||
Warm captures retain identical handles/facts: warning 0.770 ms / 0.000797 CPU s;
|
||||
caller 0.702 ms / 0.000761 CPU s. Cold captures take 2033.6 / 5857.3 ms and
|
||||
2.023671 / 5.835244 CPU s respectively. These are lease cache observations, not
|
||||
OS-cold or model-task measurements.
|
||||
|
||||
| Executed check after no-return correction | Family wall s | Family CPU s | Kernel family peak MiB |
|
||||
| --------------------------------------------------------------------- | ------------- | ------------ | ---------------------- |
|
||||
| Native CLI build | 212.756 | 166.409933 | 629.8 |
|
||||
| New SDK return/source verifier | 13.606 | 13.381336 | 421.9 |
|
||||
| Equivalent earlier SDK inventory/references/two captures/full sources | 15.632 | 15.370816 | 415.7 |
|
||||
| Existing AArch64 xrefs/GOT reads/source regression | 13.309 | 13.018756 | 206.9 |
|
||||
| Complete old x86 function/prototype fidelity through MCP | 9.080 | 8.790274 | 308.5 |
|
||||
| All 87 experimental JS tests | 5.355 | 5.055786 | 83.8 |
|
||||
| Both actual catalogs and converted schemas | 1.936 | 1.712823 | 136.3 |
|
||||
| Both real native ELF loader paths | 4.047 | 3.799690 | 101.1 |
|
||||
|
||||
All these successful scopes verify cleanup and observed OOM 0 under the same
|
||||
resource bounds. Wall/CPU divergence occurred during compilation; attribution
|
||||
between host contention and changed compilation is unmeasured. The equivalent
|
||||
SDK task is slower than the PLT-only 14.312 s / 14.080485 CPU s / 388.5 MiB
|
||||
sample, while retaining the previously missing path. This single sequential
|
||||
pair does not establish a general performance delta. Changed-file JS lint,
|
||||
format and Python compilation checks pass. Broad repository type checking
|
||||
retains the previously disclosed resource-limited gap.
|
||||
@@ -24,6 +24,361 @@ index 157e7bd2..6caa4604 100644
|
||||
"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<u64>, 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<Insn>) -> 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<u64>) -> 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<u64>,
|
||||
- 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<NoReturnFact> {
|
||||
let mut discovered: BTreeMap<u64, NoReturnFact> = 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<u64> = listing
|
||||
.functions()
|
||||
.filter(|(_, f)| f.has_no_return)
|
||||
@@ -272,9 +236,9 @@ fn discover_noreturn(listing: &Listing, strict: bool) -> Vec<NoReturnFact> {
|
||||
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<NoReturnFact> {
|
||||
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<u64, &Insn> = 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<u64>,
|
||||
+) -> 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
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Public SDK regression: canonical imports and the retained post-warning path.
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { AtomicSession } from "./session.mjs";
|
||||
import { familyCounters } from "./family-counters.mjs";
|
||||
import {
|
||||
createVerifierRun,
|
||||
completeVerifierRun,
|
||||
assertVerifierCleanupComplete,
|
||||
} from "../../scripts/lib/verifier-run.mjs";
|
||||
|
||||
const [binary, output] = process.argv.slice(2);
|
||||
assert.ok(
|
||||
binary && output,
|
||||
"Usage: verify-aarch64-returns.mjs ARM64_SSH OUTPUT",
|
||||
);
|
||||
const run = createVerifierRun(),
|
||||
session = new AtomicSession();
|
||||
const observations = [],
|
||||
phases = [],
|
||||
sources = new Map(),
|
||||
failures = [];
|
||||
const digest =
|
||||
"de352e4ace7028d9816fd0ee9d22936bc1b5622c400d5b0b7df660df3426b068";
|
||||
const read = async (method, input) => {
|
||||
const reply = await session.call(method, input);
|
||||
assert.equal(reply.ok, true, JSON.stringify(reply));
|
||||
assert.equal(reply.context.artifact_sha256, digest);
|
||||
for (const value of reply.data.results ?? [reply.data]) {
|
||||
if (Object.hasOwn(value, "ok"))
|
||||
assert.equal(value.ok, true, JSON.stringify(value));
|
||||
if (value.source) sources.set(value.source.evidence_id, value.source);
|
||||
}
|
||||
observations.push({ method, input, data: reply.data });
|
||||
return reply.data;
|
||||
};
|
||||
const measure = async (phase, work) => {
|
||||
const before = await familyCounters(),
|
||||
start = performance.now();
|
||||
const value = await work(),
|
||||
after = await familyCounters();
|
||||
phases.push({
|
||||
phase,
|
||||
elapsed_ms: performance.now() - start,
|
||||
family_cpu_seconds: (after.cpu - before.cpu) / 1e6,
|
||||
cumulative_family_memory_peak_bytes: after.peak,
|
||||
});
|
||||
return value;
|
||||
};
|
||||
let context;
|
||||
try {
|
||||
await session.open(binary);
|
||||
context = session.context;
|
||||
assert.equal(context.artifact_sha256, digest);
|
||||
const functions = await read("functions", {
|
||||
addresses: ["0xbad0", "0xbad4", "0xbdd0", "0xbdd4"],
|
||||
fields: ["name", "aliases", "size"],
|
||||
});
|
||||
assert.deepEqual(
|
||||
functions.items.map((f) => [f.address, f.name]),
|
||||
[
|
||||
["0xbad0", "free"],
|
||||
["0xbdd0", "_exit"],
|
||||
],
|
||||
);
|
||||
const refs = await read("references", {
|
||||
addresses: ["0x25858"],
|
||||
direction: "outgoing",
|
||||
kinds: ["jump"],
|
||||
fields: ["fromFunction", "toFunction"],
|
||||
});
|
||||
assert.equal(refs.results[0].total, 1);
|
||||
const tail = refs.results[0].items[0];
|
||||
assert.equal(tail.to, "0xbad0");
|
||||
assert.equal(tail.toFunction.name, "free");
|
||||
assert.equal(tail.fromFunction.address, "0x256c0");
|
||||
assert.equal(refs.results[0].coverage.runtime_binding, "unknown");
|
||||
const bytes = await read("bytes", {
|
||||
windows: [{ address: "0xbad0", length: 20 }],
|
||||
});
|
||||
assert.equal(
|
||||
bytes.results[0].hex,
|
||||
"5f2403d53008009011ca41f910420e9120021fd6",
|
||||
);
|
||||
const captures = [];
|
||||
for (const entry of ["0x256c0", "0x27848"]) {
|
||||
const capture = await measure(`cold-capture:${entry}`, () =>
|
||||
read("snapshot", { entry }),
|
||||
);
|
||||
const warm = await measure(`warm-same-capture:${entry}`, () =>
|
||||
read("snapshot", { entry }),
|
||||
);
|
||||
assert.deepEqual(warm, capture);
|
||||
const calls = await read("calls", {
|
||||
snapshot: capture.snapshot,
|
||||
origin_addresses:
|
||||
entry === "0x256c0" ? ["0x25858", "0x25884"] : ["0x2841c", "0x2854c"],
|
||||
fields: ["target", "target_name"],
|
||||
});
|
||||
const selected = new Map(calls.items.map((c) => [c.origin_address, c]));
|
||||
if (entry === "0x256c0") {
|
||||
assert.equal(selected.get("0x25858").target_name, "free");
|
||||
assert.equal(selected.get("0x25884").target.offset, "0x579a0");
|
||||
} else {
|
||||
assert.equal(selected.get("0x2841c").target.offset, "0x256c0");
|
||||
assert.equal(selected.get("0x2854c").target.offset, "0x5ae04");
|
||||
}
|
||||
const code = await read("code", {
|
||||
snapshot: capture.snapshot,
|
||||
limit: 2000,
|
||||
});
|
||||
assert.equal(code.next_offset, null);
|
||||
if (entry === "0x27848") {
|
||||
assert.ok(
|
||||
!code.text
|
||||
.split("\n")
|
||||
.some(
|
||||
(line) => line.includes("sub_256c0(") && line.includes("no-return"),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
assert.ok(
|
||||
code.text
|
||||
.split("\n")
|
||||
.some(
|
||||
(line) => line.includes("sub_579a0(") && line.includes("no-return"),
|
||||
),
|
||||
"genuine fatal call stays terminal",
|
||||
);
|
||||
}
|
||||
captures.push({ capture, code });
|
||||
}
|
||||
// Complete sources remain accessible; hashing is outside timed capture phases.
|
||||
for (const source of sources.values()) {
|
||||
let text = "",
|
||||
offset = 0;
|
||||
do {
|
||||
const page = await session.call("evidence", {
|
||||
evidence_id: source.evidence_id,
|
||||
offset,
|
||||
limit: 16384,
|
||||
});
|
||||
assert.equal(page.ok, true);
|
||||
text += page.data.text;
|
||||
offset = page.data.next_offset;
|
||||
} while (offset !== null);
|
||||
assert.equal(Buffer.byteLength(text), source.bytes);
|
||||
assert.equal(
|
||||
createHash("sha256").update(text).digest("hex"),
|
||||
source.sha256,
|
||||
);
|
||||
const captured = captures.find(
|
||||
(c) => c.capture.source.evidence_id === source.evidence_id,
|
||||
);
|
||||
if (captured)
|
||||
assert.equal(JSON.parse(text).functions[0].code, captured.code.text);
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(error.stack ?? String(error));
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
const lifecycle = await completeVerifierRun(run);
|
||||
assertVerifierCleanupComplete(lifecycle.process_lineage);
|
||||
await writeFile(
|
||||
output,
|
||||
JSON.stringify({
|
||||
context,
|
||||
phases,
|
||||
observations,
|
||||
failures,
|
||||
lifecycle,
|
||||
source_digests_verified: failures.length === 0,
|
||||
scope:
|
||||
"Selected static SDK facts and recovered return coverage; not target execution, full analysis completeness, live binding or feasible-path proof.",
|
||||
}),
|
||||
{ flag: "wx" },
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({ output, phases, failures, source_count: sources.size }),
|
||||
);
|
||||
if (failures.length) process.exitCode = 1;
|
||||
Reference in new issue
Block a user