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

@ -11,6 +11,7 @@ noxis-analysis = { workspace = true }
noxis-classfile = { workspace = true }
noxis-core = { workspace = true }
noxis-runtime = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }

View file

@ -3,7 +3,7 @@
use std::collections::{BTreeMap, BTreeSet};
use noxis_analysis::{Analysis, ClassHierarchy};
use noxis_analysis::{Analysis, ClassHierarchy, external_dispatch_families};
use noxis_classfile::Program;
use noxis_core::{Config, Context, NameStyle, Rng};
@ -97,6 +97,7 @@ impl RenameMaps {
pub(super) fn build_virtual_families(
program: &Program,
analysis: &Analysis,
hierarchy: &ClassHierarchy,
) -> (BTreeMap<FamilyKey, Vec<MemberKey>>, BTreeSet<FamilyKey>) {
let mut families: BTreeMap<FamilyKey, Vec<MemberKey>> = BTreeMap::new();
let mut locked: BTreeSet<FamilyKey> = BTreeSet::new();
@ -124,6 +125,11 @@ pub(super) fn build_virtual_families(
));
}
}
// Семейства, которые диспатчатся через внешние интерфейсы/классы, обязаны
// сохранить имя: внешний вызывающий код не переименовать.
locked.extend(external_dispatch_families(program, hierarchy));
(families, locked)
}

View file

@ -64,7 +64,7 @@ impl Pass for RenamePass {
// rename splits the override chain (the JVM then fails dispatch
// with AbstractMethodError, real case: shaded gson's package-private
// `ReflectionHelper$RecordHelper.isRecord` chain).
let (families, locked_families) = build_virtual_families(program, &analysis);
let (families, locked_families) = build_virtual_families(program, &analysis, &hierarchy);
// 1. Class names.
{

View file

@ -0,0 +1,153 @@
//! Fabric mixins.json and refmap: rewrite package + all references.
//!
//! Mixins reference classes by short names relative to `package`.
//! Plain full-name text replacement does not work. Algorithm:
//! 1. Collect full paths of all mixin entries (package + short name).
//! 2. Find common prefix of mapped names.
//! 3. Rewrite `package` to that prefix, entries relative to it.
use std::collections::BTreeMap;
/// Patch mixins.json: package, entries in mixins/client/server, plugin.
pub fn patch_mixins_json(text: &str, map: &BTreeMap<String, String>) -> Option<String> {
let mut root: serde_json::Value = serde_json::from_str(text).ok()?;
let pkg_dot = root.get("package").and_then(|v| v.as_str()).unwrap_or("");
let pkg_path = pkg_dot.replace('.', "/");
let sections = ["mixins", "client", "server"];
// 1. Collect full paths of all entries.
let mut full_names: Vec<String> = Vec::new();
for section in sections {
let Some(arr) = root.get(section).and_then(|v| v.as_array()) else {
continue;
};
for el in arr {
let Some(short) = el.as_str() else { continue };
let full = if pkg_path.is_empty() {
short.replace('.', "/")
} else {
format!("{}/{}", pkg_path, short.replace('.', "/"))
};
let mapped = map.get(&full).cloned().unwrap_or(full);
full_names.push(mapped);
}
}
// 2. Common prefix of mapped names.
let common = common_prefix(&full_names);
let common_dot = common.replace('/', ".");
// 3. Rewrite package and entries.
for section in sections {
let Some(arr) = root.get(section).and_then(|v| v.as_array()) else {
continue;
};
let entries: Vec<String> = arr
.iter()
.filter_map(|el| el.as_str())
.map(|short| {
let full = if pkg_path.is_empty() {
short.replace('.', "/")
} else {
format!("{}/{}", pkg_path, short.replace('.', "/"))
};
let mapped = map.get(&full).cloned().unwrap_or(full);
let tail = if !common.is_empty() && mapped.starts_with(&format!("{common}/")) {
&mapped[common.len() + 1..]
} else {
&mapped
};
tail.replace('/', ".")
})
.collect();
root[section] =
serde_json::Value::Array(entries.into_iter().map(serde_json::Value::String).collect());
}
root["package"] = serde_json::Value::String(common_dot);
// 4. Plugin (full class name).
if let Some(plugin) = root.get("plugin").and_then(|v| v.as_str()) {
let cls = plugin.replace('.', "/");
if let Some(mapped) = map.get(&cls) {
root["plugin"] = serde_json::Value::String(mapped.replace('/', "."));
}
}
Some(serde_json::to_string_pretty(&root).unwrap_or_else(|_| text.to_string()))
}
/// Patch refmap.json: recursively replace all string values.
pub fn patch_refmap_json(text: &str, map: &BTreeMap<String, String>) -> Option<String> {
let mut root: serde_json::Value = serde_json::from_str(text).ok()?;
remap_json_value(&mut root, map);
Some(serde_json::to_string_pretty(&root).unwrap_or_else(|_| text.to_string()))
}
/// Recursively replace strings in JSON via mapping.
fn remap_json_value(value: &mut serde_json::Value, map: &BTreeMap<String, String>) {
match value {
serde_json::Value::Object(obj) => {
let remapped: serde_json::Map<String, serde_json::Value> = obj
.iter()
.map(|(key, val)| {
let new_key = remap_reference(key, map);
let mut new_val = val.clone();
remap_json_value(&mut new_val, map);
(new_key, new_val)
})
.collect();
*obj = remapped;
}
serde_json::Value::Array(arr) => {
for el in arr.iter_mut() {
remap_json_value(el, map);
}
}
serde_json::Value::String(s) => {
*s = remap_reference(s, map);
}
_ => {}
}
}
/// Replace class/method/field reference in a string.
fn remap_reference(s: &str, map: &BTreeMap<String, String>) -> String {
// Internal name (pkg/Class) - return internal form.
if let Some(mapped) = map.get(s) {
return mapped.clone();
}
// Dotted name (pkg.Class) - return dotted form.
let internal = s.replace('.', "/");
if internal != s
&& let Some(mapped) = map.get(&internal)
{
return mapped.replace('/', ".");
}
s.to_string()
}
/// Find common path prefix of names (separator `/`).
fn common_prefix(names: &[String]) -> String {
if names.is_empty() {
return String::new();
}
let parts: Vec<Vec<&str>> = names.iter().map(|s| s.split('/').collect()).collect();
let first = &parts[0];
if first.len() <= 1 {
return String::new();
}
let mut common = first.len() - 1;
for cur in parts.iter().skip(1) {
let mut k = 0;
while k < common && k < cur.len() && first[k] == cur[k] {
k += 1;
}
common = k;
}
if common == 0 {
return String::new();
}
first[..common].join("/")
}

View file

@ -14,12 +14,16 @@
#[cfg(test)]
mod tests;
mod mixins;
use std::cmp::Reverse;
use std::collections::BTreeMap;
use noxis_classfile::Program;
use noxis_core::{Context, CoreError, Pass, PassId, PassReport};
pub use mixins::{patch_mixins_json, patch_refmap_json};
/// Расширения текстовых ресурсов, которые имеют смысл переписывать.
const TEXT_EXTS: [&str; 9] = [
".json",
@ -68,7 +72,14 @@ impl Pass for ResourcesPass {
skipped += 1;
continue;
};
let updated = remap_text(text, &map);
let lower = resource.path.to_ascii_lowercase();
let updated = if lower.ends_with(".mixins.json") || lower.ends_with("mixins.json") {
patch_mixins_json(text, &map).unwrap_or_else(|| remap_text(text, &map))
} else if lower.ends_with("refmap.json") {
patch_refmap_json(text, &map).unwrap_or_else(|| remap_text(text, &map))
} else {
remap_text(text, &map)
};
if updated == text {
skipped += 1;
continue;

View file

@ -5,7 +5,7 @@
use noxis_classfile::{Program, Resource};
use noxis_core::{Config, Context, Pass};
use super::{ResourcesPass, is_text, remap_text};
use super::{ResourcesPass, is_text, patch_mixins_json, patch_refmap_json, remap_text};
fn mapping() -> std::collections::BTreeMap<String, String> {
let mut map = std::collections::BTreeMap::new();
@ -158,3 +158,78 @@ fn pass_is_a_noop_without_a_mapping() {
"должна быть заметка о пустом маппинге"
);
}
fn mixin_mapping() -> std::collections::BTreeMap<String, String> {
let mut map = std::collections::BTreeMap::new();
map.insert("dev/loki/mixins/MixinA".into(), "xYz/abc/MixinA".into());
map.insert("dev/loki/mixins/MixinB".into(), "xYz/abc/MixinB".into());
map.insert("dev/loki/mixins/Plugin".into(), "xYz/abc/Plugin".into());
map
}
#[test]
fn patch_mixins_json_rewrites_package_and_entries() {
let json = r#"{
"package": "dev.loki.mixins",
"plugin": "dev.loki.mixins.Plugin",
"mixins": ["MixinA", "MixinB"]
}"#;
let patched = patch_mixins_json(json, &mixin_mapping()).expect("patch");
assert!(
patched.contains("\"package\": \"xYz.abc\""),
"got: {patched}"
);
assert!(patched.contains("\"MixinA\""), "got: {patched}");
assert!(patched.contains("\"xYz.abc.Plugin\""), "got: {patched}");
}
#[test]
fn patch_mixins_json_handles_empty_package() {
let json = r#"{"mixins": ["a.b.MixinA"]}"#;
let mut map = std::collections::BTreeMap::new();
map.insert("a/b/MixinA".into(), "x/M".into());
let patched = patch_mixins_json(json, &map).expect("patch");
assert!(patched.contains("\"M\""), "got: {patched}");
}
#[test]
fn patch_refmap_json_rewrites_strings() {
let json = r#"{
"mappings": {
"dev/loki/mixins/MixinA": {"method_1234": "a/b/Target"}
},
"data": {"dev/loki/mixins/MixinA": {"method_1234": "field_5678"}}
}"#;
let mut map = std::collections::BTreeMap::new();
map.insert("a/b/Target".into(), "c/D".into());
let patched = patch_refmap_json(json, &map).expect("patch");
assert!(patched.contains("c/D"), "got: {patched}");
}
#[test]
fn pass_patches_mixins_json_via_specialized_patcher() {
let mut program = Program {
classes: Vec::new(),
resources: vec![Resource {
path: "lovisual.mixins.json".into(),
data: br#"{
"package": "dev.loki.mixins",
"mixins": ["MixinA"]
}"#
.to_vec(),
}],
};
let mut ctx = Context::new(Config::default());
ctx.mapping
.classes
.insert("dev/loki/mixins/MixinA".into(), "xYz/abc/MixinA".into());
let report = ResourcesPass
.apply(&mut ctx, &mut program)
.expect("resources");
assert_eq!(report.transformed, 1);
let text = String::from_utf8(program.resources[0].data.clone()).unwrap();
assert!(text.contains("\"package\": \"xYz.abc\""), "got: {text}");
}

View file

@ -103,3 +103,59 @@ fn package_private_overrides_stay_consistent_without_public_rename() {
"families off must keep the chain untouched, got {names:?}"
);
}
/// `demo/Iter` implements the external `java/util/Iterator`; `demo/Use` calls
/// `invokeinterface Iterator.hasNext`. The `(hasNext, ()Z)` family must keep
/// its original name: renaming it splits dispatch through the JDK interface
/// (the JVM then fails with `AbstractMethodError ... is abstract`, real case:
/// shaded gson's `LinkedTreeMap$EntrySet$1` iterator reached from
/// `Map.forEach`).
fn external_iterator_sample() -> Program {
let iter = ClassBuilder::new("demo/Iter", Some("java/lang/Object"))
.version(Version::JAVA_21)
.interface("java/util/Iterator")
.method_with_code(0x0001, "hasNext", "()Z", check_code())
.build();
let use_code = Code {
max_stack: 1,
max_locals: 1,
insns: vec![
Insn::AconstNull,
Insn::Invoke {
kind: noxis_classfile::InvokeKind::Interface,
owner: "java/util/Iterator".to_string(),
name: "hasNext".to_string(),
descriptor: "()Z".to_string(),
itf: true,
},
Insn::Return(Some(Prim::Int)),
],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
let use_class = ClassBuilder::new("demo/Use", Some("java/lang/Object"))
.version(Version::JAVA_21)
.method_with_code(0x0001, "use", "()Z", use_code)
.build();
Program {
classes: vec![iter, use_class],
resources: Vec::new(),
}
}
#[test]
fn externally_dispatched_interface_method_keeps_its_name() {
let mut program = external_iterator_sample();
run(&mut program, true);
let kept = program.classes.iter().any(|c| {
c.methods
.iter()
.any(|m| m.name == "hasNext" && m.descriptor == "()Z")
});
assert!(
kept,
"hasNext implements java/util/Iterator and must not be renamed"
);
}