passes: implement control-flow pass (4 schemes + rotation)
FlowPass rebuilds one method per scheme picked from FlowConfig: state-spread (dense tableswitch dispatcher), computed-switch (sparse lookupswitch over a counter local), butterfly (branch inversion), exception-goto (dispatcher under try with a dead catch-all), plus opaque goto-splices inside every method and StackMapTable recomputation afterwards. Now in standard_pipeline (between constants and noise); --no-flow is an honest off-flag. Acceptance is real JVM, not shapes: per-scheme verify, rotation spread, calc-prints-45 execution, paranoid smoke on lorep-1.1.0 (131 methods rebuilt, no new failures over standard). Two verifier rules learned the hard way are now construction invariants: no inserted mark shares a bytecode offset with a foreign one (nop pads), and no frameless gap follows a terminal (dead blocks are opaque-entered and throw). Also fixes a latent frame-emitter bug the pass exposed: wide stack values were written as slot pairs ([Double, Top]) while javac writes one entry ([Double]); HotSpot rejects the pair. Stacks now collapse like locals (emit::stack_vars), with a regression test in classfile/tests/frames.rs.
This commit is contained in:
parent
39178420e4
commit
2172206e83
15 changed files with 1360 additions and 103 deletions
96
crates/noxis-passes/src/flow/dispatch.rs
Normal file
96
crates/noxis-passes/src/flow/dispatch.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! Entry dispatchers: opaque `switch` prologues and opaque guards.
|
||||
//!
|
||||
//! Every emitter here is stack-neutral as a whole: whatever the prologue
|
||||
//! pushes it also consumes before falling through to `body`. That is what
|
||||
//! makes prepending safe at method entry (empty stack) without any dataflow
|
||||
//! analysis — all joins at `body` carry an empty stack, so any computed
|
||||
//! `StackMapTable` stays consistent.
|
||||
|
||||
use noxis_classfile::{Arith, BranchCond, Cond, Const, Insn, Prim, SlotKind};
|
||||
use noxis_core::Rng;
|
||||
|
||||
/// Dense `tableswitch` dispatcher (`state-spread`).
|
||||
///
|
||||
/// The opaque key is computed as `a + b` (two constants, never the key
|
||||
/// itself) and dispatched over `[key-2, key+2]`; only the middle slot reaches
|
||||
/// `body`, the rest fall into a dead block that jumps back to `body`.
|
||||
/// Does not emit `Mark(body)` — the caller places it.
|
||||
pub(super) fn table_dispatch(body: u32, dead: u32, rng: &mut Rng) -> Vec<Insn> {
|
||||
let key = 100 + (rng.next_u64() % 10_000) as i32;
|
||||
let half = (rng.next_u64() as i32) & 0x3FFF_FFFF;
|
||||
let low = key - 2;
|
||||
vec![
|
||||
Insn::Const(Const::Int(half)),
|
||||
Insn::Const(Const::Int(key.wrapping_sub(half))),
|
||||
Insn::Binary {
|
||||
prim: Prim::Int,
|
||||
op: Arith::Add,
|
||||
},
|
||||
Insn::TableSwitch {
|
||||
default: dead,
|
||||
low,
|
||||
high: key + 2,
|
||||
targets: vec![dead, dead, body, dead, dead],
|
||||
},
|
||||
Insn::Mark(dead),
|
||||
Insn::Nop,
|
||||
Insn::Goto(body),
|
||||
]
|
||||
}
|
||||
|
||||
/// Sparse `lookupswitch` dispatcher over a counter local (`computed-switch`).
|
||||
///
|
||||
/// The counter lives in `slot` (a fresh local owned by the caller): the shape
|
||||
/// differs from `table_dispatch` on purpose — a sparse switch over a variable
|
||||
/// instead of a dense range over a stack value. Junk keys never equal `key`.
|
||||
/// Does not emit `Mark(body)` — the caller places it.
|
||||
pub(super) fn lookup_dispatch(body: u32, dead: u32, slot: u16, rng: &mut Rng) -> Vec<Insn> {
|
||||
let key = 100 + (rng.next_u64() % 10_000) as i32;
|
||||
let mut keys = vec![key];
|
||||
while keys.len() < 4 {
|
||||
let candidate = (rng.next_u64() % 20_000) as i32 - 5_000;
|
||||
if !keys.contains(&candidate) {
|
||||
keys.push(candidate);
|
||||
}
|
||||
}
|
||||
let mut pairs: Vec<(i32, u32)> = keys
|
||||
.into_iter()
|
||||
.map(|k| (k, if k == key { body } else { dead }))
|
||||
.collect();
|
||||
// The JVM requires lookupswitch pairs sorted by key.
|
||||
pairs.sort_by_key(|(k, _)| *k);
|
||||
vec![
|
||||
Insn::Const(Const::Int(key)),
|
||||
Insn::Store {
|
||||
slot,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Load {
|
||||
slot,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::LookupSwitch {
|
||||
default: dead,
|
||||
pairs,
|
||||
},
|
||||
Insn::Mark(dead),
|
||||
Insn::Nop,
|
||||
Insn::Goto(body),
|
||||
]
|
||||
}
|
||||
|
||||
/// Always-true opaque guard `(C*C + C) & 1 == 0` with a dead `nop` branch.
|
||||
///
|
||||
/// Stack-neutral: the computed value is consumed by the branch that never
|
||||
/// fires. Ends with `Mark(merge)` — execution continues there.
|
||||
/// Same construction as the constants pass uses; the predicate core is shared
|
||||
/// with the flow splices (`rewire::opaque_compute`).
|
||||
pub(super) fn opaque_guard(dead: u32, merge: u32, rng: &mut Rng) -> Vec<Insn> {
|
||||
let mut guard = super::rewire::opaque_compute(rng);
|
||||
guard.push(Insn::Branch(BranchCond::Zero(Cond::Ne), dead));
|
||||
guard.push(Insn::Goto(merge));
|
||||
guard.push(Insn::Mark(dead));
|
||||
guard.push(Insn::Nop);
|
||||
guard.push(Insn::Mark(merge));
|
||||
guard
|
||||
}
|
||||
270
crates/noxis-passes/src/flow/mod.rs
Normal file
270
crates/noxis-passes/src/flow/mod.rs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
//! Control-flow obfuscation: dispatcher prologues plus CFG rewiring.
|
||||
//!
|
||||
//! One scheme per method (rotation), picked from `FlowConfig::schemes`:
|
||||
//! - `state-spread`: dense `tableswitch` entry dispatcher;
|
||||
//! - `computed-switch`: sparse `lookupswitch` over a counter local;
|
||||
//! - `butterfly`: every branch inverted, stream cut with `goto` splices;
|
||||
//! - `exception-goto`: dispatcher inside `try`, dead catch-all rejoins body.
|
||||
//!
|
||||
//! Every scheme also cuts the stream with a few `goto` splices (intra-method
|
||||
//! mixing) and recomputes `StackMapTable` afterwards, so the only thing a
|
||||
//! scheme may assume is an empty stack at method entry — joins stay consistent
|
||||
//! by construction (see `dispatch`/`rewire` for why each shape is safe).
|
||||
|
||||
mod dispatch;
|
||||
mod rewire;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use dispatch::{lookup_dispatch, opaque_guard, table_dispatch};
|
||||
use noxis_analysis::ClassHierarchy;
|
||||
use noxis_classfile::{Code, Insn, Label, Program, compute_frames};
|
||||
use noxis_core::{Context, CoreError, Pass, PassId, PassReport, Rng};
|
||||
use rewire::{dead_catch, invert_branches, split_blocks};
|
||||
|
||||
/// Control-flow obfuscation pass.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct FlowPass;
|
||||
|
||||
/// Dispatcher scheme (names from `FlowConfig::schemes`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Scheme {
|
||||
StateSpread,
|
||||
ComputedSwitch,
|
||||
Butterfly,
|
||||
ExceptionGoto,
|
||||
}
|
||||
|
||||
impl Scheme {
|
||||
fn parse(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"state-spread" => Some(Scheme::StateSpread),
|
||||
"computed-switch" => Some(Scheme::ComputedSwitch),
|
||||
"butterfly" => Some(Scheme::Butterfly),
|
||||
"exception-goto" => Some(Scheme::ExceptionGoto),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh-label allocator over one method body.
|
||||
pub(crate) struct LabelGen {
|
||||
next: Label,
|
||||
}
|
||||
|
||||
impl LabelGen {
|
||||
fn of(code: &Code) -> Self {
|
||||
let top = code.labels().into_iter().max().unwrap_or(0);
|
||||
Self {
|
||||
next: top.saturating_add(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn next(&mut self) -> Label {
|
||||
let label = self.next;
|
||||
self.next = self.next.saturating_add(1);
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
impl Pass for FlowPass {
|
||||
fn id(&self) -> PassId {
|
||||
PassId::Flow
|
||||
}
|
||||
|
||||
fn apply(&self, ctx: &mut Context, program: &mut Program) -> Result<PassReport, CoreError> {
|
||||
let cfg = ctx.config.flow.clone();
|
||||
let mut schemes = Vec::with_capacity(cfg.schemes.len());
|
||||
for name in &cfg.schemes {
|
||||
schemes.push(
|
||||
Scheme::parse(name)
|
||||
.ok_or_else(|| pass_error(format!("unknown scheme `{name}`")))?,
|
||||
);
|
||||
}
|
||||
if schemes.is_empty() {
|
||||
return Err(pass_error("no control-flow schemes configured"));
|
||||
}
|
||||
let mut rng = ctx.rng("flow");
|
||||
let mut mutated: Vec<(usize, usize)> = Vec::new();
|
||||
let mut per_scheme = [0usize; 4];
|
||||
let mut skipped = 0usize;
|
||||
|
||||
for (ci, class) in program.classes.iter_mut().enumerate() {
|
||||
for (mi, method) in class.methods.iter_mut().enumerate() {
|
||||
let Some(code) = method.code.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if skippable(&method.name, code, cfg.max_insns) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let scheme = schemes[rng.below(schemes.len() as u64) as usize];
|
||||
apply_scheme(code, scheme, &cfg, &mut rng);
|
||||
per_scheme[scheme_index(scheme)] += 1;
|
||||
mutated.push((ci, mi));
|
||||
}
|
||||
}
|
||||
let hierarchy = ClassHierarchy::build(program);
|
||||
for (ci, mi) in &mutated {
|
||||
refresh_frames(program, *ci, *mi, &hierarchy)?;
|
||||
}
|
||||
Ok(report(&per_scheme, mutated.len(), skipped))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a method body is left alone.
|
||||
fn skippable(name: &str, code: &Code, max_insns: usize) -> bool {
|
||||
// The super-call-first shape of a constructor must survive: no prologue
|
||||
// may run before it.
|
||||
if name == "<init>" {
|
||||
return true;
|
||||
}
|
||||
// Frames are never computed for subroutines — keep them byte-identical.
|
||||
if code
|
||||
.insns
|
||||
.iter()
|
||||
.any(|insn| matches!(insn, Insn::Jsr(_) | Insn::Ret(_)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let real = code
|
||||
.insns
|
||||
.iter()
|
||||
.filter(|insn| !matches!(insn, Insn::Mark(_)))
|
||||
.count();
|
||||
if real < 3 {
|
||||
return true;
|
||||
}
|
||||
if code.insns.len() > max_insns {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Rebuild one method body with the given scheme; prologue stays first.
|
||||
fn apply_scheme(code: &mut Code, scheme: Scheme, cfg: &noxis_core::FlowConfig, rng: &mut Rng) {
|
||||
let mut labels = LabelGen::of(code);
|
||||
let body = labels.next();
|
||||
let dead = labels.next();
|
||||
if scheme == Scheme::Butterfly {
|
||||
invert_branches(&mut code.insns, &mut labels, rng, 100);
|
||||
}
|
||||
let real = code
|
||||
.insns
|
||||
.iter()
|
||||
.filter(|insn| !matches!(insn, Insn::Mark(_)))
|
||||
.count();
|
||||
let mut prologue = Vec::new();
|
||||
if cfg.encrypt_block_bodies {
|
||||
let guard_dead = labels.next();
|
||||
let guard_merge = labels.next();
|
||||
prologue.extend(opaque_guard(guard_dead, guard_merge, rng));
|
||||
}
|
||||
match scheme {
|
||||
Scheme::StateSpread => {
|
||||
prologue.extend(table_dispatch(body, dead, rng));
|
||||
prologue.push(Insn::Mark(body));
|
||||
}
|
||||
Scheme::ComputedSwitch if code.max_locals == u16::MAX => {
|
||||
// No free local slot — fall back to the stack-based dispatcher.
|
||||
prologue.extend(table_dispatch(body, dead, rng));
|
||||
prologue.push(Insn::Mark(body));
|
||||
}
|
||||
Scheme::ComputedSwitch => {
|
||||
let slot = code.max_locals;
|
||||
prologue.extend(lookup_dispatch(body, dead, slot, rng));
|
||||
prologue.push(Insn::Mark(body));
|
||||
code.max_locals = code.max_locals.saturating_add(1);
|
||||
}
|
||||
Scheme::Butterfly => {
|
||||
prologue.push(Insn::Mark(body));
|
||||
}
|
||||
Scheme::ExceptionGoto => {
|
||||
let try_start = labels.next();
|
||||
let handler = labels.next();
|
||||
let mut guarded = vec![Insn::Mark(try_start)];
|
||||
guarded.append(&mut prologue);
|
||||
guarded.extend(table_dispatch(body, dead, rng));
|
||||
// No skip-goto after the dead block: the dispatcher's dead
|
||||
// `Goto(body)` already jumps over the handler. A second goto
|
||||
// would sit after a terminal with no frame of its own, and
|
||||
// HotSpot requires a frame at every block start — even an
|
||||
// unreachable one. try_e IS the dead mark for the same reason.
|
||||
let (mut handler_code, entry) = dead_catch(try_start, dead, handler, body);
|
||||
guarded.append(&mut handler_code);
|
||||
guarded.push(Insn::Mark(body));
|
||||
prologue = guarded;
|
||||
code.exception_table.push(entry);
|
||||
}
|
||||
}
|
||||
// A `nop` pads the body mark: the original stream may start with its own
|
||||
// mark (even a handler entry), and two marks on one offset would merge
|
||||
// frames from inconsistent states.
|
||||
prologue.push(Insn::Nop);
|
||||
let cuts = (real / 16).clamp(1, 8);
|
||||
let start = prologue.len();
|
||||
let mut insns = prologue;
|
||||
insns.append(&mut code.insns);
|
||||
split_blocks(&mut insns, &mut labels, rng, start, cuts);
|
||||
code.insns = insns;
|
||||
code.max_stack = code.max_stack.saturating_add(2);
|
||||
}
|
||||
|
||||
fn scheme_index(scheme: Scheme) -> usize {
|
||||
match scheme {
|
||||
Scheme::StateSpread => 0,
|
||||
Scheme::ComputedSwitch => 1,
|
||||
Scheme::Butterfly => 2,
|
||||
Scheme::ExceptionGoto => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute the frames of a touched method.
|
||||
fn refresh_frames(
|
||||
program: &mut Program,
|
||||
ci: usize,
|
||||
mi: usize,
|
||||
hierarchy: &ClassHierarchy,
|
||||
) -> Result<(), CoreError> {
|
||||
let (name, snapshot) = {
|
||||
let class = program
|
||||
.classes
|
||||
.get(ci)
|
||||
.ok_or_else(|| pass_error("class vanished mid-pass"))?;
|
||||
(
|
||||
class.this_class.clone(),
|
||||
class
|
||||
.methods
|
||||
.get(mi)
|
||||
.cloned()
|
||||
.ok_or_else(|| pass_error("method vanished mid-pass"))?,
|
||||
)
|
||||
};
|
||||
let frames =
|
||||
compute_frames(&name, &snapshot, hierarchy).map_err(|e| pass_error(e.to_string()))?;
|
||||
program
|
||||
.classes
|
||||
.get_mut(ci)
|
||||
.and_then(|c| c.methods.get_mut(mi))
|
||||
.and_then(|m| m.code.as_mut())
|
||||
.ok_or_else(|| pass_error("method vanished mid-pass"))?
|
||||
.frames = frames;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn report(per_scheme: &[usize; 4], mutated: usize, skipped: usize) -> PassReport {
|
||||
PassReport {
|
||||
pass: PassId::Flow,
|
||||
transformed: mutated,
|
||||
skipped,
|
||||
notes: vec![format!(
|
||||
"methods rebuilt: {mutated} (state-spread: {}, computed-switch: {}, butterfly: {}, exception-goto: {})",
|
||||
per_scheme[0], per_scheme[1], per_scheme[2], per_scheme[3]
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
fn pass_error(msg: impl Into<String>) -> CoreError {
|
||||
CoreError::pass(PassId::Flow, msg)
|
||||
}
|
||||
230
crates/noxis-passes/src/flow/rewire.rs
Normal file
230
crates/noxis-passes/src/flow/rewire.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
//! CFG rewiring: branch inversion, block splitting, exception routing.
|
||||
//!
|
||||
//! All rewrites preserve semantics without dataflow analysis:
|
||||
//! - inverting a branch negates its condition and swaps target/fallthrough;
|
||||
//! - a `goto` + dead `nop` splice is stack-neutral at any cut point, because
|
||||
//! both joins carry the same stack the original stream had;
|
||||
//! - the dead catch-all rejoins `body` with an empty stack after popping the
|
||||
//! exception, so it merges with the dispatcher's empty-stack edges.
|
||||
|
||||
use noxis_classfile::{BranchCond, Cond, ExceptionHandler, Insn};
|
||||
use noxis_core::Rng;
|
||||
|
||||
/// Logical negation of a branch condition.
|
||||
pub(super) fn negate(cond: BranchCond) -> BranchCond {
|
||||
match cond {
|
||||
BranchCond::Zero(c) => BranchCond::Zero(negate_cond(c)),
|
||||
BranchCond::Int(c) => BranchCond::Int(negate_cond(c)),
|
||||
BranchCond::Ref { eq } => BranchCond::Ref { eq: !eq },
|
||||
BranchCond::Null { is_null } => BranchCond::Null { is_null: !is_null },
|
||||
}
|
||||
}
|
||||
|
||||
fn negate_cond(cond: Cond) -> Cond {
|
||||
match cond {
|
||||
Cond::Eq => Cond::Ne,
|
||||
Cond::Ne => Cond::Eq,
|
||||
Cond::Lt => Cond::Ge,
|
||||
Cond::Le => Cond::Gt,
|
||||
Cond::Gt => Cond::Le,
|
||||
Cond::Ge => Cond::Lt,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn `if-cond T` into `if-not SKIP; goto T; SKIP:` (`butterfly`).
|
||||
///
|
||||
/// `rate` is a 0–100 percentage applied per branch. Semantics are unchanged:
|
||||
/// the inverted branch skips a `goto` to the original target, so the taken /
|
||||
/// not-taken destinations are exactly the old ones. A `nop` pads the fresh
|
||||
/// mark: whatever follows (an original mark, even a handler entry with its
|
||||
/// own stack) must not share a bytecode offset with it, or their frames
|
||||
/// would merge from inconsistent states.
|
||||
pub(super) fn invert_branches(
|
||||
insns: &mut Vec<Insn>,
|
||||
fresh: &mut crate::flow::LabelGen,
|
||||
rng: &mut Rng,
|
||||
rate: u64,
|
||||
) {
|
||||
let mut index = 0;
|
||||
while index < insns.len() {
|
||||
let (cond, target) = match &insns[index] {
|
||||
Insn::Branch(cond, target) => (*cond, *target),
|
||||
_ => {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if rng.below(100) >= rate {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let skip = fresh.next();
|
||||
insns.splice(
|
||||
index..=index,
|
||||
[
|
||||
Insn::Branch(negate(cond), skip),
|
||||
Insn::Goto(target),
|
||||
Insn::Mark(skip),
|
||||
Insn::Nop,
|
||||
],
|
||||
);
|
||||
index += 4;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut the stream with opaque-entered splices.
|
||||
///
|
||||
/// Each splice computes an always-false opaque predicate `(C*C + C) & 1 != 0`
|
||||
/// and branches on it: the fallthrough continues the stream, the dead block
|
||||
/// throws instead of rejoining. Throwing (rather than jumping back) keeps the
|
||||
/// splice valid at *any* stack depth — `athrow` has no successor, so there is
|
||||
/// nothing to merge — and the dead block still owns a frame through its entry
|
||||
/// branch, which HotSpot requires even for code that never executes.
|
||||
///
|
||||
/// Cuts land at or after `start` (the entry prologue must stay first) and
|
||||
/// only at reachable positions: a splice in an unreachable gap would leave
|
||||
/// its `into` mark without a computed state, and the verifier rejects jump
|
||||
/// targets without frames.
|
||||
///
|
||||
/// A cut is also pushed past a run of consecutive marks. Inserting *before* a
|
||||
/// targeted mark would reroute its incoming branch edges over the splice,
|
||||
/// orphaning the splice itself: the mark must keep its edges, so the splice
|
||||
/// goes after it and inherits its reachability through fallthrough.
|
||||
pub(super) fn split_blocks(
|
||||
insns: &mut Vec<Insn>,
|
||||
fresh: &mut crate::flow::LabelGen,
|
||||
rng: &mut Rng,
|
||||
start: usize,
|
||||
count: usize,
|
||||
) {
|
||||
let live = reachable(insns);
|
||||
let mut cuts = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let allowed: Vec<usize> = (start..insns.len())
|
||||
.filter(|c| live.get(*c).copied().unwrap_or(false))
|
||||
.collect();
|
||||
if allowed.is_empty() {
|
||||
break;
|
||||
}
|
||||
let mut cut = allowed[rng.below(allowed.len() as u64) as usize];
|
||||
while cut < insns.len() && matches!(insns[cut], Insn::Mark(_)) {
|
||||
cut += 1;
|
||||
}
|
||||
// A mark run at the very end leaves no stream to cut into.
|
||||
if cut < insns.len() {
|
||||
cuts.push(cut);
|
||||
}
|
||||
}
|
||||
cuts.sort_unstable();
|
||||
cuts.dedup();
|
||||
for cut in cuts.into_iter().rev() {
|
||||
let into = fresh.next();
|
||||
let dead = fresh.next();
|
||||
let mut splice = opaque_compute(rng);
|
||||
splice.push(Insn::Branch(BranchCond::Zero(Cond::Ne), dead));
|
||||
splice.push(Insn::Goto(into));
|
||||
splice.push(Insn::Mark(dead));
|
||||
splice.push(Insn::Nop);
|
||||
splice.push(Insn::AconstNull);
|
||||
splice.push(Insn::AThrow);
|
||||
splice.push(Insn::Mark(into));
|
||||
insns.splice(cut..cut, splice);
|
||||
}
|
||||
}
|
||||
|
||||
/// The opaque predicate core: computes `(C*C + C) & 1` (always 0) on top of
|
||||
/// the stack without disturbing what is below it.
|
||||
pub(super) fn opaque_compute(rng: &mut Rng) -> Vec<Insn> {
|
||||
use noxis_classfile::{Arith, Const, Prim};
|
||||
|
||||
let constant = rng.next_u64() as i32;
|
||||
vec![
|
||||
Insn::Const(Const::Int(constant)),
|
||||
Insn::Dup,
|
||||
Insn::Binary {
|
||||
prim: Prim::Int,
|
||||
op: Arith::Mul,
|
||||
},
|
||||
Insn::Const(Const::Int(constant)),
|
||||
Insn::Binary {
|
||||
prim: Prim::Int,
|
||||
op: Arith::Add,
|
||||
},
|
||||
Insn::Const(Const::Int(1)),
|
||||
Insn::Binary {
|
||||
prim: Prim::Int,
|
||||
op: Arith::And,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Vec positions reachable from method entry via fallthrough and branch
|
||||
/// targets. Marks are transparent: control flows through them to the next
|
||||
/// instruction. Deliberately exception-oblivious (handler-only code reads as
|
||||
/// unreachable): cuts there are skipped rather than risked.
|
||||
pub(super) fn reachable(insns: &[Insn]) -> Vec<bool> {
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
let mut at_label: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
for (index, insn) in insns.iter().enumerate() {
|
||||
if let Insn::Mark(label) = insn {
|
||||
at_label.entry(*label).or_insert(index);
|
||||
}
|
||||
}
|
||||
let resolve = |label: u32| at_label.get(&label).copied();
|
||||
let terminal = |insn: &Insn| {
|
||||
matches!(
|
||||
insn,
|
||||
Insn::Goto(_)
|
||||
| Insn::Jsr(_)
|
||||
| Insn::Ret(_)
|
||||
| Insn::TableSwitch { .. }
|
||||
| Insn::LookupSwitch { .. }
|
||||
| Insn::Return(_)
|
||||
| Insn::AReturn
|
||||
| Insn::AThrow
|
||||
)
|
||||
};
|
||||
let mut live = vec![false; insns.len()];
|
||||
let mut queue = VecDeque::new();
|
||||
if !live.is_empty() {
|
||||
queue.push_back(0);
|
||||
}
|
||||
while let Some(index) = queue.pop_front() {
|
||||
if live[index] {
|
||||
continue;
|
||||
}
|
||||
live[index] = true;
|
||||
let insn = &insns[index];
|
||||
if !terminal(insn) && index + 1 < insns.len() {
|
||||
queue.push_back(index + 1);
|
||||
}
|
||||
for target in insn.targets() {
|
||||
if let Some(resolved) = resolve(target) {
|
||||
queue.push_back(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
live
|
||||
}
|
||||
|
||||
/// Dead catch-all over `try_start..try_end`, rejoining `body`.
|
||||
///
|
||||
/// The covered prologue cannot throw (constants, switches, gotos), so the
|
||||
/// handler never fires; if it ever did, popping the exception and jumping to
|
||||
/// `body` behaves exactly like the fallthrough path.
|
||||
pub(super) fn dead_catch(
|
||||
try_start: u32,
|
||||
try_end: u32,
|
||||
handler: u32,
|
||||
body: u32,
|
||||
) -> (Vec<Insn>, ExceptionHandler) {
|
||||
let code = vec![Insn::Mark(handler), Insn::Pop, Insn::Nop, Insn::Goto(body)];
|
||||
let entry = ExceptionHandler {
|
||||
start: try_start,
|
||||
end: try_end,
|
||||
handler,
|
||||
catch_type: None,
|
||||
};
|
||||
(code, entry)
|
||||
}
|
||||
264
crates/noxis-passes/src/flow/tests.rs
Normal file
264
crates/noxis-passes/src/flow/tests.rs
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
//! Unit tests for the control-flow pass: shapes, skips, determinism.
|
||||
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use noxis_classfile::{
|
||||
Arith, BranchCond, ClassBuilder, Code, Cond, Const, Insn, Prim, Program, SlotKind, Version,
|
||||
};
|
||||
use noxis_core::{Config, Context, FlowConfig, Pass, PassId};
|
||||
|
||||
use super::{FlowPass, LabelGen};
|
||||
|
||||
/// A small branchy method: `acc=0; for(i=0;i<10;i++) acc+=i; return acc`.
|
||||
fn branchy_code() -> Code {
|
||||
let (head, end) = (100, 101);
|
||||
Code {
|
||||
max_stack: 2,
|
||||
max_locals: 2,
|
||||
insns: vec![
|
||||
Insn::Const(Const::Int(0)),
|
||||
Insn::Store {
|
||||
slot: 0,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Const(Const::Int(0)),
|
||||
Insn::Store {
|
||||
slot: 1,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Mark(head),
|
||||
Insn::Load {
|
||||
slot: 1,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Const(Const::Int(10)),
|
||||
Insn::Branch(BranchCond::Int(Cond::Ge), end),
|
||||
Insn::Load {
|
||||
slot: 0,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Load {
|
||||
slot: 1,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Binary {
|
||||
prim: Prim::Int,
|
||||
op: Arith::Add,
|
||||
},
|
||||
Insn::Store {
|
||||
slot: 0,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Iinc { index: 1, delta: 1 },
|
||||
Insn::Goto(head),
|
||||
Insn::Mark(end),
|
||||
Insn::Load {
|
||||
slot: 0,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Return(Some(Prim::Int)),
|
||||
],
|
||||
exception_table: Vec::new(),
|
||||
frames: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn program_with(methods: Vec<(String, String, Code)>) -> Program {
|
||||
let mut builder = ClassBuilder::new("demo/Flow", Some("java/lang/Object"));
|
||||
for (name, descriptor, code) in methods {
|
||||
builder = builder.method_with_code(0x0009, name, descriptor, code);
|
||||
}
|
||||
Program {
|
||||
classes: vec![builder.version(Version::JAVA_21).build()],
|
||||
resources: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(program: &mut Program, schemes: &[&str]) -> noxis_core::PassReport {
|
||||
let config = Config {
|
||||
seed: Some(42),
|
||||
flow: FlowConfig {
|
||||
schemes: schemes.iter().map(|s| s.to_string()).collect(),
|
||||
max_insns: 4000,
|
||||
encrypt_block_bodies: false,
|
||||
},
|
||||
..Config::default()
|
||||
};
|
||||
let mut ctx = Context::new(config);
|
||||
FlowPass.apply(&mut ctx, program).expect("flow pass")
|
||||
}
|
||||
|
||||
fn code_of(program: &Program, name: &str) -> Code {
|
||||
program.classes[0]
|
||||
.method(name, "()I")
|
||||
.expect("method")
|
||||
.code
|
||||
.clone()
|
||||
.expect("code")
|
||||
}
|
||||
|
||||
fn has_switch(insns: &[Insn]) -> bool {
|
||||
insns
|
||||
.iter()
|
||||
.any(|i| matches!(i, Insn::TableSwitch { .. } | Insn::LookupSwitch { .. }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_spread_emits_table_dispatch() {
|
||||
let mut program = program_with(vec![(
|
||||
"calc".to_string(),
|
||||
"()I".to_string(),
|
||||
branchy_code(),
|
||||
)]);
|
||||
let before = code_of(&program, "calc").insns.len();
|
||||
let report = run(&mut program, &["state-spread"]);
|
||||
let code = code_of(&program, "calc");
|
||||
assert!(
|
||||
code.insns
|
||||
.iter()
|
||||
.any(|i| matches!(i, Insn::TableSwitch { .. })),
|
||||
"no tableswitch in {code:?}"
|
||||
);
|
||||
assert!(code.insns.len() > before, "stream did not grow");
|
||||
assert!(!code.frames.is_empty(), "frames were not recomputed");
|
||||
assert_eq!(report.pass, PassId::Flow);
|
||||
assert_eq!(report.transformed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computed_switch_uses_lookup_and_fresh_local() {
|
||||
let mut program = program_with(vec![(
|
||||
"calc".to_string(),
|
||||
"()I".to_string(),
|
||||
branchy_code(),
|
||||
)]);
|
||||
run(&mut program, &["computed-switch"]);
|
||||
let code = code_of(&program, "calc");
|
||||
let pairs = code.insns.iter().find_map(|i| match i {
|
||||
Insn::LookupSwitch { pairs, .. } => Some(pairs.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let pairs = pairs.expect("no lookupswitch");
|
||||
let mut sorted = pairs.clone();
|
||||
sorted.sort_by_key(|(k, _)| *k);
|
||||
assert_eq!(pairs, sorted, "lookupswitch pairs must be sorted");
|
||||
assert_eq!(code.max_locals, 3, "counter local was not allocated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn butterfly_inverts_without_switch() {
|
||||
let mut program = program_with(vec![(
|
||||
"calc".to_string(),
|
||||
"()I".to_string(),
|
||||
branchy_code(),
|
||||
)]);
|
||||
run(&mut program, &["butterfly"]);
|
||||
let code = code_of(&program, "calc");
|
||||
assert!(!has_switch(&code.insns), "butterfly must not dispatch");
|
||||
let gotos = code
|
||||
.insns
|
||||
.iter()
|
||||
.filter(|i| matches!(i, Insn::Goto(_)))
|
||||
.count();
|
||||
assert!(
|
||||
gotos >= 3,
|
||||
"expected inverted branches + splits, got {gotos}"
|
||||
);
|
||||
let labels = code.labels();
|
||||
for insn in &code.insns {
|
||||
for target in insn.targets() {
|
||||
assert!(labels.contains(&target), "dangling label {target}");
|
||||
}
|
||||
}
|
||||
// Every branch target must also be reachable: an orphaned splice leaves
|
||||
// its marks without computed frames and HotSpot rejects the class.
|
||||
let live = super::rewire::reachable(&code.insns);
|
||||
let mut at_mark = std::collections::BTreeMap::new();
|
||||
for (index, insn) in code.insns.iter().enumerate() {
|
||||
if let Insn::Mark(label) = insn {
|
||||
at_mark.entry(*label).or_insert(index);
|
||||
}
|
||||
}
|
||||
for insn in &code.insns {
|
||||
for target in insn.targets() {
|
||||
let index = at_mark.get(&target).expect("resolved above");
|
||||
assert!(live[*index], "unreachable branch target {target}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exception_goto_adds_catch_all() {
|
||||
let mut program = program_with(vec![(
|
||||
"calc".to_string(),
|
||||
"()I".to_string(),
|
||||
branchy_code(),
|
||||
)]);
|
||||
run(&mut program, &["exception-goto"]);
|
||||
let code = code_of(&program, "calc");
|
||||
assert_eq!(code.exception_table.len(), 1);
|
||||
assert_eq!(code.exception_table[0].catch_type, None);
|
||||
assert!(
|
||||
code.insns
|
||||
.iter()
|
||||
.any(|i| matches!(i, Insn::TableSwitch { .. })),
|
||||
"dispatcher missing inside try"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_init_and_tiny_methods() {
|
||||
let tiny = Code {
|
||||
max_stack: 0,
|
||||
max_locals: 0,
|
||||
insns: vec![Insn::Return(None)],
|
||||
exception_table: Vec::new(),
|
||||
frames: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
};
|
||||
let mut program = program_with(vec![
|
||||
("<init>".to_string(), "()V".to_string(), tiny.clone()),
|
||||
("run".to_string(), "()V".to_string(), tiny),
|
||||
("calc".to_string(), "()I".to_string(), branchy_code()),
|
||||
]);
|
||||
let report = run(
|
||||
&mut program,
|
||||
&[
|
||||
"state-spread",
|
||||
"computed-switch",
|
||||
"butterfly",
|
||||
"exception-goto",
|
||||
],
|
||||
);
|
||||
assert_eq!(report.transformed, 1);
|
||||
assert_eq!(report.skipped, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_scheme_is_rejected() {
|
||||
let mut program = program_with(vec![(
|
||||
"calc".to_string(),
|
||||
"()I".to_string(),
|
||||
branchy_code(),
|
||||
)]);
|
||||
let config = Config {
|
||||
seed: Some(1),
|
||||
flow: FlowConfig {
|
||||
schemes: vec!["death-star".to_string()],
|
||||
max_insns: 4000,
|
||||
encrypt_block_bodies: false,
|
||||
},
|
||||
..Config::default()
|
||||
};
|
||||
let mut ctx = Context::new(config);
|
||||
assert!(FlowPass.apply(&mut ctx, &mut program).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_are_allocated_above_existing_ones() {
|
||||
let code = branchy_code();
|
||||
let top = code.labels().into_iter().max().unwrap_or(0);
|
||||
let mut labels = LabelGen::of(&code);
|
||||
assert!(labels.next() > top);
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
//! # noxis-passes
|
||||
//!
|
||||
//! Трансформации байткода. Сборка конвейера из пассов (`standard_pipeline`)
|
||||
//! живёт здесь же: ядро (`noxis-core`) знает только контракт [`Pass`].
|
||||
//! Bytecode transformations. The pipeline assembly (`standard_pipeline`)
|
||||
//! lives here as well: the core (`noxis-core`) only knows the [`Pass`] contract.
|
||||
|
||||
pub mod constants;
|
||||
pub mod debug;
|
||||
pub mod flow;
|
||||
pub mod namegen;
|
||||
pub mod noise;
|
||||
pub mod rename;
|
||||
|
|
@ -15,24 +16,22 @@ use noxis_core::Pass;
|
|||
|
||||
pub use constants::ConstantsPass;
|
||||
pub use debug::DebugInfoPass;
|
||||
pub use flow::FlowPass;
|
||||
pub use namegen::NameGenerator;
|
||||
pub use noise::NoisePass;
|
||||
pub use rename::RenamePass;
|
||||
pub use resources::ResourcesPass;
|
||||
pub use strings::StringsPass;
|
||||
|
||||
/// Стандартный конвейер в порядке [`docs/architecture.md`](../../docs/architecture.md):
|
||||
/// Standard pipeline in [`docs/architecture.md`](../../docs/architecture.md) order:
|
||||
/// `rename → resources → strings → constants → flow → noise → debug`.
|
||||
///
|
||||
/// Нереализованные пассы просто не добавлены — порядок уже соблюдён,
|
||||
/// поэтому каждый следующий пасс вставляется на своё место, а не
|
||||
/// дописывается в конец.
|
||||
pub fn standard_pipeline() -> noxis_core::Pipeline {
|
||||
noxis_core::Pipeline::new()
|
||||
.push(Box::new(RenamePass) as Box<dyn Pass>)
|
||||
.push(Box::new(ResourcesPass) as Box<dyn Pass>)
|
||||
.push(Box::new(StringsPass) as Box<dyn Pass>)
|
||||
.push(Box::new(ConstantsPass) as Box<dyn Pass>)
|
||||
.push(Box::new(FlowPass) as Box<dyn Pass>)
|
||||
.push(Box::new(NoisePass) as Box<dyn Pass>)
|
||||
.push(Box::new(DebugInfoPass) as Box<dyn Pass>)
|
||||
}
|
||||
|
|
@ -70,6 +69,7 @@ mod tests {
|
|||
fn standard_pipeline_runs_end_to_end_in_order() {
|
||||
let mut ctx = Context::new(Config {
|
||||
seed: Some(1),
|
||||
enable: vec![PassId::Flow],
|
||||
..Config::default()
|
||||
});
|
||||
let mut program = sample();
|
||||
|
|
@ -86,6 +86,7 @@ mod tests {
|
|||
PassId::Resources,
|
||||
PassId::Strings,
|
||||
PassId::Constants,
|
||||
PassId::Flow,
|
||||
PassId::Noise,
|
||||
PassId::DebugInfo
|
||||
]
|
||||
|
|
|
|||
263
crates/noxis-passes/tests/flow_accept.rs
Normal file
263
crates/noxis-passes/tests/flow_accept.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
//! Flow acceptance: scheme rotation plus a real-JVM execution check.
|
||||
//!
|
||||
//! rotation: 30 methods through the full scheme set must not collapse onto
|
||||
//! one shape. execution: a branchy `calc` still prints 45 after the pass
|
||||
//! under `java -Xverify:all` (graceful skip without `java`).
|
||||
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use noxis_classfile::{
|
||||
Arith, BranchCond, ClassBuilder, Code, Cond, Const, Insn, InvokeKind, Prim, Program, SlotKind,
|
||||
Version, format,
|
||||
};
|
||||
use noxis_core::{Config, Context, FlowConfig, Pass};
|
||||
use noxis_passes::FlowPass;
|
||||
|
||||
const ALL_SCHEMES: [&str; 4] = [
|
||||
"state-spread",
|
||||
"computed-switch",
|
||||
"butterfly",
|
||||
"exception-goto",
|
||||
];
|
||||
|
||||
/// A small branchy method: `acc=0; for(i=0;i<10;i++) acc+=i; return acc`.
|
||||
fn branchy_code() -> Code {
|
||||
let (head, end) = (100, 101);
|
||||
Code {
|
||||
max_stack: 2,
|
||||
max_locals: 2,
|
||||
insns: vec![
|
||||
Insn::Const(Const::Int(0)),
|
||||
Insn::Store {
|
||||
slot: 0,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Const(Const::Int(0)),
|
||||
Insn::Store {
|
||||
slot: 1,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Mark(head),
|
||||
Insn::Load {
|
||||
slot: 1,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Const(Const::Int(10)),
|
||||
Insn::Branch(BranchCond::Int(Cond::Ge), end),
|
||||
Insn::Load {
|
||||
slot: 0,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Load {
|
||||
slot: 1,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Binary {
|
||||
prim: Prim::Int,
|
||||
op: Arith::Add,
|
||||
},
|
||||
Insn::Store {
|
||||
slot: 0,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Iinc { index: 1, delta: 1 },
|
||||
Insn::Goto(head),
|
||||
Insn::Mark(end),
|
||||
Insn::Load {
|
||||
slot: 0,
|
||||
kind: SlotKind::Int,
|
||||
},
|
||||
Insn::Return(Some(Prim::Int)),
|
||||
],
|
||||
exception_table: Vec::new(),
|
||||
frames: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn program_with(methods: Vec<(String, String, Code)>) -> Program {
|
||||
let mut builder = ClassBuilder::new("demo/Flow", Some("java/lang/Object"));
|
||||
for (name, descriptor, code) in methods {
|
||||
builder = builder.method_with_code(0x0009, name, descriptor, code);
|
||||
}
|
||||
Program {
|
||||
classes: vec![builder.version(Version::JAVA_21).build()],
|
||||
resources: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(program: &mut Program, schemes: &[&str]) {
|
||||
let config = Config {
|
||||
seed: Some(42),
|
||||
flow: FlowConfig {
|
||||
schemes: schemes.iter().map(|s| s.to_string()).collect(),
|
||||
max_insns: 4000,
|
||||
encrypt_block_bodies: false,
|
||||
},
|
||||
..Config::default()
|
||||
};
|
||||
let mut ctx = Context::new(config);
|
||||
FlowPass.apply(&mut ctx, program).expect("flow pass");
|
||||
}
|
||||
|
||||
fn code_of(program: &Program, name: &str) -> Code {
|
||||
program.classes[0]
|
||||
.method(name, "()I")
|
||||
.expect("method")
|
||||
.code
|
||||
.clone()
|
||||
.expect("code")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_spreads_schemes_across_methods() {
|
||||
let methods: Vec<(String, String, Code)> = (0..30)
|
||||
.map(|i| (format!("m{i}"), "()I".to_string(), branchy_code()))
|
||||
.collect();
|
||||
let mut program = program_with(methods);
|
||||
run(&mut program, &ALL_SCHEMES);
|
||||
let mut shapes = [false; 4];
|
||||
for method in &program.classes[0].methods {
|
||||
let code = method.code.as_ref().expect("code");
|
||||
if code
|
||||
.insns
|
||||
.iter()
|
||||
.any(|i| matches!(i, Insn::TableSwitch { .. }))
|
||||
{
|
||||
shapes[0] = true;
|
||||
}
|
||||
if code
|
||||
.insns
|
||||
.iter()
|
||||
.any(|i| matches!(i, Insn::LookupSwitch { .. }))
|
||||
{
|
||||
shapes[1] = true;
|
||||
}
|
||||
if !code.exception_table.is_empty() {
|
||||
shapes[3] = true;
|
||||
}
|
||||
}
|
||||
let distinct = shapes.iter().filter(|s| **s).count();
|
||||
assert!(distinct >= 2, "rotation did not spread: {shapes:?}");
|
||||
}
|
||||
|
||||
/// Every split splice must be verifier-safe: the dead block is entered
|
||||
/// through an opaque branch (so it owns a frame) and ends with `athrow`
|
||||
/// (so it joins nothing). A dead `nop` with no incoming edge and no frame
|
||||
/// is rejected by HotSpot (`Expecting a stack map frame`).
|
||||
#[test]
|
||||
fn splits_are_opaque_entered_and_throw() {
|
||||
let mut program = program_with(vec![(
|
||||
"calc".to_string(),
|
||||
"()I".to_string(),
|
||||
branchy_code(),
|
||||
)]);
|
||||
run(&mut program, &["butterfly"]);
|
||||
let code = code_of(&program, "calc");
|
||||
let insns = &code.insns;
|
||||
let throws = insns.iter().filter(|i| matches!(i, Insn::AThrow)).count();
|
||||
assert!(throws >= 1, "splits must end dead blocks with athrow");
|
||||
for (index, insn) in insns.iter().enumerate() {
|
||||
if !matches!(insn, Insn::AThrow) {
|
||||
continue;
|
||||
}
|
||||
let head = &insns[..index];
|
||||
assert!(
|
||||
matches!(head.last(), Some(Insn::AconstNull)),
|
||||
"athrow without a null on top"
|
||||
);
|
||||
let dead = head
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|i| match i {
|
||||
Insn::Mark(label) => Some(*label),
|
||||
_ => None,
|
||||
})
|
||||
.expect("dead block without a mark");
|
||||
assert!(
|
||||
insns
|
||||
.iter()
|
||||
.any(|i| matches!(i, Insn::Branch(_, target) if *target == dead)),
|
||||
"dead mark {dead} has no opaque entry branch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `calc` returns the sum 0+..+9 = 45; `main` prints it. After the pass the
|
||||
/// class must still verify *and* print 45 under a real JVM.
|
||||
#[test]
|
||||
fn flow_preserves_semantics_under_java() {
|
||||
if std::process::Command::new("java")
|
||||
.arg("-version")
|
||||
.output()
|
||||
.is_err()
|
||||
{
|
||||
eprintln!("SKIP: no java");
|
||||
return;
|
||||
}
|
||||
let main = Code {
|
||||
max_stack: 2,
|
||||
max_locals: 1,
|
||||
insns: vec![
|
||||
Insn::GetStatic {
|
||||
owner: "java/lang/System".to_string(),
|
||||
name: "out".to_string(),
|
||||
descriptor: "Ljava/io/PrintStream;".to_string(),
|
||||
},
|
||||
Insn::Invoke {
|
||||
kind: InvokeKind::Static,
|
||||
owner: "demo/Flow".to_string(),
|
||||
name: "calc".to_string(),
|
||||
descriptor: "()I".to_string(),
|
||||
itf: false,
|
||||
},
|
||||
Insn::Invoke {
|
||||
kind: InvokeKind::Virtual,
|
||||
owner: "java/io/PrintStream".to_string(),
|
||||
name: "println".to_string(),
|
||||
descriptor: "(I)V".to_string(),
|
||||
itf: false,
|
||||
},
|
||||
Insn::Return(None),
|
||||
],
|
||||
exception_table: Vec::new(),
|
||||
frames: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
};
|
||||
let mut program = program_with(vec![
|
||||
("calc".to_string(), "()I".to_string(), branchy_code()),
|
||||
(
|
||||
"main".to_string(),
|
||||
"([Ljava/lang/String;)V".to_string(),
|
||||
main,
|
||||
),
|
||||
]);
|
||||
run(&mut program, &ALL_SCHEMES);
|
||||
let dir = std::env::temp_dir().join(format!("noxis-flow-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
for class in &program.classes {
|
||||
let bytes = format::write(class).expect("write");
|
||||
let mut path = dir.clone();
|
||||
for part in class.this_class.split('/') {
|
||||
path.push(part);
|
||||
}
|
||||
path.set_extension("class");
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
|
||||
std::fs::write(&path, bytes).expect("write class");
|
||||
}
|
||||
let output = std::process::Command::new("java")
|
||||
.arg("-Xverify:all")
|
||||
.arg("-cp")
|
||||
.arg(&dir)
|
||||
.arg("demo.Flow")
|
||||
.output()
|
||||
.expect("run java");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"java failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(stdout.trim(), "45", "semantics changed: {stdout:?}");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue