obfuskator/crates/noxis-verify/src/metrics.rs
loki5512344 74ad4552e4
analysis,verify: databind field lock and locked-class share
KeepRules::absorb_databind finds X.class literals before Gson /
Jackson / fastjson2 / Moshi calls and locks every field of X
(reflection-bound names never reach the string-based detection).
The class itself still renames. Test in analysis/tests/dto.rs;
keep unit tests moved to tests/keep.rs.

ProgramStats gains locked_classes + locked_share() so analyst-bait
source names are loud in the report instead of surfacing later.
2026-10-09 13:53:30 +02:00

138 lines
4.3 KiB
Rust

//! Program metrics: counters, inflation, mapping stability.
use noxis_analysis::Analysis;
use noxis_classfile::{Const, Insn, Program};
use noxis_core::Mapping;
use serde::Serialize;
/// Program counters (for the report and budgets).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct ProgramStats {
pub classes: usize,
pub methods: usize,
pub fields: usize,
pub insns: usize,
pub strings: usize,
pub resources: usize,
/// Classes kept under their source name (entrypoints, reflection, config).
/// They stick out next to obfuscated neighbours, so the share is loud.
pub locked_classes: usize,
}
impl ProgramStats {
/// The locked share of all classes (`0.0` when there are none).
pub fn locked_share(&self) -> f64 {
if self.classes == 0 {
return 0.0;
}
self.locked_classes as f64 / self.classes as f64
}
}
/// Count the program stats, including the locked-class share.
pub fn program_stats(program: &Program, analysis: &Analysis) -> ProgramStats {
let mut stats = ProgramStats {
classes: program.classes.len(),
resources: program.resources.len(),
..ProgramStats::default()
};
for class in &program.classes {
if analysis.is_locked(&class.this_class) {
stats.locked_classes += 1;
}
stats.fields += class.fields.len();
stats.methods += class.methods.len();
for method in &class.methods {
let Some(code) = method.code.as_ref() else {
continue;
};
stats.insns += code.insns.len();
stats.strings += code
.insns
.iter()
.filter(|insn| matches!(insn, Insn::Const(Const::String(_))))
.count();
}
}
stats
}
/// The inflation factor (`after / before`).
pub fn inflation_ratio(before_bytes: u64, after_bytes: u64) -> f64 {
if before_bytes == 0 {
return f64::INFINITY;
}
after_bytes as f64 / before_bytes as f64
}
/// Inflation within budget (`max_ratio`, e.g. `1.5` = +50%).
pub fn check_inflation(before_bytes: u64, after_bytes: u64, max_ratio: f64) -> Result<(), String> {
let ratio = inflation_ratio(before_bytes, after_bytes);
if ratio > max_ratio {
return Err(format!("inflation {ratio:.2} over budget {max_ratio:.2}"));
}
Ok(())
}
/// Mappings must change between builds (otherwise the seed does nothing).
pub fn mappings_changed(before: &Mapping, after: &Mapping) -> bool {
before != after
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
#[test]
fn empty_program_stats_are_zero() {
let stats = program_stats(&Program::default(), &empty_analysis());
assert_eq!(stats, ProgramStats::default());
assert_eq!(stats.classes, 0);
}
#[test]
fn inflation_math() {
assert!((inflation_ratio(100, 150) - 1.5).abs() < f64::EPSILON);
assert!(inflation_ratio(0, 10).is_infinite());
assert!(check_inflation(100, 120, 1.5).is_ok());
assert!(check_inflation(100, 200, 1.5).is_err());
}
#[test]
fn identical_mappings_are_stable() {
let mapping = Mapping::default();
assert!(!mappings_changed(&mapping, &mapping));
}
/// Locked classes stick out next to obfuscated neighbours: count them.
#[test]
fn locked_classes_are_counted() {
use noxis_analysis::Analysis;
use noxis_classfile::ClassBuilder;
use noxis_core::Config;
let program = Program {
classes: vec![
ClassBuilder::new("app/Keep", None).build(),
ClassBuilder::new("app/Hide", None).build(),
],
resources: Vec::new(),
};
let config = Config {
keep: vec!["app/Keep".to_string()],
..Config::default()
};
let analysis = Analysis::run(&program, &config).expect("analysis");
let stats = program_stats(&program, &analysis);
assert_eq!(stats.classes, 2);
assert_eq!(stats.locked_classes, 1);
assert!((stats.locked_share() - 0.5).abs() < f64::EPSILON);
}
fn empty_analysis() -> noxis_analysis::Analysis {
noxis_analysis::Analysis::run(&Program::default(), &noxis_core::Config::default())
.expect("empty analysis")
}
}