From 3709d3ad35f505a3ba5aedc7454d9c18b16edecc Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Sun, 11 Oct 2026 13:42:08 +0200 Subject: [PATCH] analysis: lock shaded reflective packages (gson TypeAdapter-style dispatch) --- crates/noxis-analysis/src/keep/mod.rs | 1 + crates/noxis-analysis/src/keep/scans.rs | 72 ++++++++++++++- crates/noxis-analysis/tests/shaded.rs | 118 ++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 crates/noxis-analysis/tests/shaded.rs diff --git a/crates/noxis-analysis/src/keep/mod.rs b/crates/noxis-analysis/src/keep/mod.rs index 1247f3c..13a4916 100644 --- a/crates/noxis-analysis/src/keep/mod.rs +++ b/crates/noxis-analysis/src/keep/mod.rs @@ -166,6 +166,7 @@ impl Analysis { keep.absorb_bootstrap(program); keep.absorb_databind(program); keep.absorb_mixins(program); + keep.absorb_shaded(program); let locked_classes = program .classes diff --git a/crates/noxis-analysis/src/keep/scans.rs b/crates/noxis-analysis/src/keep/scans.rs index 89a9184..4d69aa1 100644 --- a/crates/noxis-analysis/src/keep/scans.rs +++ b/crates/noxis-analysis/src/keep/scans.rs @@ -1,9 +1,32 @@ //! Program scans feeding keep rules: bootstrap methods, databind literals, mixins. -use noxis_classfile::{Attribute, Const, Insn, Program}; +use noxis_classfile::{Attribute, Const, Insn, Method, Program}; use super::KeepRules; +/// Whether an invoke reaches reflective method lookup or invocation. +fn is_reflective_dispatch(owner: &str, name: &str) -> bool { + matches!( + (owner, name), + ("java/lang/Class", "getDeclaredMethod") + | ("java/lang/Class", "getMethod") + | ("java/lang/reflect/Method", "invoke") + ) +} + +/// The package prefix (`com/foo/bar`) of an internal class name. +fn package_of(internal_name: &str) -> &str { + match internal_name.rfind('/') { + Some(index) => &internal_name[..index], + None => "", + } +} + +/// Whether this method is `public abstract` (a `TypeAdapter`-style hook). +fn is_public_abstract(method: &Method) -> bool { + method.access_flags & 0x0401 == 0x0401 +} + impl KeepRules { /// Methods referenced by `BootstrapMethods` (`invokedynamic`). /// @@ -64,6 +87,53 @@ impl KeepRules { } } + /// Lock shaded reflective libraries: a package holding both a + /// `public abstract` class (the `TypeAdapter`-style hook) and another + /// class that looks up or invokes methods reflectively (`getDeclaredMethod`, + /// `getMethod`, `Method.invoke`). Gson/Jackson/Moshi dispatch their adapter + /// methods through reflection, so renaming an abstract `read`/`write` + /// while the wrapper still calls it reflectively yields + /// `AbstractMethodError` at runtime. Package-locking the whole subtree is + /// the only sound answer - a member-level rule cannot see the reflective + /// call site on a renamed adapter. + pub fn absorb_shaded(&mut self, program: &Program) { + use std::collections::BTreeMap; + let mut packages: BTreeMap> = BTreeMap::new(); + for class in &program.classes { + let package = package_of(&class.this_class); + packages.entry(package.to_string()).or_default().push(class); + } + for (package, classes) in &packages { + if package.is_empty() { + continue; + } + let has_abstract = classes + .iter() + .any(|class| class.methods.iter().any(is_public_abstract)); + let has_reflective = classes.iter().any(|class| { + class.methods.iter().any(|method| { + method.code.as_ref().is_some_and(|code| { + code.insns.iter().any(|insn| { + matches!(insn, Insn::Invoke { owner, name, .. } + if is_reflective_dispatch(owner.as_str(), name.as_str())) + }) + }) + }) + }); + if !(has_abstract && has_reflective) { + continue; + } + for class in classes { + self.keep_exact(class.this_class.clone()); + for method in &class.methods { + if is_public_abstract(method) { + self.keep_member(method.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 diff --git a/crates/noxis-analysis/tests/shaded.rs b/crates/noxis-analysis/tests/shaded.rs new file mode 100644 index 0000000..88aa701 --- /dev/null +++ b/crates/noxis-analysis/tests/shaded.rs @@ -0,0 +1,118 @@ +//! Shaded reflective libraries: a package containing a public abstract class +//! whose virtual methods are dispatched reflectively by another class in the +//! same package must keep its class and member names. +//! +//! Gson's `TypeAdapter` is the archetype: `TypeAdapterRuntimeTypeWrapper` +//! calls `getDeclaredMethod`/`Method.invoke` on an adapter instance. Renaming +//! the `read` method in `TypeAdapter` while the wrapper invokes it reflectively +//! yields `AbstractMethodError` at runtime. Package-locking the whole shaded +//! subtree is the only sound answer. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use noxis_analysis::Analysis; +use noxis_classfile::{ + ClassBuilder, Code, Const, Field, Insn, InvokeKind, Method, Program, Version, +}; +use noxis_core::Config; + +fn ret_code() -> Code { + Code { + max_stack: 0, + max_locals: 0, + insns: vec![Insn::Return(None)], + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + } +} + +/// `libs/gson/TypeAdapter` (abstract, public) with an abstract `read`, +/// plus `libs/gson/TypeAdapterRuntimeTypeWrapper` invoking `read` +/// reflectively through `getDeclaredMethod`/`invoke`. +fn sample() -> Program { + let mut abstract_adapter = ClassBuilder::new("libs/gson/TypeAdapter", Some("java/lang/Object")) + .version(Version::JAVA_21) + .build(); + abstract_adapter.methods.push(Method { + access_flags: 0x0401, // public abstract + name: "read".to_string(), + descriptor: "(Ljava/io/InputStream;)Ljava/lang/Object;".to_string(), + code: None, + attributes: Vec::new(), + }); + + let wrapper_code = Code { + max_stack: 4, + max_locals: 2, + insns: vec![ + Insn::Load { + slot: 1, + kind: noxis_classfile::SlotKind::Ref, + }, + Insn::Const(Const::String("read".to_string())), + Insn::Const(Const::Class("java/io/InputStream".to_string())), + Insn::Invoke { + kind: InvokeKind::Virtual, + owner: "java/lang/Class".to_string(), + name: "getDeclaredMethod".to_string(), + descriptor: "(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;" + .to_string(), + itf: false, + }, + Insn::Pop, + Insn::Return(None), + ], + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + }; + let wrapper = ClassBuilder::new( + "libs/gson/TypeAdapterRuntimeTypeWrapper", + Some("java/lang/Object"), + ) + .version(Version::JAVA_21) + .method_with_code(0x0001, "wrap", "(Ljava/lang/Object;)V", wrapper_code) + .build(); + + 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", ret_code()) + .build(); + + Program { + classes: vec![abstract_adapter, wrapper, state, app], + resources: Vec::new(), + } +} + +#[test] +fn shaded_reflective_package_is_locked() { + let program = sample(); + let analysis = Analysis::run(&program, &Config::default()).expect("analysis"); + assert!( + analysis.is_locked("libs/gson/TypeAdapter"), + "abstract adapter in a reflective package must stay" + ); + assert!( + analysis.is_locked("libs/gson/TypeAdapterRuntimeTypeWrapper"), + "the reflective wrapper must stay too" + ); + assert!( + analysis.keep.keeps_member("read"), + "the reflectively dispatched member must keep its name" + ); + assert!( + !analysis.is_locked("demo/State"), + "unrelated classes must still rename" + ); +}