From bf9d3a6e47de1e16a7eefb58bf385dfbe093fdb9 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:09:16 +0000 Subject: [PATCH] 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> --- .../atomic-codemode/aarch64-return-study.md | 91 ++++- .../atomic-codemode/kuna-native-ir.patch | 355 ++++++++++++++++++ .../verify-aarch64-returns.mjs | 183 +++++++++ 3 files changed, 620 insertions(+), 9 deletions(-) create mode 100644 experiments/atomic-codemode/verify-aarch64-returns.mjs diff --git a/experiments/atomic-codemode/aarch64-return-study.md b/experiments/atomic-codemode/aarch64-return-study.md index 5921aa935..f9136dbba 100644 --- a/experiments/atomic-codemode/aarch64-return-study.md +++ b/experiments/atomic-codemode/aarch64-return-study.md @@ -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. diff --git a/experiments/atomic-codemode/kuna-native-ir.patch b/experiments/atomic-codemode/kuna-native-ir.patch index 930e55f2a..e61699087 100644 --- a/experiments/atomic-codemode/kuna-native-ir.patch +++ b/experiments/atomic-codemode/kuna-native-ir.patch @@ -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, 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 diff --git a/experiments/atomic-codemode/verify-aarch64-returns.mjs b/experiments/atomic-codemode/verify-aarch64-returns.mjs new file mode 100644 index 000000000..c973371cd --- /dev/null +++ b/experiments/atomic-codemode/verify-aarch64-returns.mjs @@ -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;