analysis: lock DTO fields passed as X.class to databind calls

Gson/Jackson/fastjson2/Moshi bind fields by reflection from a Class
literal, with no field-name string for the string-based detection:
renaming such a field silently breaks deserialization. Absorb_databind
finds the nearest Const::Class before fromJson/readValue/parseObject/
adapter calls (10-insn window, control-flow boundaries stop the search)
and keeps every field of that class; the class itself still renames.

Tests: noxis-analysis/tests/dto.rs (red first), keep unit tests moved
to tests/keep.rs (file budget). Code comments migrated to English.
This commit is contained in:
Loki 2026-10-09 13:51:35 +02:00
parent 139aea9a8f
commit c3acc0baf4
Signed by: boba
GPG key ID: 253067914055423B
3 changed files with 215 additions and 61 deletions

View file

@ -0,0 +1,70 @@
//! Databinding keep: `X.class` passed to Gson/Jackson locks the fields of `X`.
//!
//! JSON libraries bind fields by reflection from a `Class` literal, with no
//! field-name string in the bytecode for the existing string-based detection
//! to catch. Renaming such a field silently breaks deserialization, so every
//! field of the literal's class stays.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use noxis_analysis::Analysis;
use noxis_classfile::{ClassBuilder, Code, Const, Field, Insn, InvokeKind, Program, Version};
use noxis_core::Config;
/// `gson.fromJson(reader, State.class)`: `State` has a private field.
fn sample() -> Program {
let caller = Code {
max_stack: 3,
max_locals: 2,
insns: vec![
Insn::Load {
slot: 1,
kind: noxis_classfile::SlotKind::Ref,
},
Insn::Const(Const::Class("demo/State".to_string())),
Insn::Invoke {
kind: InvokeKind::Virtual,
owner: "com/google/gson/Gson".to_string(),
name: "fromJson".to_string(),
descriptor: "(Ljava/io/Reader;Ljava/lang/Class;)Ljava/lang/Object;".to_string(),
itf: false,
},
Insn::Pop,
Insn::Return(None),
],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
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", caller)
.build();
Program {
classes: vec![state, app],
resources: Vec::new(),
}
}
#[test]
fn databind_class_literal_locks_fields() {
let program = sample();
let analysis = Analysis::run(&program, &Config::default()).expect("analysis");
assert!(
analysis.keep.keeps_member("nickname"),
"DTO field must survive renaming"
);
assert!(
!analysis.keep.keeps_member("load"),
"unrelated members must still rename"
);
}

View file

@ -0,0 +1,33 @@
//! Keep-rule unit tests: globs and exact names.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use noxis_analysis::KeepRules;
use noxis_core::Config;
fn rules_with_patterns() -> KeepRules {
KeepRules::from_config(&Config {
keep: vec!["com/example/*".to_string(), "**/internal/**".to_string()],
keep_resources: vec!["META-INF/services/*".to_string()],
..Config::default()
})
.expect("patterns")
}
#[test]
fn glob_semantics() {
let rules = rules_with_patterns();
assert!(rules.keeps_class("com/example/Main"));
assert!(!rules.keeps_class("com/example/sub/Main"));
assert!(rules.keeps_class("x/internal/y/Z"));
assert!(rules.keeps_resource("META-INF/services/a.b.C"));
assert!(!rules.keeps_resource("assets/mc/mod.json"));
}
#[test]
fn exact_names_win() {
let mut rules = KeepRules::default();
rules.keep_exact("app/Main");
assert!(rules.keeps_class("app/Main"));
assert!(!rules.keeps_class("app/Other"));
}