verify,cli: jadx decompile-quality metric with analyze --decompile

This commit is contained in:
Loki 2026-10-10 13:32:23 +02:00
parent 2655f83d3c
commit e9d5e44b9e
Signed by: boba
GPG key ID: 253067914055423B
4 changed files with 222 additions and 1 deletions

View file

@ -27,6 +27,96 @@ pub fn java_available() -> bool {
.is_ok_and(|out| out.status.success()) .is_ok_and(|out| out.status.success())
} }
/// Whether `jadx` is available for decompile checks.
pub fn jadx_available() -> bool {
Command::new("jadx")
.arg("--version")
.output()
.is_ok_and(|out| out.status.success())
}
/// Decompile quality of one jar: how much of the output jadx itself flags.
///
/// `JADX WARN` markers mean recovered-but-dubious structures; `Failed`
/// markers mean unrecovered ones. Obfuscation wins when these climb while
/// `java -Xverify:all` stays green: the bytecode runs, but readers drown.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DecompileReport {
/// `.java` files emitted.
pub files: usize,
/// Files with at least one `JADX WARN`.
pub files_with_warn: usize,
/// Total `JADX WARN` markers.
pub warns: usize,
/// `JADX WARN: Failed...` markers (unrecovered structures).
pub failed: usize,
/// Warn markers by kind (first word after the prefix).
pub kinds: std::collections::BTreeMap<String, usize>,
/// jadx exit code.
pub exit_code: Option<i32>,
}
/// Decompile a jar with jadx into a scratch dir and grade the output.
/// `None` when jadx is missing or refuses to start.
pub fn decompile_report(jar: &Path) -> Option<DecompileReport> {
if !jadx_available() {
return None;
}
let scratch = std::env::temp_dir().join(format!("noxis-jadx-rep-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&scratch);
let output = Command::new("jadx")
.arg("-q")
.arg("-d")
.arg(&scratch)
.arg(jar)
.output()
.ok()?;
let mut report = DecompileReport {
exit_code: output.status.code(),
..DecompileReport::default()
};
let mut stack = vec![scratch.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|ext| ext == "java") {
report.files += 1;
let text = std::fs::read(&path)
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
.unwrap_or_default();
let mut file_warns = 0;
for line in text.lines() {
if let Some(rest) = line.find("JADX WARN:").map(|i| &line[i + 10..]) {
let kind: String = rest
.trim_start_matches([' ', '\'', '"'])
.split_whitespace()
.next()
.unwrap_or("?")
.trim_matches(['\'', '"', ':'])
.to_string();
*report.kinds.entry(kind.clone()).or_insert(0) += 1;
if kind == "Failed" {
report.failed += 1;
}
report.warns += 1;
file_warns += 1;
}
}
if file_warns > 0 {
report.files_with_warn += 1;
}
}
}
}
let _ = std::fs::remove_dir_all(&scratch);
Some(report)
}
/// Проверить байты одного класса настоящим верификатором. /// Проверить байты одного класса настоящим верификатором.
pub fn verify_class_bytes(internal_name: &str, bytes: &[u8]) -> JavaVerdict { pub fn verify_class_bytes(internal_name: &str, bytes: &[u8]) -> JavaVerdict {
if !java_available() { if !java_available() {

View file

@ -10,7 +10,10 @@ pub mod java;
pub mod leaks; pub mod leaks;
pub mod metrics; pub mod metrics;
pub use java::{JavaVerdict, java_available, verify_class_bytes, verify_jar}; pub use java::{
DecompileReport, JavaVerdict, decompile_report, jadx_available, java_available,
verify_class_bytes, verify_jar,
};
pub use leaks::{RED_PATTERNS, SecretHit, check_patterns, collect_names, find_leaks, scan_secrets}; pub use leaks::{RED_PATTERNS, SecretHit, check_patterns, collect_names, find_leaks, scan_secrets};
pub use metrics::{ pub use metrics::{
ProgramStats, check_inflation, inflation_ratio, mappings_changed, program_stats, ProgramStats, check_inflation, inflation_ratio, mappings_changed, program_stats,

View file

@ -0,0 +1,108 @@
//! Decompile-quality metric: jadx must chew a clean jar without warnings.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use noxis_classfile::{ClassBuilder, Code, Insn, Version};
use noxis_verify::{decompile_report, jadx_available};
/// Two classes, one with a branchy method: enough structure for jadx to
/// prove it can read the jar, small enough to run in milliseconds.
fn sample_jar(path: &std::path::Path) {
let loop_code = Code {
max_stack: 2,
max_locals: 2,
insns: vec![
Insn::Const(noxis_classfile::Const::Int(0)),
Insn::Store {
slot: 0,
kind: noxis_classfile::SlotKind::Int,
},
Insn::Mark(0),
Insn::Load {
slot: 0,
kind: noxis_classfile::SlotKind::Int,
},
Insn::Const(noxis_classfile::Const::Int(10)),
Insn::Branch(
noxis_classfile::BranchCond::Int(noxis_classfile::Cond::Ge),
1,
),
Insn::Load {
slot: 0,
kind: noxis_classfile::SlotKind::Int,
},
Insn::Const(noxis_classfile::Const::Int(1)),
Insn::Binary {
prim: noxis_classfile::Prim::Int,
op: noxis_classfile::Arith::Add,
},
Insn::Store {
slot: 0,
kind: noxis_classfile::SlotKind::Int,
},
Insn::Goto(0),
Insn::Mark(1),
Insn::Load {
slot: 0,
kind: noxis_classfile::SlotKind::Int,
},
Insn::Return(Some(noxis_classfile::Prim::Int)),
],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
let mut program = noxis_classfile::Program {
classes: vec![
ClassBuilder::new("demo/App", Some("java/lang/Object"))
.version(Version::JAVA_21)
.method_with_code(0x0009, "run", "()I", loop_code)
.build(),
ClassBuilder::new("demo/Help", Some("java/lang/Object"))
.version(Version::JAVA_21)
.build(),
],
resources: Vec::new(),
};
// Real frames: jadx grades structure, not just parsing.
let hierarchy = noxis_analysis::ClassHierarchy::build(&program);
for class in &mut program.classes {
for method in &mut class.methods {
if method.code.is_none() {
continue;
}
let snapshot = method.clone();
let frames = noxis_classfile::compute_frames(&class.this_class, &snapshot, &hierarchy)
.expect("frames");
if let Some(code) = method.code.as_mut() {
code.frames = frames;
}
}
}
let entries = noxis_jar::save(&program).expect("save");
noxis_jar::write_file(path, &entries).expect("write jar");
}
#[test]
fn clean_jar_decompiles_without_warnings() {
if !jadx_available() {
eprintln!("SKIP: no jadx");
return;
}
let dir = std::env::temp_dir().join(format!("noxis-jadx-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
let jar = dir.join("in.jar");
sample_jar(&jar);
let report = decompile_report(&jar).expect("jadx runs");
assert!(
report.files >= 2,
"expected 2 classes, got {}",
report.files
);
assert_eq!(report.failed, 0, "clean code must not fail: {report:?}");
assert_eq!(
report.files_with_warn, 0,
"clean code must not warn: {report:?}"
);
}

View file

@ -14,6 +14,9 @@ pub struct Args {
/// TOML-конфиг с keep-паттернами (без него - пустой keep). /// TOML-конфиг с keep-паттернами (без него - пустой keep).
#[arg(short, long)] #[arg(short, long)]
pub config: Option<PathBuf>, pub config: Option<PathBuf>,
/// Декомпилировать jadx и показать долю распознанных структур.
#[arg(long)]
pub decompile: bool,
} }
/// Показать классы, entrypoint'ы и keep-правила. /// Показать классы, entrypoint'ы и keep-правила.
@ -48,6 +51,22 @@ pub fn run(args: Args) -> Result<()> {
analysis.entrypoints.reflective_names.len() analysis.entrypoints.reflective_names.len()
); );
println!("keep-правил: {}", analysis.keep.len()); println!("keep-правил: {}", analysis.keep.len());
if args.decompile {
match noxis_verify::decompile_report(&args.input) {
None => println!("decompile: нет jadx"),
Some(report) => {
println!(
"decompile: файлов {}, с WARN {}, WARN всего {}, Failed {}",
report.files, report.files_with_warn, report.warns, report.failed
);
let mut kinds: Vec<(&String, &usize)> = report.kinds.iter().collect();
kinds.sort_by(|a, b| b.1.cmp(a.1));
for (kind, count) in kinds.iter().take(8) {
println!(" {kind}: {count}");
}
}
}
}
Ok(()) Ok(())
} }
@ -62,6 +81,7 @@ mod tests {
let args = Args { let args = Args {
input: PathBuf::from("definitely-not-here.jar"), input: PathBuf::from("definitely-not-here.jar"),
config: None, config: None,
decompile: false,
}; };
assert!(run(args).is_err()); assert!(run(args).is_err());
} }