diff --git a/crates/noxis-classfile/src/format/frames/merge/descs.rs b/crates/noxis-classfile/src/format/frames/merge/descs.rs index e089cf2..18c41b6 100644 --- a/crates/noxis-classfile/src/format/frames/merge/descs.rs +++ b/crates/noxis-classfile/src/format/frames/merge/descs.rs @@ -140,12 +140,20 @@ pub(crate) fn array_element( if dims > 1 { return VerificationType::Object(descriptor[1..].to_string()); } + // `split_array` keeps the `L` prefix and trims `;`: a one-dimensional + // reference array yields its element as an internal name. Returning + // the array itself here poisoned every downstream join (the reported + // `... is not assignable to [;` family). return match elem { "Z" | "B" | "C" | "S" | "I" => VerificationType::Integer, "J" => VerificationType::Long, "F" => VerificationType::Float, "D" => VerificationType::Double, - _ => VerificationType::Object(format!("[{elem};")), + _ => VerificationType::Object( + elem.strip_prefix('L') + .unwrap_or("java/lang/Object") + .to_string(), + ), }; } match kind { diff --git a/crates/noxis-classfile/src/format/frames/tests/joins.rs b/crates/noxis-classfile/src/format/frames/tests/joins.rs index 2328c65..ebf8462 100644 --- a/crates/noxis-classfile/src/format/frames/tests/joins.rs +++ b/crates/noxis-classfile/src/format/frames/tests/joins.rs @@ -4,7 +4,7 @@ use super::super::{StackMapFrame, VerificationType, compute_frames}; use super::{NoHierarchy, code_method, load, load_ref, store, store_ref}; -use crate::insn::{BranchCond, Cond, Const, Insn, Prim}; +use crate::insn::{ArrayKind, BranchCond, Cond, Const, Insn, Prim}; /// `anewarray [B` (a partially bound multi-dim array) yields its descriptor /// verbatim: wrapping it again (`[L[B;`) is not a class and HotSpot rejects @@ -189,3 +189,59 @@ fn array_load_wide_pushes_pair() { ] ); } + +/// `aaload` on a one-dimensional reference array yields the element type. +/// +/// Real-world shape (`V8ValueObject.bind` family): one join inflow carries +/// the loaded element (`Method`), the other the array itself (`Method[]`). +/// The join must declare their LUB (`Object`); declaring the array rejects +/// the element inflow (`Type ... is not assignable to ...`). +#[test] +fn aaload_on_ref_array_yields_element() { + let method = code_method( + "pick", + "([Ljava/lang/reflect/Method;)Ljava/lang/Object;", + 2, + 2, + vec![ + Insn::Mark(0), + Insn::Const(Const::Int(0)), + Insn::Branch(BranchCond::Zero(Cond::Eq), 1), + load_ref(0), + Insn::Const(Const::Int(0)), + Insn::ArrayLoad(ArrayKind::Reference), + store_ref(1), + Insn::Goto(2), + Insn::Mark(1), + load_ref(0), + store_ref(1), + Insn::Mark(2), + load_ref(1), + Insn::AReturn, + ], + vec![], + ); + let frames = compute_frames("demo/Pick", &method, &NoHierarchy).expect("frames"); + // The join declares locals[1]; whatever the frame kind, it must be the + // LUB (`Object`), never the array the other inflow carried. + let mut saw_join = false; + for frame in &frames { + let locals: Vec = match frame { + StackMapFrame::SameLocals { local, .. } => vec![local.clone()], + StackMapFrame::Append { locals, .. } | StackMapFrame::Full { locals, .. } => { + locals.clone() + } + StackMapFrame::Same { .. } | StackMapFrame::Chop { .. } => vec![], + }; + for local in &locals { + assert_ne!( + local, + &VerificationType::Object("[Ljava/lang/reflect/Method;".to_string()) + ); + if local == &VerificationType::Object("java/lang/Object".to_string()) { + saw_join = true; + } + } + } + assert!(saw_join, "expected the join LUB in {frames:?}"); +} diff --git a/crates/noxis/examples/minimizer.rs b/crates/noxis/examples/minimizer.rs new file mode 100644 index 0000000..f1470c0 --- /dev/null +++ b/crates/noxis/examples/minimizer.rs @@ -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, + bisect: bool, +} + +fn parse_args() -> Result { + 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 --class [--seed N] [--config p.toml] [--bisect]" + .into(), + ); + } + Ok(args) +} + +fn load_config(args: &Args) -> Result { + 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 ``/``: 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> { + 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> { + 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 { + 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 = 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::>().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) +}