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.
This commit is contained in:
Loki 2026-10-09 13:53:30 +02:00
parent c3acc0baf4
commit 74ad4552e4
Signed by: boba
GPG key ID: 253067914055423B

View file

@ -1,10 +1,11 @@
//! Метрики программы: счётчики, раздувание, стабильность маппингов. //! Program metrics: counters, inflation, mapping stability.
use noxis_analysis::Analysis;
use noxis_classfile::{Const, Insn, Program}; use noxis_classfile::{Const, Insn, Program};
use noxis_core::Mapping; use noxis_core::Mapping;
use serde::Serialize; use serde::Serialize;
/// Счётчики программы (для отчёта и бюджетов). /// Program counters (for the report and budgets).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct ProgramStats { pub struct ProgramStats {
pub classes: usize, pub classes: usize,
@ -13,16 +14,32 @@ pub struct ProgramStats {
pub insns: usize, pub insns: usize,
pub strings: usize, pub strings: usize,
pub resources: 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 {
pub fn program_stats(program: &Program) -> 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 { let mut stats = ProgramStats {
classes: program.classes.len(), classes: program.classes.len(),
resources: program.resources.len(), resources: program.resources.len(),
..ProgramStats::default() ..ProgramStats::default()
}; };
for class in &program.classes { for class in &program.classes {
if analysis.is_locked(&class.this_class) {
stats.locked_classes += 1;
}
stats.fields += class.fields.len(); stats.fields += class.fields.len();
stats.methods += class.methods.len(); stats.methods += class.methods.len();
for method in &class.methods { for method in &class.methods {
@ -40,7 +57,7 @@ pub fn program_stats(program: &Program) -> ProgramStats {
stats stats
} }
/// Коэффициент раздувания (`after / before`). /// The inflation factor (`after / before`).
pub fn inflation_ratio(before_bytes: u64, after_bytes: u64) -> f64 { pub fn inflation_ratio(before_bytes: u64, after_bytes: u64) -> f64 {
if before_bytes == 0 { if before_bytes == 0 {
return f64::INFINITY; return f64::INFINITY;
@ -48,18 +65,16 @@ pub fn inflation_ratio(before_bytes: u64, after_bytes: u64) -> f64 {
after_bytes as f64 / before_bytes as f64 after_bytes as f64 / before_bytes as f64
} }
/// Раздувание в бюджете (`max_ratio`, например `1.5` = +50%). /// 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> { pub fn check_inflation(before_bytes: u64, after_bytes: u64, max_ratio: f64) -> Result<(), String> {
let ratio = inflation_ratio(before_bytes, after_bytes); let ratio = inflation_ratio(before_bytes, after_bytes);
if ratio > max_ratio { if ratio > max_ratio {
return Err(format!( return Err(format!("inflation {ratio:.2} over budget {max_ratio:.2}"));
"раздувание {ratio:.2} сверх бюджета {max_ratio:.2}"
));
} }
Ok(()) Ok(())
} }
/// Маппинги обязаны меняться между сборками (иначе сид не работает). /// Mappings must change between builds (otherwise the seed does nothing).
pub fn mappings_changed(before: &Mapping, after: &Mapping) -> bool { pub fn mappings_changed(before: &Mapping, after: &Mapping) -> bool {
before != after before != after
} }
@ -72,7 +87,7 @@ mod tests {
#[test] #[test]
fn empty_program_stats_are_zero() { fn empty_program_stats_are_zero() {
let stats = program_stats(&Program::default()); let stats = program_stats(&Program::default(), &empty_analysis());
assert_eq!(stats, ProgramStats::default()); assert_eq!(stats, ProgramStats::default());
assert_eq!(stats.classes, 0); assert_eq!(stats.classes, 0);
} }
@ -90,4 +105,34 @@ mod tests {
let mapping = Mapping::default(); let mapping = Mapping::default();
assert!(!mappings_changed(&mapping, &mapping)); 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")
}
} }