diff --git a/crates/noxis-classfile/src/format/parse/attrs.rs b/crates/noxis-classfile/src/format/parse/attrs.rs index beaf2a0..d983318 100644 --- a/crates/noxis-classfile/src/format/parse/attrs.rs +++ b/crates/noxis-classfile/src/format/parse/attrs.rs @@ -47,6 +47,12 @@ pub(super) fn convert(attr: &RtAttr, pool: &ConstantPool<'_>) -> Result>()?, ), + RtAttr::PermittedSubclasses { class_indexes, .. } => Attribute::PermittedSubclasses( + class_indexes + .iter() + .map(|index| class_name(pool, *index)) + .collect::>()?, + ), RtAttr::EnclosingMethod { class_index, method_index, diff --git a/crates/noxis-classfile/src/format/parse/class.rs b/crates/noxis-classfile/src/format/parse/class.rs index a9fdc86..2e6ee58 100644 --- a/crates/noxis-classfile/src/format/parse/class.rs +++ b/crates/noxis-classfile/src/format/parse/class.rs @@ -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 { 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 { 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, diff --git a/crates/noxis-classfile/src/format/write/attrs.rs b/crates/noxis-classfile/src/format/write/attrs.rs index fe9319f..43580b7 100644 --- a/crates/noxis-classfile/src/format/write/attrs.rs +++ b/crates/noxis-classfile/src/format/write/attrs.rs @@ -145,6 +145,13 @@ pub(super) fn convert(attr: &Attribute, pool: &mut Pool) -> Result>()?, }, + Attribute::PermittedSubclasses(classes) => RtAttr::PermittedSubclasses { + name_index: pool.utf8("PermittedSubclasses")?, + class_indexes: classes + .iter() + .map(|name| pool.class(name)) + .collect::>()?, + }, Attribute::ConstantValue(value) => RtAttr::ConstantValue { name_index: pool.utf8("ConstantValue")?, constant_value_index: pool.constant(value)?, diff --git a/crates/noxis-classfile/src/model/mod.rs b/crates/noxis-classfile/src/model/mod.rs index fea3184..87c2948 100644 --- a/crates/noxis-classfile/src/model/mod.rs +++ b/crates/noxis-classfile/src/model/mod.rs @@ -23,6 +23,9 @@ pub enum Attribute { NestHost(String), /// `NestMembers`: the member classes of this nest host (JVMS §4.7.29). NestMembers(Vec), + /// `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), /// A field's `ConstantValue` (`static final`): a value from the pool. ConstantValue(Const), /// `BootstrapMethods` for `invokedynamic` (lambdas): method + arguments. diff --git a/crates/noxis-classfile/tests/roundtrip/attrs.rs b/crates/noxis-classfile/tests/roundtrip/attrs.rs new file mode 100644 index 0000000..1fa776d --- /dev/null +++ b/crates/noxis-classfile/tests/roundtrip/attrs.rs @@ -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"); +} diff --git a/crates/noxis-classfile/tests/roundtrip.rs b/crates/noxis-classfile/tests/roundtrip/main.rs similarity index 64% rename from crates/noxis-classfile/tests/roundtrip.rs rename to crates/noxis-classfile/tests/roundtrip/main.rs index ed813ae..0152923 100644 --- a/crates/noxis-classfile/tests/roundtrip.rs +++ b/crates/noxis-classfile/tests/roundtrip/main.rs @@ -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; diff --git a/crates/noxis-jar/src/loader.rs b/crates/noxis-jar/src/loader.rs index 965fe4b..79ea76f 100644 --- a/crates/noxis-jar/src/loader.rs +++ b/crates/noxis-jar/src/loader.rs @@ -46,7 +46,13 @@ pub fn save(program: &Program) -> Result, 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())); diff --git a/crates/noxis-passes/src/rename/rewrite/mod.rs b/crates/noxis-passes/src/rename/rewrite/mod.rs index d10be8a..99a47f5 100644 --- a/crates/noxis-passes/src/rename/rewrite/mod.rs +++ b/crates/noxis-passes/src/rename/rewrite/mod.rs @@ -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); diff --git a/crates/noxis-passes/src/rename/tests/rewrite.rs b/crates/noxis-passes/src/rename/tests/rewrite.rs index 48c6af5..16bdaf1 100644 --- a/crates/noxis-passes/src/rename/tests/rewrite.rs +++ b/crates/noxis-passes/src/rename/tests/rewrite.rs @@ -109,3 +109,33 @@ fn generic_signatures_rewrite_all_references() { &format!("Ljava/lang/Object;Ljava/lang/Comparable;") ); } + +/// 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()]); +} diff --git a/crates/noxis-verify/src/lib.rs b/crates/noxis-verify/src/lib.rs index 431920f..679daf5 100644 --- a/crates/noxis-verify/src/lib.rs +++ b/crates/noxis-verify/src/lib.rs @@ -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, }; diff --git a/crates/noxis-verify/src/metrics.rs b/crates/noxis-verify/src/metrics.rs index 93e5122..2d9cc61 100644 --- a/crates/noxis-verify/src/metrics.rs +++ b/crates/noxis-verify/src/metrics.rs @@ -79,6 +79,62 @@ pub fn mappings_changed(before: &Mapping, after: &Mapping) -> bool { before != after } +/// Wall time of `java -cp ` (stderr inherited). +pub fn measure_runtime( + jar: &std::path::Path, + main_class: &str, + args: &[&str], +) -> Option { + 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, +} + +/// Run `java -cp `, 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 { + 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();