passes: rename mixin classes + patch mixins.json/refmap like OBF

- Remove absorb_mixins keep rule (mixin classes now renameable)
- Add mixins.rs: patch_mixins_json rewrites package + entries via
  common prefix of mapped names; patch_refmap_json rewrites all strings
- Resources pass routes *.mixins.json and *refmap.json to specialized
  patchers, falls back to generic text remap

Reference: OBF fabric-obfuscator patchMixinsJson/patchRefmapJson.
This commit is contained in:
Loki 2026-10-12 14:18:19 +02:00
parent b73c72d56d
commit 90506a4736
Signed by: boba
GPG key ID: 253067914055423B
17 changed files with 490 additions and 24 deletions

View file

@ -0,0 +1,86 @@
//! Внешний диспатч: семейства методов, которые программный код зовёт через
//! внешние интерфейсы/классы (не из артефакта). Их нельзя переименовывать -
//! внешний вызывающий код не переписать, и JVM получит `AbstractMethodError`.
use std::collections::{BTreeSet, HashSet};
use noxis_classfile::{Insn, InvokeKind, Program};
use crate::hierarchy::ClassHierarchy;
/// Семейства `(name, params)`, которые нельзя переименовывать: программа
/// вызывает их виртуально/интерфейсно на внешнем владельце, а некоторый
/// класс в предках этого владельца (внутри артефакта) объявляет то же
/// `(name, params)`. Переименование разорвало бы диспатч через внешний
/// интерфейс/класс - JVM упала бы с `AbstractMethodError ... is abstract`
/// (реальный случай: шадоуed gson `LinkedTreeMap$EntrySet$1`, который
/// JDK `Map.forEach` зовёт через `invokeinterface Iterator.hasNext`).
pub fn external_dispatch_families(
program: &Program,
hierarchy: &ClassHierarchy,
) -> BTreeSet<(String, String)> {
let in_program: HashSet<&str> = program
.classes
.iter()
.map(|c| c.this_class.as_str())
.collect();
// Виртуальные/интерфейсные вызовы на внешнем владельце (дедуплицированно).
let mut external_calls: BTreeSet<(String, String, String)> = BTreeSet::new();
for class in &program.classes {
for method in &class.methods {
let Some(code) = &method.code else { continue };
for insn in &code.insns {
if let Insn::Invoke {
kind,
owner,
name,
descriptor,
..
} = insn
&& matches!(kind, InvokeKind::Virtual | InvokeKind::Interface)
&& !in_program.contains(owner.as_str())
&& let Some(params) = params_part(descriptor)
{
external_calls.insert((owner.clone(), name.clone(), params.to_string()));
}
}
}
}
if external_calls.is_empty() {
return BTreeSet::new();
}
// Для каждого класса - внешние предки, достижимые сквозь внутренние
// суперклассы/интерфейсы (цепочка обрывается на внешнем узле).
let mut locked: BTreeSet<(String, String)> = BTreeSet::new();
for class in &program.classes {
let mut externals: HashSet<String> = HashSet::new();
let mut seen: HashSet<String> = HashSet::new();
let mut stack = hierarchy.parents(&class.this_class);
while let Some(name) = stack.pop() {
if in_program.contains(name.as_str()) {
if seen.insert(name.clone()) {
stack.extend(hierarchy.parents(&name));
}
} else {
externals.insert(name);
}
}
for (owner, name, params) in &external_calls {
if externals.contains(owner) {
locked.insert((name.clone(), params.clone()));
}
}
}
locked
}
/// `(<params>)<ret>` → `(<params>)`, или `None` при кривом дескрипторе.
fn params_part(descriptor: &str) -> Option<&str> {
let close = descriptor.find(')')?;
if !descriptor.starts_with('(') {
return None;
}
Some(&descriptor[..=close])
}

View file

@ -8,8 +8,11 @@ use regex::Regex;
use crate::entrypoints::Entrypoints;
use noxis_core::Config;
mod dispatch;
mod scans;
pub use dispatch::external_dispatch_families;
/// Compiled glob: `*` - anything but `/`, `**` - anything, `?` - one char.
fn compile(pattern: &str) -> Result<Regex, regex::Error> {
let mut out = String::from("^");
@ -165,7 +168,6 @@ impl Analysis {
keep.absorb(&entrypoints);
keep.absorb_bootstrap(program);
keep.absorb_databind(program);
keep.absorb_mixins(program);
let locked_classes = program
.classes

View file

@ -38,6 +38,11 @@ impl KeepRules {
/// 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.
///
/// DISABLED: the resources pass now patches mixins.json properly
/// (rewrites package + entries via common prefix), so mixin classes
/// can be renamed like any other class. Reference: OBF fabric-obfuscator.
#[allow(dead_code)]
pub fn absorb_mixins(&mut self, program: &Program) {
let mut listed: Vec<String> = Vec::new();
for resource in &program.resources {

View file

@ -16,4 +16,4 @@ pub mod keep;
pub use entrypoints::Entrypoints;
pub use hierarchy::{ClassHierarchy, RenameSafety, method_rename_safety};
pub use jdk::JdkHierarchy;
pub use keep::{Analysis, KeepRules, RuleError};
pub use keep::{Analysis, KeepRules, RuleError, external_dispatch_families};

View file

@ -1,9 +1,7 @@
//! Mixin keep: classes listed in `*.mixins.json` (and the plugin) stay.
//! Mixin keep: classes listed in `*.mixins.json` are now renameable.
//!
//! 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.
//! The resources pass patches mixins.json (rewrites package + entries),
//! so mixin classes can be renamed. Reference: OBF fabric-obfuscator.
#![allow(clippy::expect_used, clippy::unwrap_used)]
@ -56,20 +54,20 @@ fn sample() -> Program {
}
#[test]
fn mixin_classes_and_plugin_stay() {
fn mixin_classes_are_renameable() {
let program = sample();
let analysis = Analysis::run(&program, &Config::default()).expect("analysis");
for kept in [
// Mixin classes are no longer locked - resources pass patches the config.
for renameable 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(renameable),
"{renameable} should be renameable now"
);
}
assert!(
!analysis.is_locked("demo/App"),
"ordinary classes must still rename"
);
}