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.
263 lines
8.1 KiB
Rust
263 lines
8.1 KiB
Rust
//! 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:?}");
|
|
}
|