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:
Loki 2026-10-09 11:11:55 +02:00
parent 39178420e4
commit 2172206e83
Signed by: boba
GPG key ID: 253067914055423B
15 changed files with 1360 additions and 103 deletions

View 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
}

View 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)
}

View 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)
}

View 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);
}

View file

@ -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
]

View 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:?}");
}