analysis: keep-lock mixin classes and plugins from mixins.json configs
This commit is contained in:
parent
6a68c9d69b
commit
21a7c3df1a
5 changed files with 279 additions and 104 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -310,6 +310,7 @@ dependencies = [
|
||||||
"noxis-core",
|
"noxis-core",
|
||||||
"noxis-structure-check",
|
"noxis-structure-check",
|
||||||
"regex",
|
"regex",
|
||||||
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
noxis-classfile = { workspace = true }
|
noxis-classfile = { workspace = true }
|
||||||
noxis-core = { workspace = true }
|
noxis-core = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use noxis_classfile::{Attribute, Const, Insn, Program};
|
use noxis_classfile::Program;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
use crate::entrypoints::Entrypoints;
|
use crate::entrypoints::Entrypoints;
|
||||||
use noxis_core::Config;
|
use noxis_core::Config;
|
||||||
|
|
||||||
|
mod scans;
|
||||||
|
|
||||||
/// Compiled glob: `*` - anything but `/`, `**` - anything, `?` - one char.
|
/// Compiled glob: `*` - anything but `/`, `**` - anything, `?` - one char.
|
||||||
fn compile(pattern: &str) -> Result<Regex, regex::Error> {
|
fn compile(pattern: &str) -> Result<Regex, regex::Error> {
|
||||||
let mut out = String::from("^");
|
let mut out = String::from("^");
|
||||||
|
|
@ -38,28 +40,6 @@ fn compile(pattern: &str) -> Result<Regex, regex::Error> {
|
||||||
Regex::new(&out)
|
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.
|
/// "Do not touch" rules: explicit config patterns + entrypoints.
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct KeepRules {
|
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.
|
/// Whether the class with this internal name stays.
|
||||||
pub fn keeps_class(&self, internal_name: &str) -> bool {
|
pub fn keeps_class(&self, internal_name: &str) -> bool {
|
||||||
self.fixed.contains(internal_name)
|
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<String> {
|
|
||||||
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.
|
/// A pattern compilation error.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum RuleError {
|
pub enum RuleError {
|
||||||
|
|
@ -266,6 +165,7 @@ impl Analysis {
|
||||||
keep.absorb(&entrypoints);
|
keep.absorb(&entrypoints);
|
||||||
keep.absorb_bootstrap(program);
|
keep.absorb_bootstrap(program);
|
||||||
keep.absorb_databind(program);
|
keep.absorb_databind(program);
|
||||||
|
keep.absorb_mixins(program);
|
||||||
|
|
||||||
let locked_classes = program
|
let locked_classes = program
|
||||||
.classes
|
.classes
|
||||||
198
crates/noxis-analysis/src/keep/scans.rs
Normal file
198
crates/noxis-analysis/src/keep/scans.rs
Normal file
|
|
@ -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<String> = 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<String> {
|
||||||
|
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<String> {
|
||||||
|
let Ok(json) = serde_json::from_str::<serde_json::Value>(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<String> {
|
||||||
|
let Ok(json) = serde_json::from_str::<serde_json::Value>(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<String>| {
|
||||||
|
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
|
||||||
|
}
|
||||||
75
crates/noxis-analysis/tests/mixins.rs
Normal file
75
crates/noxis-analysis/tests/mixins.rs
Normal file
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue