classfile: aaload on 1-dim ref arrays yields element type; passes: minimizer example
This commit is contained in:
parent
21a7c3df1a
commit
1c417f596c
3 changed files with 388 additions and 2 deletions
322
crates/noxis/examples/minimizer.rs
Normal file
322
crates/noxis/examples/minimizer.rs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
//! Minimizer: isolate a `VerifyError` to the smallest failing method set.
|
||||
//!
|
||||
//! The full pipeline runs on the whole jar (so hierarchy and the classpath
|
||||
//! are complete), then the transformed target is verified with the whole
|
||||
//! transformed jar behind it. Bisection deletes methods from the target
|
||||
//! only: the verifier rejects a class method-locally, so the smallest subset
|
||||
//! that still fails names the culprit.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --example minimizer -- \
|
||||
//! --jar mod.jar --class a/b/C [--seed 42] [--config p.toml] [--bisect]
|
||||
//! ```
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
use noxis_classfile::format;
|
||||
use noxis_classfile::model::ClassFile;
|
||||
use noxis_core::{Config, Context, PassId, Profile};
|
||||
use noxis_jar::{Entry, load, read_file, save, write_file};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(true) => ExitCode::SUCCESS,
|
||||
Ok(false) => ExitCode::FAILURE,
|
||||
Err(error) => {
|
||||
eprintln!("minimizer: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Args {
|
||||
jar: String,
|
||||
class: String,
|
||||
seed: u64,
|
||||
config: Option<String>,
|
||||
bisect: bool,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut args = Args {
|
||||
jar: String::new(),
|
||||
class: String::new(),
|
||||
seed: 1,
|
||||
config: None,
|
||||
bisect: false,
|
||||
};
|
||||
let mut iter = std::env::args().skip(1);
|
||||
while let Some(flag) = iter.next() {
|
||||
match flag.as_str() {
|
||||
"--jar" => args.jar = iter.next().ok_or("--jar needs a value")?,
|
||||
"--class" => args.class = iter.next().ok_or("--class needs a value")?,
|
||||
"--seed" => {
|
||||
args.seed = iter
|
||||
.next()
|
||||
.ok_or("--seed needs a value")?
|
||||
.parse()
|
||||
.map_err(|_| "--seed must be a number")?
|
||||
}
|
||||
"--config" => args.config = Some(iter.next().ok_or("--config needs a value")?),
|
||||
"--bisect" => args.bisect = true,
|
||||
other => return Err(format!("unknown flag {other}")),
|
||||
}
|
||||
}
|
||||
if args.jar.is_empty() || args.class.is_empty() {
|
||||
return Err(
|
||||
"usage: --jar <jar> --class <internal/name> [--seed N] [--config p.toml] [--bisect]"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn load_config(args: &Args) -> Result<Config, String> {
|
||||
let mut config = match &args.config {
|
||||
Some(path) => {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
Config::from_toml(&text).map_err(|e| e.to_string())?
|
||||
}
|
||||
None => Config {
|
||||
profile: Profile::Paranoid,
|
||||
..Config::default()
|
||||
},
|
||||
};
|
||||
config.seed = Some(args.seed);
|
||||
// Junk classes never touch existing frames and only clutter the list.
|
||||
config.disable.push(PassId::Noise);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Verdict {
|
||||
Verified,
|
||||
Failed,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// Verify a class with the full transformed jar behind it on the classpath.
|
||||
fn verify(classpath: &Path, name: &str, bytes: &[u8]) -> (Verdict, String) {
|
||||
let scratch = std::env::temp_dir().join(format!("noxis-min-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let mut path = scratch.clone();
|
||||
for part in name.split('/') {
|
||||
path.push(part);
|
||||
}
|
||||
path.set_extension("class");
|
||||
let written =
|
||||
std::fs::create_dir_all(path.parent().unwrap()).and_then(|()| std::fs::write(&path, bytes));
|
||||
if let Err(error) = written {
|
||||
return (Verdict::Skipped, format!("write: {error}"));
|
||||
}
|
||||
// scratch first so the (subset) target shadows its full-jar entry.
|
||||
let cp = format!("{}:{}", scratch.display(), classpath.display());
|
||||
let output = Command::new("java")
|
||||
.arg("-Xverify:all")
|
||||
.arg("-cp")
|
||||
.arg(&cp)
|
||||
.arg(name.replace('/', "."))
|
||||
.output();
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let Ok(output) = output else {
|
||||
return (Verdict::Skipped, "java failed to start".into());
|
||||
};
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
if stderr.contains("VerifyError") || stderr.contains("ClassFormatError") {
|
||||
(Verdict::Failed, stderr)
|
||||
} else if stderr.contains("NoClassDefFoundError")
|
||||
|| stderr.contains("ClassNotFoundException")
|
||||
|| stderr.contains("UnsupportedClassVersionError")
|
||||
{
|
||||
(Verdict::Skipped, stderr)
|
||||
} else {
|
||||
(Verdict::Verified, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the failing class and method from a VerifyError's `Location:` line:
|
||||
/// ` pkg/Class.method(desc)R @pc: insn`. The verifier blames the exact
|
||||
/// method, which may live in a superclass of the class under test. Note the
|
||||
/// class name is printed verbatim (an obfuscated name may itself start
|
||||
/// with `L`), so both spellings are tried by the caller.
|
||||
fn failing_location(stderr: &str) -> Option<(String, String)> {
|
||||
let mut lines = stderr.lines();
|
||||
while let Some(line) = lines.next() {
|
||||
if line.trim() == "Location:" {
|
||||
let loc = lines.next()?.trim();
|
||||
let dot = loc.find('.')?;
|
||||
let class = loc[..dot].to_string();
|
||||
let method = loc[dot + 1..].split(" @").next()?.to_string();
|
||||
return Some((class, method));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Method identity as the verifier sees it: name plus descriptor. All
|
||||
/// methods participate, including `<init>`/`<clinit>`: deletion is
|
||||
/// verification-safe (no code runs), and the bug may live there.
|
||||
fn method_keys(class: &ClassFile) -> Vec<(String, String)> {
|
||||
class
|
||||
.methods
|
||||
.iter()
|
||||
.map(|m| (m.name.clone(), m.descriptor.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_subset(class: &ClassFile, keep: &BTreeSet<(String, String)>) -> Option<Vec<u8>> {
|
||||
let mut cut = class.clone();
|
||||
cut.methods
|
||||
.retain(|m| keep.contains(&(m.name.clone(), m.descriptor.clone())));
|
||||
format::write(&cut).ok()
|
||||
}
|
||||
|
||||
fn subset_verdict(cp: &Path, class: &ClassFile, keep: &BTreeSet<(String, String)>) -> Verdict {
|
||||
let Some(bytes) = write_subset(class, keep) else {
|
||||
return Verdict::Skipped;
|
||||
};
|
||||
let (verdict, stderr) = verify(cp, &class.this_class, &bytes);
|
||||
if verdict == Verdict::Failed {
|
||||
let reason = stderr
|
||||
.lines()
|
||||
.find(|l| l.contains("VerifyError"))
|
||||
.unwrap_or("?");
|
||||
println!(" keep={}: {reason}", keep.len());
|
||||
}
|
||||
verdict
|
||||
}
|
||||
|
||||
fn bisect(cp: &Path, class: &ClassFile) -> Option<BTreeSet<(String, String)>> {
|
||||
let mut keep: BTreeSet<(String, String)> = method_keys(class).into_iter().collect();
|
||||
println!("bisect: {} methods total", keep.len());
|
||||
while keep.len() > 1 {
|
||||
let all: Vec<(String, String)> = keep.iter().cloned().collect();
|
||||
let half = all.len() / 2;
|
||||
let left: BTreeSet<_> = all[..half].iter().cloned().collect();
|
||||
let right: BTreeSet<_> = all[half..].iter().cloned().collect();
|
||||
match (
|
||||
subset_verdict(cp, class, &left),
|
||||
subset_verdict(cp, class, &right),
|
||||
) {
|
||||
(Verdict::Failed, _) => keep = left,
|
||||
(_, Verdict::Failed) => keep = right,
|
||||
_ => {
|
||||
println!("bisect: neither half fails alone - interaction; keeping all");
|
||||
return Some(keep);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(keep)
|
||||
}
|
||||
|
||||
fn run() -> Result<bool, String> {
|
||||
let args = parse_args()?;
|
||||
let jar_path = PathBuf::from(&args.jar);
|
||||
let config = load_config(&args)?;
|
||||
let entries = read_file(&jar_path).map_err(|e| e.to_string())?;
|
||||
if !entries
|
||||
.iter()
|
||||
.any(|e| e.name == format!("{}.class", args.class))
|
||||
{
|
||||
return Err(format!("{} not in jar", args.class));
|
||||
}
|
||||
let mut program = load(entries).map_err(|e| e.to_string())?;
|
||||
println!("loaded {} classes", program.classes.len());
|
||||
|
||||
let mut ctx = Context::new(config);
|
||||
noxis_passes::standard_pipeline()
|
||||
.run(&mut ctx, &mut program)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let new_name = ctx
|
||||
.mapping
|
||||
.class(&args.class)
|
||||
.unwrap_or(&args.class)
|
||||
.to_string();
|
||||
println!("target {} -> {new_name}", args.class);
|
||||
|
||||
// Whole transformed jar becomes the verifier's classpath.
|
||||
let out_jar = std::env::temp_dir().join(format!("noxis-min-cp-{}.jar", std::process::id()));
|
||||
let transformed: Vec<Entry> = save(&program).map_err(|e| e.to_string())?;
|
||||
write_file(&out_jar, &transformed).map_err(|e| e.to_string())?;
|
||||
|
||||
let target = program
|
||||
.class(&new_name)
|
||||
.ok_or_else(|| format!("{new_name} vanished"))?;
|
||||
let bytes = format::write(target).map_err(|e| e.to_string())?;
|
||||
let (verdict, stderr) = verify(&out_jar, &new_name, &bytes);
|
||||
match verdict {
|
||||
Verdict::Verified => {
|
||||
println!("PASS: this run does not reproduce the failure");
|
||||
let _ = std::fs::remove_file(&out_jar);
|
||||
return Ok(false);
|
||||
}
|
||||
Verdict::Skipped => {
|
||||
println!("SKIP: dependencies unresolved:\n{stderr}");
|
||||
let _ = std::fs::remove_file(&out_jar);
|
||||
return Ok(false);
|
||||
}
|
||||
Verdict::Failed => {
|
||||
println!(
|
||||
"reproduced:\n{}",
|
||||
stderr.lines().take(6).collect::<Vec<_>>().join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let parsed = format::parse(&bytes).map_err(|e| e.to_string())?;
|
||||
if !args.bisect {
|
||||
let _ = std::fs::remove_file(&out_jar);
|
||||
return Ok(false);
|
||||
}
|
||||
// The verifier blames the exact method, which may live in a superclass:
|
||||
// pivot the bisection to the class that actually fails.
|
||||
let mut pivot_name = new_name.clone();
|
||||
let mut pivot = parsed;
|
||||
if let Some((fail_class, fail_method)) = failing_location(&stderr) {
|
||||
println!("verifier blames {fail_class}.{fail_method}");
|
||||
if fail_class != new_name {
|
||||
// The name may itself start with `L`; HotSpot prints it verbatim.
|
||||
let real_bytes = if let Some(real) = program.class(&fail_class) {
|
||||
format::write(real).map_err(|e| e.to_string())?
|
||||
} else if let Some(stripped) =
|
||||
fail_class.strip_prefix('L').and_then(|s| program.class(s))
|
||||
{
|
||||
format::write(stripped).map_err(|e| e.to_string())?
|
||||
} else {
|
||||
return Err(format!("{fail_class} not in program"));
|
||||
};
|
||||
pivot = format::parse(&real_bytes).map_err(|e| e.to_string())?;
|
||||
pivot_name = fail_class;
|
||||
println!("pivoted to {pivot_name}");
|
||||
}
|
||||
}
|
||||
let minimal = bisect(&out_jar, &pivot);
|
||||
let _ = std::fs::remove_file(&out_jar);
|
||||
let Some(minimal) = minimal else {
|
||||
return Ok(false);
|
||||
};
|
||||
println!("\nminimal failing set in {pivot_name}:");
|
||||
for (name, descriptor) in &minimal {
|
||||
let method = pivot
|
||||
.methods
|
||||
.iter()
|
||||
.find(|m| &m.name == name && &m.descriptor == descriptor)
|
||||
.unwrap();
|
||||
let insns = method.code.as_ref().map_or(0, |c| c.insns.len());
|
||||
let frames = method.code.as_ref().map_or(0, |c| c.frames.len());
|
||||
println!(" {name}{descriptor} insns={insns} frames={frames}");
|
||||
}
|
||||
// Write the minimal single-failing-subset class for javap inspection.
|
||||
let minimal_set: BTreeSet<(String, String)> = minimal.iter().cloned().collect();
|
||||
if let Some(minimal_bytes) = write_subset(&pivot, &minimal_set) {
|
||||
let dump = std::env::temp_dir().join("noxis-minimal-fail.class");
|
||||
if std::fs::write(&dump, &minimal_bytes).is_ok() {
|
||||
println!("minimal class bytes: {}", dump.display());
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue