diff --git a/crates/noxis-analysis/src/keep.rs b/crates/noxis-analysis/src/keep.rs index f9895e3..b3e323c 100644 --- a/crates/noxis-analysis/src/keep.rs +++ b/crates/noxis-analysis/src/keep.rs @@ -1,12 +1,14 @@ -//! Keep-правила: что нельзя переименовывать (glob-паттерны + entrypoint'ы). +//! Keep rules: what must not be renamed (glob patterns + entrypoints). -use noxis_classfile::{Attribute, Const, Program}; +use std::collections::BTreeSet; + +use noxis_classfile::{Attribute, Const, Insn, Program}; use regex::Regex; use crate::entrypoints::Entrypoints; use noxis_core::Config; -/// Скомпилированный glob: `*` - любые символы кроме `/`, `**` - всё, `?` - один. +/// Compiled glob: `*` - anything but `/`, `**` - anything, `?` - one char. fn compile(pattern: &str) -> Result { let mut out = String::from("^"); let chars: Vec = pattern.chars().collect(); @@ -36,19 +38,41 @@ fn compile(pattern: &str) -> Result { Regex::new(&out) } -/// Правила «не трогать»: явные паттерны конфига + entrypoint'ы. +/// Databinding entry points: `(owner, method names)` taking a `Class` literal. +/// Gson, Jackson, fastjson2 and Moshi bind DTO fields by reflection from +/// `X.class`, with no field-name string for the string-based detection. +const DATABIND_CALLS: [(&str, &[&str]); 5] = [ + ("com/google/gson/Gson", &["fromJson"]), + ( + "com/fasterxml/jackson/databind/ObjectMapper", + &["readValue", "readValues", "convertValue"], + ), + ( + "com/fasterxml/jackson/databind/ObjectReader", + &["readValue", "readValues"], + ), + ("com/alibaba/fastjson2/JSON", &["parseObject"]), + ("com/squareup/moshi/Moshi", &["adapter"]), +]; + +/// How far back an `X.class` literal may sit before the databind call. +/// Argument setup is straight-line: a few loads between the literal and the +/// call at most. +const CLASS_LITERAL_WINDOW: usize = 10; + +/// "Do not touch" rules: explicit config patterns + entrypoints. #[derive(Debug, Default, Clone)] pub struct KeepRules { class_patterns: Vec, resource_patterns: Vec, - /// Точные имена, которые нельзя трогать (entrypoint'ы, рефлексия, ...). - fixed: std::collections::BTreeSet, - /// Имена членов, защищённые рефлексией. - fixed_members: std::collections::BTreeSet, + /// Exact names to keep (entrypoints, reflection, ...). + fixed: BTreeSet, + /// Member names protected by reflection. + fixed_members: BTreeSet, } impl KeepRules { - /// Собрать правила из конфига. + /// Build the rules from the config. pub fn from_config(config: &Config) -> Result { let mut class_patterns = Vec::with_capacity(config.keep.len()); for pattern in &config.keep { @@ -67,22 +91,22 @@ impl KeepRules { Ok(Self { class_patterns, resource_patterns, - fixed: std::collections::BTreeSet::new(), - fixed_members: std::collections::BTreeSet::new(), + fixed: BTreeSet::new(), + fixed_members: BTreeSet::new(), }) } - /// Запретить переименование члена с таким именем (в любом классе). + /// Forbid renaming a member with this name (in any class). pub fn keep_member(&mut self, name: impl Into) { self.fixed_members.insert(name.into()); } - /// Добавить точное имя класса (из анализа программы). + /// Add an exact class name (from program analysis). pub fn keep_exact(&mut self, internal_name: impl Into) { self.fixed.insert(internal_name.into()); } - /// Объединить с найденными entrypoint'ами. + /// Absorb found entrypoints. pub fn absorb(&mut self, entrypoints: &Entrypoints) { for name in &entrypoints.main_classes { self.keep_exact(name.clone()); @@ -104,10 +128,10 @@ impl KeepRules { } } - /// Методы, на которые ссылаются `BootstrapMethods` (`invokedynamic`). + /// Methods referenced by `BootstrapMethods` (`invokedynamic`). /// - /// Переименование такого метода без правки bootstrap-записи ломает класс, - /// а правка - задача P2 (см. `todo.md`): пока просто не трогаем имена. + /// Renaming such a method without patching the bootstrap record breaks + /// the class, and patching is a P2 task (see `todo.md`): keep the names. pub fn absorb_bootstrap(&mut self, program: &Program) { for class in &program.classes { for method in &class.methods { @@ -127,7 +151,36 @@ impl KeepRules { } } - /// Нужно ли оставить класс с данным внутренним именем. + /// Lock every field of every class passed as an `X.class` literal to a + /// databinding call. Gson/Jackson bind fields by reflection from the + /// literal; renaming them silently breaks deserialization, while the + /// class itself (never named in JSON) still renames freely. + pub fn absorb_databind(&mut self, program: &Program) { + for class in &program.classes { + for method in &class.methods { + let Some(code) = method.code.as_ref() else { + continue; + }; + for (index, insn) in code.insns.iter().enumerate() { + let Insn::Invoke { owner, name, .. } = insn else { + continue; + }; + if !is_databind_call(owner, name) { + continue; + } + if let Some(dto) = nearest_class_literal(&code.insns[..index]) + && let Some(dto_class) = program.class(&dto) + { + for field in &dto_class.fields { + self.keep_member(field.name.clone()); + } + } + } + } + } + } + + /// Whether the class with this internal name stays. pub fn keeps_class(&self, internal_name: &str) -> bool { self.fixed.contains(internal_name) || self @@ -136,17 +189,17 @@ impl KeepRules { .any(|re| re.is_match(internal_name)) } - /// Нужно ли оставить член с таким именем (не переименовывать). + /// Whether a member with this name stays (is not renamed). pub fn keeps_member(&self, name: &str) -> bool { self.fixed_members.contains(name) } - /// Нужно ли оставить ресурс с данным путём. + /// Whether the resource at this path stays unrewritten. pub fn keeps_resource(&self, path: &str) -> bool { self.resource_patterns.iter().any(|re| re.is_match(path)) } - /// Число правил (для отчёта). + /// Rule count (for the report). pub fn len(&self) -> usize { self.class_patterns.len() + self.resource_patterns.len() @@ -154,35 +207,65 @@ impl KeepRules { + self.fixed_members.len() } - /// Есть ли вообще правила. + /// Whether there are any rules at all. pub fn is_empty(&self) -> bool { self.len() == 0 } } -/// Ошибка компиляции паттерна. +/// Whether an invoke is a databinding call taking a `Class` literal. +fn is_databind_call(owner: &str, name: &str) -> bool { + DATABIND_CALLS + .iter() + .any(|(candidate, names)| *candidate == owner && names.contains(&name)) +} + +/// The nearest `X.class` literal before a call site (control-flow +/// boundaries stop the search: argument setup is straight-line). +fn nearest_class_literal(before: &[Insn]) -> Option { + let start = before.len().saturating_sub(CLASS_LITERAL_WINDOW); + for insn in before[start..].iter().rev() { + match insn { + Insn::Const(Const::Class(name)) => return Some(name.clone()), + Insn::Branch(..) + | Insn::Goto(_) + | Insn::TableSwitch { .. } + | Insn::LookupSwitch { .. } + | Insn::Jsr(_) + | Insn::Ret(_) + | Insn::Return(_) + | Insn::AReturn + | Insn::AThrow => return None, + _ => {} + } + } + None +} + +/// A pattern compilation error. #[derive(Debug, thiserror::Error)] pub enum RuleError { - #[error("некорректный паттерн `{pattern}`: {msg}")] + #[error("malformed pattern `{pattern}`: {msg}")] Pattern { pattern: String, msg: String }, } -/// Полный анализ перед обфускацией. +/// The full pre-obfuscation analysis. #[derive(Debug, Clone)] pub struct Analysis { pub entrypoints: Entrypoints, pub keep: KeepRules, - /// Классы, которые не переименовываются. - pub locked_classes: std::collections::BTreeSet, + /// Classes that are not renamed. + pub locked_classes: BTreeSet, } impl Analysis { - /// Прогнать весь анализ по программе. + /// Run the whole analysis over the program. pub fn run(program: &Program, config: &Config) -> Result { let entrypoints = Entrypoints::collect(program); let mut keep = KeepRules::from_config(config)?; keep.absorb(&entrypoints); keep.absorb_bootstrap(program); + keep.absorb_databind(program); let locked_classes = program .classes @@ -198,40 +281,8 @@ impl Analysis { }) } - /// Класс запрещено переименовывать. + /// Renaming this class is forbidden. pub fn is_locked(&self, internal_name: &str) -> bool { self.locked_classes.contains(internal_name) } } - -#[cfg(test)] -mod tests { - #![allow(clippy::expect_used, clippy::unwrap_used)] - use super::*; - - #[test] - fn glob_semantics() { - let rules = KeepRules { - class_patterns: vec![ - compile("com/example/*").unwrap(), - compile("**/internal/**").unwrap(), - ], - resource_patterns: vec![compile("META-INF/services/*").unwrap()], - fixed: std::collections::BTreeSet::new(), - fixed_members: std::collections::BTreeSet::new(), - }; - assert!(rules.keeps_class("com/example/Main")); - assert!(!rules.keeps_class("com/example/sub/Main")); - assert!(rules.keeps_class("x/internal/y/Z")); - assert!(rules.keeps_resource("META-INF/services/a.b.C")); - assert!(!rules.keeps_resource("assets/mc/mod.json")); - } - - #[test] - fn exact_names_win() { - let mut rules = KeepRules::default(); - rules.keep_exact("app/Main"); - assert!(rules.keeps_class("app/Main")); - assert!(!rules.keeps_class("app/Other")); - } -} diff --git a/crates/noxis-analysis/tests/dto.rs b/crates/noxis-analysis/tests/dto.rs new file mode 100644 index 0000000..ac399f4 --- /dev/null +++ b/crates/noxis-analysis/tests/dto.rs @@ -0,0 +1,70 @@ +//! Databinding keep: `X.class` passed to Gson/Jackson locks the fields of `X`. +//! +//! JSON libraries bind fields by reflection from a `Class` literal, with no +//! field-name string in the bytecode for the existing string-based detection +//! to catch. Renaming such a field silently breaks deserialization, so every +//! field of the literal's class stays. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use noxis_analysis::Analysis; +use noxis_classfile::{ClassBuilder, Code, Const, Field, Insn, InvokeKind, Program, Version}; +use noxis_core::Config; + +/// `gson.fromJson(reader, State.class)`: `State` has a private field. +fn sample() -> Program { + let caller = Code { + max_stack: 3, + max_locals: 2, + insns: vec![ + Insn::Load { + slot: 1, + kind: noxis_classfile::SlotKind::Ref, + }, + Insn::Const(Const::Class("demo/State".to_string())), + Insn::Invoke { + kind: InvokeKind::Virtual, + owner: "com/google/gson/Gson".to_string(), + name: "fromJson".to_string(), + descriptor: "(Ljava/io/Reader;Ljava/lang/Class;)Ljava/lang/Object;".to_string(), + itf: false, + }, + Insn::Pop, + Insn::Return(None), + ], + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + }; + let mut state = ClassBuilder::new("demo/State", Some("java/lang/Object")) + .version(Version::JAVA_21) + .build(); + state.fields.push(Field { + access_flags: 0x0002, + name: "nickname".to_string(), + descriptor: "Ljava/lang/String;".to_string(), + attributes: Vec::new(), + }); + let app = ClassBuilder::new("demo/App", Some("java/lang/Object")) + .version(Version::JAVA_21) + .method_with_code(0x0009, "load", "()V", caller) + .build(); + Program { + classes: vec![state, app], + resources: Vec::new(), + } +} + +#[test] +fn databind_class_literal_locks_fields() { + let program = sample(); + let analysis = Analysis::run(&program, &Config::default()).expect("analysis"); + assert!( + analysis.keep.keeps_member("nickname"), + "DTO field must survive renaming" + ); + assert!( + !analysis.keep.keeps_member("load"), + "unrelated members must still rename" + ); +} diff --git a/crates/noxis-analysis/tests/keep.rs b/crates/noxis-analysis/tests/keep.rs new file mode 100644 index 0000000..ecc8354 --- /dev/null +++ b/crates/noxis-analysis/tests/keep.rs @@ -0,0 +1,33 @@ +//! Keep-rule unit tests: globs and exact names. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use noxis_analysis::KeepRules; +use noxis_core::Config; + +fn rules_with_patterns() -> KeepRules { + KeepRules::from_config(&Config { + keep: vec!["com/example/*".to_string(), "**/internal/**".to_string()], + keep_resources: vec!["META-INF/services/*".to_string()], + ..Config::default() + }) + .expect("patterns") +} + +#[test] +fn glob_semantics() { + let rules = rules_with_patterns(); + assert!(rules.keeps_class("com/example/Main")); + assert!(!rules.keeps_class("com/example/sub/Main")); + assert!(rules.keeps_class("x/internal/y/Z")); + assert!(rules.keeps_resource("META-INF/services/a.b.C")); + assert!(!rules.keeps_resource("assets/mc/mod.json")); +} + +#[test] +fn exact_names_win() { + let mut rules = KeepRules::default(); + rules.keep_exact("app/Main"); + assert!(rules.keeps_class("app/Main")); + assert!(!rules.keeps_class("app/Other")); +}