From 21a7c3df1a5cabf30d21adbff636c2f65a2ea50b Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Sat, 10 Oct 2026 10:02:38 +0200 Subject: [PATCH] analysis: keep-lock mixin classes and plugins from mixins.json configs --- Cargo.lock | 1 + crates/noxis-analysis/Cargo.toml | 1 + .../src/{keep.rs => keep/mod.rs} | 108 +--------- crates/noxis-analysis/src/keep/scans.rs | 198 ++++++++++++++++++ crates/noxis-analysis/tests/mixins.rs | 75 +++++++ 5 files changed, 279 insertions(+), 104 deletions(-) rename crates/noxis-analysis/src/{keep.rs => keep/mod.rs} (58%) create mode 100644 crates/noxis-analysis/src/keep/scans.rs create mode 100644 crates/noxis-analysis/tests/mixins.rs diff --git a/Cargo.lock b/Cargo.lock index 364e024..22e3e80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -310,6 +310,7 @@ dependencies = [ "noxis-core", "noxis-structure-check", "regex", + "serde_json", "thiserror", "tracing", ] diff --git a/crates/noxis-analysis/Cargo.toml b/crates/noxis-analysis/Cargo.toml index 40224a6..4a65dac 100644 --- a/crates/noxis-analysis/Cargo.toml +++ b/crates/noxis-analysis/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [dependencies] regex = { workspace = true } +serde_json = { workspace = true } noxis-classfile = { workspace = true } noxis-core = { workspace = true } thiserror = { workspace = true } diff --git a/crates/noxis-analysis/src/keep.rs b/crates/noxis-analysis/src/keep/mod.rs similarity index 58% rename from crates/noxis-analysis/src/keep.rs rename to crates/noxis-analysis/src/keep/mod.rs index b3e323c..1247f3c 100644 --- a/crates/noxis-analysis/src/keep.rs +++ b/crates/noxis-analysis/src/keep/mod.rs @@ -2,12 +2,14 @@ use std::collections::BTreeSet; -use noxis_classfile::{Attribute, Const, Insn, Program}; +use noxis_classfile::Program; use regex::Regex; use crate::entrypoints::Entrypoints; use noxis_core::Config; +mod scans; + /// Compiled glob: `*` - anything but `/`, `**` - anything, `?` - one char. fn compile(pattern: &str) -> Result { let mut out = String::from("^"); @@ -38,28 +40,6 @@ fn compile(pattern: &str) -> Result { Regex::new(&out) } -/// 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 { @@ -128,58 +108,6 @@ impl KeepRules { } } - /// Methods referenced by `BootstrapMethods` (`invokedynamic`). - /// - /// 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 { - for attr in &method.attributes { - if let Attribute::BootstrapMethods(methods) = attr { - for bsm in methods { - self.keep_member(bsm.name.clone()); - for arg in &bsm.args { - if let Const::MethodHandle { name, .. } = arg { - self.keep_member(name.clone()); - } - } - } - } - } - } - } - } - - /// 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) @@ -213,35 +141,6 @@ impl KeepRules { } } -/// 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 { @@ -266,6 +165,7 @@ impl Analysis { keep.absorb(&entrypoints); keep.absorb_bootstrap(program); keep.absorb_databind(program); + keep.absorb_mixins(program); let locked_classes = program .classes diff --git a/crates/noxis-analysis/src/keep/scans.rs b/crates/noxis-analysis/src/keep/scans.rs new file mode 100644 index 0000000..89a9184 --- /dev/null +++ b/crates/noxis-analysis/src/keep/scans.rs @@ -0,0 +1,198 @@ +//! Program scans feeding keep rules: bootstrap methods, databind literals, mixins. + +use noxis_classfile::{Attribute, Const, Insn, Program}; + +use super::KeepRules; + +impl KeepRules { + /// Methods referenced by `BootstrapMethods` (`invokedynamic`). + /// + /// 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 { + for attr in &method.attributes { + if let Attribute::BootstrapMethods(methods) = attr { + for bsm in methods { + self.keep_member(bsm.name.clone()); + for arg in &bsm.args { + if let Const::MethodHandle { name, .. } = arg { + self.keep_member(name.clone()); + } + } + } + } + } + } + } + } + + /// Lock every Mixin class (and the plugin) listed in the configs. + /// + /// The Mixin framework resolves mixin classes and their members by name + /// at runtime, so renaming them breaks the mod at startup. Entries use + /// simple names relative to the `package` field, which generic text + /// patching can never see - locking is the only sound answer. Locking + /// the class also freezes its members (locked classes skip member + /// renaming entirely), which the `@Accessor`/`@Invoker`/`@Overwrite` + /// lookups need. Mixins configs are found two ways: any resource ending + /// in `mixins.json`, plus whatever `fabric.mod.json` lists. + pub fn absorb_mixins(&mut self, program: &Program) { + let mut listed: Vec = Vec::new(); + for resource in &program.resources { + if !resource.path.ends_with("fabric.mod.json") { + continue; + } + if let Ok(text) = std::str::from_utf8(&resource.data) { + listed.extend(mixin_config_names(text)); + } + } + for resource in &program.resources { + let is_mixins = resource.path.ends_with("mixins.json"); + let is_listed = listed.iter().any(|name| resource.path.ends_with(name)); + if !is_mixins && !is_listed { + continue; + } + if let Ok(text) = std::str::from_utf8(&resource.data) { + for class in mixin_classes(text) { + if program.class(&class).is_some() { + self.keep_exact(class); + } + } + } + } + } + + /// 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()); + } + } + } + } + } + } +} + +/// 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; + +/// 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 +} + +/// Filenames from a `fabric.mod.json` "mixins" array. +fn mixin_config_names(text: &str) -> Vec { + let Ok(json) = serde_json::from_str::(text) else { + return Vec::new(); + }; + json.get("mixins") + .and_then(|value| value.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +/// Mixin classes (internal names) from one mixins config. +/// +/// Entries may be simple (relative to `package`) or already qualified; +/// both spellings are returned and the caller keeps whichever exists. +/// The `plugin` class is included the same way. +fn mixin_classes(text: &str) -> Vec { + let Ok(json) = serde_json::from_str::(text) else { + return Vec::new(); + }; + let package = json + .get("package") + .and_then(|value| value.as_str()) + .unwrap_or("") + .replace('.', "/"); + let mut out = Vec::new(); + let push = |entry: &str, out: &mut Vec| { + out.push(entry.replace('.', "/")); + if !package.is_empty() { + out.push(format!("{package}/{}", entry.replace('.', "/"))); + } + }; + for key in ["mixins", "client", "server"] { + if let Some(items) = json.get(key).and_then(|value| value.as_array()) { + for item in items { + if let Some(entry) = item.as_str() { + push(entry, &mut out); + } + } + } + } + if let Some(plugin) = json.get("plugin").and_then(|value| value.as_str()) { + push(plugin, &mut out); + } + out +} diff --git a/crates/noxis-analysis/tests/mixins.rs b/crates/noxis-analysis/tests/mixins.rs new file mode 100644 index 0000000..f4718e8 --- /dev/null +++ b/crates/noxis-analysis/tests/mixins.rs @@ -0,0 +1,75 @@ +//! Mixin keep: classes listed in `*.mixins.json` (and the plugin) stay. +//! +//! The Mixin framework resolves mixin classes and their members by name at +//! runtime, so renaming them breaks the mod at startup. Entries use simple +//! names relative to the `package` field, which generic text patching can +//! never see - locking is the only sound answer. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use noxis_analysis::Analysis; +use noxis_classfile::{ClassBuilder, Program, Resource, Version}; +use noxis_core::Config; + +const MIXINS: &str = r#"{ + "package": "demo.mixin", + "plugin": "demo.mixin.Plugin", + "mixins": ["TargetMixin", "other.Helper"], + "client": ["ClientMixin"] +}"#; + +const FABRIC: &str = r#"{ + "id": "demo", + "mixins": ["demo.mixins.json"] +}"#; + +fn sample() -> Program { + let mut program = Program { + classes: vec![ + ClassBuilder::new("demo/mixin/TargetMixin", Some("java/lang/Object")) + .version(Version::JAVA_21) + .build(), + ClassBuilder::new("demo/mixin/other/Helper", Some("java/lang/Object")) + .version(Version::JAVA_21) + .build(), + ClassBuilder::new("demo/mixin/ClientMixin", Some("java/lang/Object")) + .version(Version::JAVA_21) + .build(), + ClassBuilder::new("demo/mixin/Plugin", Some("java/lang/Object")) + .version(Version::JAVA_21) + .build(), + ClassBuilder::new("demo/App", Some("java/lang/Object")) + .version(Version::JAVA_21) + .build(), + ], + resources: Vec::new(), + }; + program.resources.push(Resource { + path: "demo.mixins.json".into(), + data: MIXINS.as_bytes().to_vec(), + }); + program.resources.push(Resource { + path: "fabric.mod.json".into(), + data: FABRIC.as_bytes().to_vec(), + }); + program +} + +#[test] +fn mixin_classes_and_plugin_stay() { + let program = sample(); + let analysis = Analysis::run(&program, &Config::default()).expect("analysis"); + + for kept in [ + "demo/mixin/TargetMixin", + "demo/mixin/other/Helper", + "demo/mixin/ClientMixin", + "demo/mixin/Plugin", + ] { + assert!(analysis.is_locked(kept), "{kept} must survive renaming"); + } + assert!( + !analysis.is_locked("demo/App"), + "ordinary classes must still rename" + ); +}