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

@ -1,59 +1,56 @@
//! Команда `obfuscate`: jar → конвейер → jar + маппинги + отчёт.
//! The `obfuscate` command: jar → pipeline → jar + mappings + report.
use std::path::PathBuf;
use anyhow::{Context as _, Result};
use noxis_core::{Config, Context, PassId};
/// Аргументы обфускации.
/// Obfuscation arguments.
#[derive(Debug, clap::Args)]
pub struct Args {
/// Входной jar.
/// Input jar.
pub input: PathBuf,
/// Выходной jar.
/// Output jar.
#[arg(short, long)]
pub output: PathBuf,
/// TOML-конфиг (без него — профиль Standard).
/// TOML config (without it — the Standard profile).
#[arg(short, long)]
pub config: Option<PathBuf>,
/// Мастер-сид (детерминированность по умолчанию — из конфига).
/// Master seed (deterministic by default — taken from the config).
#[arg(long)]
pub seed: Option<u64>,
/// Куда положить JSON-отчёт (по умолчанию рядом с выходом).
/// Where to write the JSON report (next to the output by default).
#[arg(long)]
pub report: Option<PathBuf>,
/// Выключить пасс переименования.
/// Disable the rename pass.
#[arg(long)]
pub no_rename: bool,
/// Выключить пасс ресурсов.
/// Disable the resources pass.
#[arg(long)]
pub no_resources: bool,
/// Выключить пасс строк.
/// Disable the strings pass.
#[arg(long)]
pub no_strings: bool,
/// Выключить пасс констант.
/// Disable the constants pass.
#[arg(long)]
pub no_constants: bool,
/// Выключить пасс шума.
#[arg(long)]
pub no_noise: bool,
/// Выключить пасс отладочной информации.
#[arg(long)]
pub no_debug_info: bool,
/// Выключить пасс control-flow (ещё не реализован — честная ошибка).
/// Disable the control-flow pass.
#[arg(long)]
pub no_flow: bool,
/// Disable the noise pass.
#[arg(long)]
pub no_noise: bool,
/// Disable the debug-info pass.
#[arg(long)]
pub no_debug_info: bool,
}
/// Прогнать обфускацию.
/// Run the obfuscation.
pub fn run(args: Args) -> Result<()> {
if args.no_flow {
anyhow::bail!("пасс flow ещё не реализован (см. todo.md §3.4)");
}
let mut config = match &args.config {
Some(path) => {
let text = std::fs::read_to_string(path)
.with_context(|| format!("конфиг {}", path.display()))?;
.with_context(|| format!("config {}", path.display()))?;
Config::from_toml(&text).map_err(|error| anyhow::anyhow!("{error}"))?
}
None => Config::default(),
@ -66,6 +63,7 @@ pub fn run(args: Args) -> Result<()> {
(args.no_resources, PassId::Resources),
(args.no_strings, PassId::Strings),
(args.no_constants, PassId::Constants),
(args.no_flow, PassId::Flow),
(args.no_noise, PassId::Noise),
(args.no_debug_info, PassId::DebugInfo),
] {
@ -75,19 +73,19 @@ pub fn run(args: Args) -> Result<()> {
}
let entries = noxis_jar::read_file(&args.input)
.with_context(|| format!("чтение {}", args.input.display()))?;
.with_context(|| format!("reading {}", args.input.display()))?;
let (entries, stripped) = noxis_jar::strip_signatures(entries);
let mut program =
noxis_jar::load(entries).map_err(|error| anyhow::anyhow!("разбор jar: {error}"))?;
noxis_jar::load(entries).map_err(|error| anyhow::anyhow!("parsing jar: {error}"))?;
let mut ctx = Context::new(config);
let report = noxis_passes::standard_pipeline()
.run(&mut ctx, &mut program)
.map_err(|error| anyhow::anyhow!("конвейер: {error}"))?;
.map_err(|error| anyhow::anyhow!("pipeline: {error}"))?;
let entries =
noxis_jar::save(&program).map_err(|error| anyhow::anyhow!("сборка jar: {error}"))?;
noxis_jar::save(&program).map_err(|error| anyhow::anyhow!("assembling jar: {error}"))?;
noxis_jar::write_file(&args.output, &entries)
.with_context(|| format!("запись {}", args.output.display()))?;
.with_context(|| format!("writing {}", args.output.display()))?;
let report_path = args
.report
@ -96,14 +94,14 @@ pub fn run(args: Args) -> Result<()> {
&report_path,
serde_json::to_string_pretty(&SerializableReport::from(&report, &ctx))?,
)
.with_context(|| format!("отчёт {}", report_path.display()))?;
.with_context(|| format!("report {}", report_path.display()))?;
std::fs::write(
args.output.with_extension("mapping.txt"),
ctx.mapping.to_text(),
)?;
println!(
"noxis: {} → {} (классов: {}, подписей срезано: {}, отчёт: {})",
"noxis: {} → {} (classes: {}, signatures stripped: {}, report: {})",
args.input.display(),
args.output.display(),
report.counters.get("classes").copied().unwrap_or(0),
@ -113,7 +111,7 @@ pub fn run(args: Args) -> Result<()> {
Ok(())
}
/// Отчёт в JSON: сид, пассы, счётчики, маппинги текстом.
/// JSON report: seed, passes, counters, mappings as text.
#[derive(Debug, serde::Serialize)]
struct SerializableReport {
seed: u64,
@ -182,6 +180,23 @@ mod tests {
noxis_jar::write_file(path, &entries).expect("write jar");
}
fn sample_args(input: PathBuf, output: PathBuf) -> Args {
Args {
input,
output,
config: None,
seed: Some(5),
report: None,
no_rename: false,
no_resources: false,
no_strings: false,
no_constants: false,
no_flow: false,
no_noise: false,
no_debug_info: false,
}
}
#[test]
fn obfuscate_end_to_end() {
let dir = std::env::temp_dir().join(format!("noxis-cli-{}", std::process::id()));
@ -190,43 +205,44 @@ mod tests {
let input = dir.join("in.jar");
let output = dir.join("out.jar");
sample_jar(&input);
run(Args {
input: input.clone(),
output: output.clone(),
config: None,
seed: Some(5),
report: None,
no_rename: false,
no_resources: false,
no_strings: false,
no_constants: false,
no_noise: false,
no_debug_info: false,
no_flow: false,
})
.expect("obfuscate");
assert!(output.exists(), "нет выходного jar");
assert!(dir.join("out.report.json").exists(), "нет отчёта");
assert!(dir.join("out.mapping.txt").exists(), "нет маппингов");
run(sample_args(input.clone(), output.clone())).expect("obfuscate");
assert!(output.exists(), "missing output jar");
assert!(dir.join("out.report.json").exists(), "missing report");
assert!(dir.join("out.mapping.txt").exists(), "missing mappings");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn no_flow_is_honest_error() {
let args = Args {
input: PathBuf::from("in.jar"),
output: PathBuf::from("out.jar"),
config: None,
seed: None,
report: None,
no_rename: false,
no_resources: false,
no_strings: false,
no_constants: false,
no_noise: false,
no_debug_info: false,
no_flow: true,
fn no_flow_disables_the_pass() {
let dir = std::env::temp_dir().join(format!("noxis-cli-flow-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
let input = dir.join("in.jar");
sample_jar(&input);
let config_path = dir.join("paranoid.toml");
std::fs::write(&config_path, "profile = \"paranoid\"\n").expect("write config");
let run_with_flag = |name: &str, no_flow: bool| {
let output = dir.join(format!("{name}.jar"));
let report_path = dir.join(format!("{name}.report.json"));
let mut args = sample_args(input.clone(), output);
args.config = Some(config_path.clone());
args.report = Some(report_path.clone());
args.no_flow = no_flow;
run(args).expect("obfuscate");
std::fs::read_to_string(&report_path).expect("read report")
};
assert!(run(args).is_err());
let with_flow = run_with_flag("with", false);
assert!(
with_flow.contains("\"flow\""),
"flow pass missing under paranoid: {with_flow}"
);
let without_flow = run_with_flag("without", true);
assert!(
!without_flow.contains("\"flow\""),
"flow pass ran despite --no-flow: {without_flow}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}