classfile,passes,jar: model PermittedSubclasses, normalize interface flags, name class in write errors

This commit is contained in:
Loki 2026-10-10 14:22:03 +02:00
parent e9d5e44b9e
commit 9a33cef9b1
Signed by: boba
GPG key ID: 253067914055423B
11 changed files with 299 additions and 85 deletions

View file

@ -47,6 +47,12 @@ pub(super) fn convert(attr: &RtAttr, pool: &ConstantPool<'_>) -> Result<Attribut
.map(|index| class_name(pool, *index))
.collect::<Result<_, _>>()?,
),
RtAttr::PermittedSubclasses { class_indexes, .. } => Attribute::PermittedSubclasses(
class_indexes
.iter()
.map(|index| class_name(pool, *index))
.collect::<Result<_, _>>()?,
),
RtAttr::EnclosingMethod {
class_index,
method_index,

View file

@ -11,6 +11,21 @@ use super::pool::{class_name, utf8};
use crate::error::ClassfileError;
use crate::model::{Attribute, ClassFile, Code, Field, Method, Version};
/// JVMS 4.1: an interface implies `ABSTRACT` and ignores `SUPER`. Some
/// compilers emit a bare `0x200` that HotSpot tolerates, but the strict
/// backend verifier rejects it on write (after widening, as `0x201`) - so
/// normalize once on parse. Interfaces are abstract by definition: no
/// behaviour changes.
fn normalize_class_flags(flags: u16) -> u16 {
const INTERFACE: u16 = 0x0200;
const SUPER: u16 = 0x0020;
const ABSTRACT: u16 = 0x0400;
if flags & INTERFACE == 0 {
return flags;
}
(flags | ABSTRACT) & !SUPER
}
/// Parse `.class` bytes into our IR.
pub(crate) fn parse_class(bytes: &[u8]) -> Result<ClassFile, ClassfileError> {
let rt = RtClass::from_bytes(bytes).map_err(|e| ClassfileError::Parse(e.to_string()))?;
@ -25,7 +40,7 @@ pub(crate) fn parse_class(bytes: &[u8]) -> Result<ClassFile, ClassfileError> {
minor: rt.version.minor(),
major: rt.version.major(),
},
access_flags: rt.access_flags.bits(),
access_flags: normalize_class_flags(rt.access_flags.bits()),
this_class: class_name(pool, rt.this_class)?,
super_class: optional_class(pool, rt.super_class)?,
interfaces,

View file

@ -145,6 +145,13 @@ pub(super) fn convert(attr: &Attribute, pool: &mut Pool) -> Result<RtAttr, Class
.map(|name| pool.class(name))
.collect::<Result<_, _>>()?,
},
Attribute::PermittedSubclasses(classes) => RtAttr::PermittedSubclasses {
name_index: pool.utf8("PermittedSubclasses")?,
class_indexes: classes
.iter()
.map(|name| pool.class(name))
.collect::<Result<_, _>>()?,
},
Attribute::ConstantValue(value) => RtAttr::ConstantValue {
name_index: pool.utf8("ConstantValue")?,
constant_value_index: pool.constant(value)?,

View file

@ -23,6 +23,9 @@ pub enum Attribute {
NestHost(String),
/// `NestMembers`: the member classes of this nest host (JVMS §4.7.29).
NestMembers(Vec<String>),
/// `PermittedSubclasses`: who may extend a sealed class (JVMS §4.7.31).
/// Stored as names: pool indices go stale when the pool is rebuilt.
PermittedSubclasses(Vec<String>),
/// A field's `ConstantValue` (`static final`): a value from the pool.
ConstantValue(Const),
/// `BootstrapMethods` for `invokedynamic` (lambdas): method + arguments.

View file

@ -0,0 +1,146 @@
//! Attribute round trips: bootstrap methods, nesting, flags, sealing.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::round_trip;
use ristretto_classfile::attributes::{
Attribute as RtAttr, BootstrapMethod as RtBsm, Instruction as Rt,
};
use ristretto_classfile::{
ClassAccessFlags, ClassFile as RtClass, ConstantPool, JAVA_21, Method as RtMethod,
MethodAccessFlags, ReferenceKind,
};
/// `BootstrapMethods` + `invokedynamic` survive the round trip.
#[test]
fn bootstrap_methods_round_trip() {
let mut pool = ConstantPool::default();
let this = pool.add_class("demo/Lambda").expect("add_class");
let object = pool.add_class("java/lang/Object").expect("add_object");
let owner = pool.add_class("demo/RT").expect("owner");
let method_ref = pool.add_method_ref(owner, "boot", "()V").expect("ref");
let handle = pool
.add_method_handle(ReferenceKind::InvokeStatic, method_ref)
.expect("handle");
let arg = pool.add_string("x").expect("arg");
let bsm_name = pool.add_utf8("BootstrapMethods").expect("attr");
let name = pool.add_utf8("run").expect("name");
let desc = pool.add_utf8("()V").expect("desc");
let code_name = pool.add_utf8("Code").expect("attr");
let dynamic = pool.add_invoke_dynamic(0, "run", "()V").expect("indy");
let rt = RtClass {
version: JAVA_21,
constant_pool: pool,
access_flags: ClassAccessFlags::PUBLIC,
this_class: this,
super_class: object,
interfaces: vec![],
fields: vec![],
methods: vec![RtMethod {
access_flags: MethodAccessFlags::PUBLIC | MethodAccessFlags::STATIC,
name_index: name,
descriptor_index: desc,
attributes: vec![RtAttr::Code {
name_index: code_name,
max_stack: 1,
max_locals: 0,
code: vec![Rt::Invokedynamic(dynamic), Rt::Return],
exception_table: vec![],
attributes: vec![],
}],
}],
attributes: vec![RtAttr::BootstrapMethods {
name_index: bsm_name,
methods: vec![RtBsm {
bootstrap_method_ref: handle,
arguments: vec![arg],
}],
}],
..RtClass::default()
};
let ir = round_trip(&rt);
assert_eq!(ir.attributes.len(), 1);
assert!(format!("{:?}", ir.attributes[0]).contains("BootstrapMethods"));
}
/// `NestMembers` переживает пересборку пула (баг: `ClassFormatError`).
#[test]
fn nest_attributes_round_trip() {
let mut pool = ConstantPool::default();
let this = pool.add_class("demo/Nest").expect("c");
let object = pool.add_class("java/lang/Object").expect("o");
let member = pool.add_class("demo/Nest$Inner").expect("m");
let mn = pool.add_utf8("NestMembers").expect("n");
let mems = RtAttr::NestMembers {
name_index: mn,
class_indexes: vec![this, member],
};
let rt = RtClass {
version: JAVA_21,
constant_pool: pool,
this_class: this,
super_class: object,
attributes: vec![mems],
..RtClass::default()
};
let ir = round_trip(&rt);
assert!(format!("{:?}", ir.attributes).contains("NestMembers"));
}
/// `parse → write → parse` даёт тот же IR; записанное верифицируется.
#[test]
fn bare_interface_flags_normalize_on_parse() {
use ristretto_classfile::ClassAccessFlags;
let mut pool = ConstantPool::default();
let this = pool.add_class("demo/Bare").expect("add_class");
let object = pool.add_class("java/lang/Object").expect("add_object");
let rt = RtClass {
version: JAVA_21,
constant_pool: pool,
access_flags: ClassAccessFlags::INTERFACE,
this_class: this,
super_class: object,
interfaces: vec![],
fields: vec![],
methods: vec![],
attributes: vec![],
..RtClass::default()
};
let ir = round_trip(&rt);
assert_eq!(ir.access_flags & 0x0200, 0x0200, "still an interface");
assert_eq!(
ir.access_flags & 0x0400,
0x0400,
"abstract is implied for interfaces"
);
}
/// `PermittedSubclasses` survives the round trip: its pool indices are
/// remapped on write (not copied verbatim), or the backend rejects the
/// rebuilt pool (`bad constant type`).
#[test]
fn permitted_subclasses_round_trip() {
let mut pool = ConstantPool::default();
let this = pool.add_class("demo/Sealed").expect("add_class");
let object = pool.add_class("java/lang/Object").expect("add_object");
let permitted = pool.add_class("demo/Impl").expect("add permitted");
let attr_name = pool.add_utf8("PermittedSubclasses").expect("attr");
let rt = RtClass {
version: JAVA_21,
constant_pool: pool,
access_flags: ClassAccessFlags::PUBLIC | ClassAccessFlags::ABSTRACT,
this_class: this,
super_class: object,
interfaces: vec![],
fields: vec![],
methods: vec![],
attributes: vec![RtAttr::PermittedSubclasses {
name_index: attr_name,
class_indexes: vec![permitted],
}],
..RtClass::default()
};
let ir = round_trip(&rt);
assert_eq!(ir.this_class, "demo/Sealed");
}

View file

@ -8,12 +8,12 @@
use noxis_classfile::{ClassFile, format};
use ristretto_classfile::attributes::{
Attribute as RtAttr, BootstrapMethod as RtBsm, ExceptionTableEntry as RtEntry,
Instruction as Rt, LineNumber as RtLine, StackFrame as RtFrame,
Attribute as RtAttr, ExceptionTableEntry as RtEntry, Instruction as Rt, LineNumber as RtLine,
StackFrame as RtFrame,
};
use ristretto_classfile::{
ClassAccessFlags, ClassFile as RtClass, ConstantPool, Field as RtField, FieldAccessFlags,
FieldType, JAVA_21, Method as RtMethod, MethodAccessFlags, ReferenceKind,
FieldType, JAVA_21, Method as RtMethod, MethodAccessFlags,
};
/// Минимальный класс: версия, флаги, поле с `ConstantValue`, `SourceFile`.
@ -151,85 +151,6 @@ fn code_with_branches_round_trip() {
assert_eq!(code.attributes.len(), 1);
}
/// `BootstrapMethods` + `invokedynamic` переживают round-trip.
#[test]
fn bootstrap_methods_round_trip() {
let mut pool = ConstantPool::default();
let this = pool.add_class("demo/Lambda").expect("add_class");
let object = pool.add_class("java/lang/Object").expect("add_object");
let owner = pool.add_class("demo/RT").expect("owner");
let method_ref = pool.add_method_ref(owner, "boot", "()V").expect("ref");
let handle = pool
.add_method_handle(ReferenceKind::InvokeStatic, method_ref)
.expect("handle");
let arg = pool.add_string("x").expect("arg");
let bsm_name = pool.add_utf8("BootstrapMethods").expect("attr");
let name = pool.add_utf8("run").expect("name");
let desc = pool.add_utf8("()V").expect("desc");
let code_name = pool.add_utf8("Code").expect("attr");
let dynamic = pool.add_invoke_dynamic(0, "run", "()V").expect("indy");
let rt = RtClass {
version: JAVA_21,
constant_pool: pool,
access_flags: ClassAccessFlags::PUBLIC,
this_class: this,
super_class: object,
interfaces: vec![],
fields: vec![],
methods: vec![RtMethod {
access_flags: MethodAccessFlags::PUBLIC | MethodAccessFlags::STATIC,
name_index: name,
descriptor_index: desc,
attributes: vec![RtAttr::Code {
name_index: code_name,
max_stack: 1,
max_locals: 0,
code: vec![Rt::Invokedynamic(dynamic), Rt::Return],
exception_table: vec![],
attributes: vec![],
}],
}],
attributes: vec![RtAttr::BootstrapMethods {
name_index: bsm_name,
methods: vec![RtBsm {
bootstrap_method_ref: handle,
arguments: vec![arg],
}],
}],
..RtClass::default()
};
let ir = round_trip(&rt);
assert_eq!(ir.attributes.len(), 1);
assert!(format!("{:?}", ir.attributes[0]).contains("BootstrapMethods"));
}
/// `NestMembers` переживает пересборку пула (баг: `ClassFormatError`).
#[test]
fn nest_attributes_round_trip() {
let mut pool = ConstantPool::default();
let this = pool.add_class("demo/Nest").expect("c");
let object = pool.add_class("java/lang/Object").expect("o");
let member = pool.add_class("demo/Nest$Inner").expect("m");
let mn = pool.add_utf8("NestMembers").expect("n");
let mems = RtAttr::NestMembers {
name_index: mn,
class_indexes: vec![this, member],
};
let rt = RtClass {
version: JAVA_21,
constant_pool: pool,
this_class: this,
super_class: object,
attributes: vec![mems],
..RtClass::default()
};
let ir = round_trip(&rt);
assert!(format!("{:?}", ir.attributes).contains("NestMembers"));
}
/// `parse → write → parse` даёт тот же IR; записанное верифицируется.
fn round_trip(rt: &RtClass<'_>) -> ClassFile {
let mut original = Vec::new();
rt.to_bytes(&mut original).expect("to_bytes");
@ -243,3 +164,8 @@ fn round_trip(rt: &RtClass<'_>) -> ClassFile {
assert_eq!(first, second, "IR обязан пережить round-trip");
second
}
/// A bare `0x200` interface (no `ABSTRACT`, as some compilers emit) must
/// survive the round trip: HotSpot tolerates it, so we normalize on parse
/// instead of dying in the backend's strict verifier on write.
mod attrs;

View file

@ -46,7 +46,13 @@ pub fn save(program: &Program) -> Result<Vec<Entry>, JarError> {
for class in &program.classes {
let name = format!("{}.class", class.this_class);
entries.push(Entry::new(name, format::write(class)?));
let bytes = format::write(class).map_err(|error| {
JarError::Classfile(noxis_classfile::ClassfileError::Write(format!(
"{}: {error}",
class.this_class
)))
})?;
entries.push(Entry::new(name, bytes));
}
for resource in &program.resources {
entries.push(Entry::new(resource.path.clone(), resource.data.clone()));

View file

@ -134,6 +134,13 @@ fn rewrite_attributes(
}
}
}
Attribute::PermittedSubclasses(classes) => {
for name in classes.iter_mut() {
if let Some(mapped) = maps.classes.get(name.as_str()) {
*name = mapped.clone();
}
}
}
Attribute::LocalVariableTable(vars) => {
for var in vars.iter_mut() {
var.descriptor = rewrite_descriptor(&var.descriptor, &maps.classes);

View file

@ -109,3 +109,33 @@ fn generic_signatures_rewrite_all_references() {
&format!("Ljava/lang/Object;Ljava/lang/Comparable<L{helper};>;")
);
}
/// Sealed classes keep working: `PermittedSubclasses` entries follow the
/// mapping (a stale entry breaks loading with `ClassFormatError`, and raw
/// pool indices would go stale on rebuild anyway).
#[test]
fn permitted_subclasses_follow_the_mapping() {
use noxis_classfile::{Attribute, ClassBuilder, Version};
let mut program = sample();
program.classes.push(
ClassBuilder::new("app/Sealed", Some("java/lang/Object"))
.version(Version::JAVA_21)
.attribute(Attribute::PermittedSubclasses(vec![
"app/Helper".to_string(),
]))
.build(),
);
let ctx = run_rename(&mut program);
let helper = ctx.mapping.class("app/Helper").expect("renamed");
let sealed_name = ctx.mapping.class("app/Sealed").expect("renamed");
let sealed = program.class(sealed_name).expect("sealed");
let permitted = sealed
.attributes
.iter()
.find_map(|attr| match attr {
Attribute::PermittedSubclasses(names) => Some(names),
_ => None,
})
.expect("permitted");
assert_eq!(permitted, &vec![helper.to_string()]);
}

View file

@ -16,5 +16,6 @@ pub use java::{
};
pub use leaks::{RED_PATTERNS, SecretHit, check_patterns, collect_names, find_leaks, scan_secrets};
pub use metrics::{
ProgramStats, check_inflation, inflation_ratio, mappings_changed, program_stats,
ProgramStats, RunOutput, check_inflation, check_slowdown, inflation_ratio, mappings_changed,
measure_runtime, program_stats, run_java, slowdown_ratio,
};

View file

@ -79,6 +79,62 @@ pub fn mappings_changed(before: &Mapping, after: &Mapping) -> bool {
before != after
}
/// Wall time of `java -cp <jar> <main_class> <args>` (stderr inherited).
pub fn measure_runtime(
jar: &std::path::Path,
main_class: &str,
args: &[&str],
) -> Option<std::time::Duration> {
run_java(jar, main_class, args).map(|run| run.elapsed)
}
/// One `java` run: wall time plus captured stdout (for checksum comparison).
#[derive(Debug, Clone)]
pub struct RunOutput {
pub elapsed: std::time::Duration,
pub stdout: Vec<u8>,
}
/// Run `java -cp <jar> <main_class> <args>`, capturing stdout.
/// `None` when `java` is missing, refuses to start, or the run fails:
/// a workload that cannot run has no budget to check.
pub fn run_java(jar: &std::path::Path, main_class: &str, args: &[&str]) -> Option<RunOutput> {
let start = std::time::Instant::now();
let output = std::process::Command::new("java")
.arg("-cp")
.arg(jar)
.arg(main_class)
.args(args)
.stderr(std::process::Stdio::null())
.output()
.ok()?;
output.status.success().then(|| RunOutput {
elapsed: start.elapsed(),
stdout: output.stdout,
})
}
/// Slowdown factor (`after / before`).
pub fn slowdown_ratio(before: std::time::Duration, after: std::time::Duration) -> f64 {
if before.is_zero() {
return f64::INFINITY;
}
after.as_secs_f64() / before.as_secs_f64()
}
/// Runtime within budget (`max_ratio`, e.g. `5.0` = at most five times slower).
pub fn check_slowdown(
before: std::time::Duration,
after: std::time::Duration,
max_ratio: f64,
) -> Result<(), String> {
let ratio = slowdown_ratio(before, after);
if ratio > max_ratio {
return Err(format!("slowdown {ratio:.2} over budget {max_ratio:.2}"));
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
@ -100,6 +156,17 @@ mod tests {
assert!(check_inflation(100, 200, 1.5).is_err());
}
#[test]
fn slowdown_math() {
use std::time::Duration;
assert!(
(slowdown_ratio(Duration::from_secs(2), Duration::from_secs(3)) - 1.5).abs() < 1e-9
);
assert!(slowdown_ratio(Duration::ZERO, Duration::from_secs(1)).is_infinite());
assert!(check_slowdown(Duration::from_secs(2), Duration::from_secs(4), 5.0).is_ok());
assert!(check_slowdown(Duration::from_secs(2), Duration::from_secs(11), 5.0).is_err());
}
#[test]
fn identical_mappings_are_stable() {
let mapping = Mapping::default();