diff --git a/REPORT.md b/REPORT.md new file mode 100644 index 00000000..b9d26e9b --- /dev/null +++ b/REPORT.md @@ -0,0 +1,28 @@ +# REPORT + +## [2026-07-22] 클래스파일 파싱 실패 → ClassFormatError 전파 (rustjava-classfile-parse-error-propagation) +- 무엇을: `ClassInfo::parse` 를 `Option` → `Result<_, ParseError>` 로 바꿔 실패 원인(절단/매직 + 불일치/미지원 상수풀 태그 N/기타 손상)을 담고, `from_classfile` 의 `unwrap()`/`assert_eq!` 를 + 제거해 `define_class` 에서 기존 예외 관례(`jvm.exception`)대로 `java.lang.ClassFormatError` 로 + 올림. `java/lang/ClassFormatError` 런타임 클래스(부모 LinkageError) 신설. +- 왜: 손상되거나 미지원 항목(javac 9+ 가 기본으로 심는 invokedynamic 계열 태그 15~18)을 가진 + class 파일을 여는 순간 Rust 패닉으로 프로세스(임베딩 호스트 포함)가 즉사했음. "클래스 못 찾음" + 은 예외인데 "못 읽음"만 패닉인 비대칭. +- 사용자 영향: 잘못된 class 파일이 진단 가능한 자바 예외(원인 메시지 포함)로 보고되고 프로세스는 + 살아남음. 미지원 태그는 명확히 거절(구현 아님). `tests/test_class_format.rs` 4케이스(절단/태그 + 18/매직/못찾음 대조군)가 회귀 잠금. +- 후속 추천: ① invokedynamic/MethodHandle 실제 지원(별건 대형), ② 상수풀 인덱스 참조 + (`.get().unwrap()` 계열) 손상 대응(별건), ③ UnsupportedClassVersionError 도입 검토(major + version 기반). + +## [2026-07-22] 시간 API 패닉 제거 + 회귀 잠금 (rustjava-runtime-time-todo-impl) +- 무엇을: `src/runtime.rs`의 `RuntimeImpl` 에서 `now()`/`sleep()`/`r#yield()` 의 `todo!()` 를 실제 + 구현(UNIX epoch ms·`tokio::time::sleep`·`tokio::task::yield_now`)으로 교체하고, `test_utils` + `TestRuntime::r#yield` 의 `todo!()` 도 동형으로 구현. 루트 `Cargo.toml` tokio 에 `time` 피처 추가. +- 왜: 배포 바이너리가 `System.currentTimeMillis()`·`Thread.sleep()`·`new Date()` 등 시간 API 를 + 부르는 순간 Rust 패닉으로 즉사했으나, 테스트 코퍼스가 해당 API 를 0건 사용해 CI 가 초록이었음. +- 사용자 영향: 시간 API 를 쓰는 모든 자바 프로그램이 이제 정상 동작. `test_data/TimeApi` + 픽스처(currentTimeMillis/yield/sleep/Date, 결정론적 단언)가 `RuntimeImpl` 경로 통합 테스트로 + 상시 회귀 감시. +- 후속 추천: ① `jvm_rust/src/interpreter.rs:629` 의 잔여 `todo!()` 제거(별건), ② Timer/Object.wait + 경로도 픽스처 확장, ③ 픽스처 .java 소스 보관 체계(현재 .class+.txt 만 커밋하는 관례). diff --git a/STATE.md b/STATE.md new file mode 100644 index 00000000..c17697c3 --- /dev/null +++ b/STATE.md @@ -0,0 +1,18 @@ +# STATE + +## 진행중 +- (없음) + +## 완료 +- [rustjava-runtime-time-todo-impl] RuntimeImpl 시간 API `todo!()` 3건 제거(now/sleep/yield) + + test_utils `r#yield` 구현 + tokio `time` 피처 추가 + 회귀 잠금 픽스처(`test_data/TimeApi`). + 브랜치 `runtime-time-impl`, PR #2 게이트② 대기. +- [rustjava-classfile-parse-error-propagation] 클래스파일 파싱 실패를 패닉 대신 + `java.lang.ClassFormatError` 로 전파(절단/매직 불일치/미지원 상수풀 태그 구분). + 브랜치 `classfile-parse-error-propagation`, PR 게이트② 대기. + +## 다음 +- PR approve 후 머지, 브랜치 정리(`gh pr merge --delete-branch` → `git branch -D` → `git fetch --prune`) +- ★두 PR 모두 STATE.md/REPORT.md 를 추가하므로 나중에 머지되는 쪽에서 add/add 충돌 예상 — + 선행 PR 머지 후 후행 브랜치에 `git merge main` 하고 후행(superset) 내용 채택으로 해소. +- (범위 밖 잔여) `jvm_rust/src/interpreter.rs:629` `todo!()` — 별건 티켓 필요 diff --git a/classfile/src/class.rs b/classfile/src/class.rs index e78a8eef..01f3a15a 100644 --- a/classfile/src/class.rs +++ b/classfile/src/class.rs @@ -8,7 +8,9 @@ use nom::{ use java_constants::ClassAccessFlags; -use crate::{attribute::AttributeInfo, constant_pool::ConstantPoolItem, field::FieldInfo, interface::parse_interface, method::MethodInfo}; +use crate::{ + attribute::AttributeInfo, constant_pool::ConstantPoolItem, error::ParseError, field::FieldInfo, interface::parse_interface, method::MethodInfo, +}; fn parse_this_class<'a>(data: &'a [u8], constant_pool: &BTreeMap) -> IResult<&'a [u8], Arc> { let (data, this_class) = be_u16(data)?; @@ -44,48 +46,56 @@ pub struct ClassInfo { pub attributes: Vec, } -impl ClassInfo { - fn parse_info(data: &[u8]) -> IResult<&[u8], Self> { - let (data, magic) = be_u32(data)?; - if magic != 0xCAFEBABE { - return Err(nom::Err::Error(nom::error::Error::new(data, nom::error::ErrorKind::Verify))); - } +type ClassBody = ( + u16, + Arc, + Option>, + Vec>, + Vec, + Vec, + Vec, +); - let (data, minor_version) = be_u16(data)?; - let (data, major_version) = be_u16(data)?; - let (data, constant_pool) = ConstantPoolItem::parse_all(data)?; +impl ClassInfo { + fn parse_body<'a>(data: &'a [u8], constant_pool: &BTreeMap) -> IResult<&'a [u8], ClassBody> { let (data, access_flags) = be_u16(data)?; - let (data, this_class) = parse_this_class(data, &constant_pool)?; - let (data, super_class) = parse_super_class(data, &constant_pool)?; - let (data, interfaces) = length_count(be_u16, |x| parse_interface(x, &constant_pool)).parse(data)?; - let (data, fields) = length_count(be_u16, |x| FieldInfo::parse(x, &constant_pool)).parse(data)?; - let (data, methods) = length_count(be_u16, |x| MethodInfo::parse(x, &constant_pool)).parse(data)?; - let (data, attributes) = length_count(be_u16, |x| AttributeInfo::parse(x, &constant_pool)).parse(data)?; + let (data, this_class) = parse_this_class(data, constant_pool)?; + let (data, super_class) = parse_super_class(data, constant_pool)?; + let (data, interfaces) = length_count(be_u16, |x| parse_interface(x, constant_pool)).parse(data)?; + let (data, fields) = length_count(be_u16, |x| FieldInfo::parse(x, constant_pool)).parse(data)?; + let (data, methods) = length_count(be_u16, |x| MethodInfo::parse(x, constant_pool)).parse(data)?; + let (data, attributes) = length_count(be_u16, |x| AttributeInfo::parse(x, constant_pool)).parse(data)?; - Ok(( - data, - Self { - magic, - minor_version, - major_version, - constant_pool, - access_flags: ClassAccessFlags::from_bits_truncate(access_flags), - this_class, - super_class, - interfaces, - fields, - methods, - attributes, - }, - )) + Ok((data, (access_flags, this_class, super_class, interfaces, fields, methods, attributes))) } - pub fn parse(file: &[u8]) -> Option { - let (remaining, result) = Self::parse_info(file).ok()?; + pub fn parse(file: &[u8]) -> Result { + let (data, magic) = be_u32::<_, nom::error::Error<&[u8]>>(file).map_err(ParseError::from_nom)?; + if magic != 0xCAFEBABE { + return Err(ParseError::BadMagic(magic)); + } + + let (data, minor_version) = be_u16::<_, nom::error::Error<&[u8]>>(data).map_err(ParseError::from_nom)?; + let (data, major_version) = be_u16::<_, nom::error::Error<&[u8]>>(data).map_err(ParseError::from_nom)?; + let (data, constant_pool) = ConstantPoolItem::parse_all(data)?; + let (remaining, (access_flags, this_class, super_class, interfaces, fields, methods, attributes)) = + Self::parse_body(data, &constant_pool).map_err(ParseError::from_nom)?; if !remaining.is_empty() { - return None; + return Err(ParseError::TrailingData); } - Some(result) + Ok(Self { + magic, + minor_version, + major_version, + constant_pool, + access_flags: ClassAccessFlags::from_bits_truncate(access_flags), + this_class, + super_class, + interfaces, + fields, + methods, + attributes, + }) } } diff --git a/classfile/src/constant_pool.rs b/classfile/src/constant_pool.rs index f97d5441..6ff5f7ec 100644 --- a/classfile/src/constant_pool.rs +++ b/classfile/src/constant_pool.rs @@ -7,11 +7,15 @@ use nom::{ number::complete::{be_f32, be_f64, be_i32, be_i64, be_u16, u8}, }; +use crate::error::ParseError; + fn parse_utf8(data: &[u8]) -> IResult<&[u8], Arc> { let (data, length) = be_u16(data)?; let (data, utf8) = take(length as usize).parse(data)?; - Ok((data, Arc::new(String::from_utf8(utf8.to_vec()).unwrap()))) + let utf8 = String::from_utf8(utf8.to_vec()).map_err(|_| nom::Err::Error(Error::new(data, ErrorKind::Verify)))?; + + Ok((data, Arc::new(utf8))) } #[derive(Debug)] @@ -108,14 +112,19 @@ impl ConstantPoolItem { } } - pub fn parse_all(data: &[u8]) -> IResult<&[u8], BTreeMap> { - let (remaining, count) = be_u16(data)?; + pub fn parse_all(data: &[u8]) -> Result<(&[u8], BTreeMap), ParseError> { + let (remaining, count) = be_u16::<_, Error<&[u8]>>(data).map_err(ParseError::from_nom)?; let mut data = remaining; let mut result = BTreeMap::new(); let mut i = 1; loop { - let (remaining, item) = Self::parse_with_tag(data)?; + let (remaining, tag) = u8::<_, Error<&[u8]>>(data).map_err(ParseError::from_nom)?; + let (remaining, item) = Self::parse_tagged(remaining, tag).map_err(|err| match err { + // parse_tagged signals an unrecognized tag with ErrorKind::Switch + nom::Err::Error(e) if e.code == ErrorKind::Switch => ParseError::UnsupportedConstantPoolTag { index: i, tag }, + other => ParseError::from_nom(other), + })?; let is_double_entry = match &item { Self::Long(_) | Self::Double(_) => { // long or double constant takes two constant pool entries.... diff --git a/classfile/src/error.rs b/classfile/src/error.rs new file mode 100644 index 00000000..ea7da01e --- /dev/null +++ b/classfile/src/error.rs @@ -0,0 +1,42 @@ +use core::fmt::{self, Display, Formatter}; + +/// Reason a class file could not be parsed. Rendered into the +/// `java.lang.ClassFormatError` message, so each variant carries enough +/// context to diagnose the offending file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseError { + Truncated, + BadMagic(u32), + UnsupportedConstantPoolTag { index: u16, tag: u8 }, + Malformed, + TrailingData, +} + +impl ParseError { + pub(crate) fn from_nom(err: nom::Err>) -> Self { + match err { + nom::Err::Incomplete(_) => Self::Truncated, + nom::Err::Error(e) | nom::Err::Failure(e) => { + if e.code == nom::error::ErrorKind::Eof { + Self::Truncated + } else { + Self::Malformed + } + } + } + } +} + +impl Display for ParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Truncated => write!(f, "Truncated class file"), + Self::BadMagic(magic) => write!(f, "Incompatible magic value 0x{magic:08X} in class file"), + Self::UnsupportedConstantPoolTag { index, tag } => { + write!(f, "Unknown or unsupported constant pool tag {tag} at index {index} in class file") + } + Self::Malformed => write!(f, "Malformed class file"), + Self::TrailingData => write!(f, "Extra bytes at end of class file"), + } + } +} diff --git a/classfile/src/lib.rs b/classfile/src/lib.rs index b1672afd..8d69b85a 100644 --- a/classfile/src/lib.rs +++ b/classfile/src/lib.rs @@ -4,6 +4,7 @@ extern crate alloc; mod attribute; mod class; mod constant_pool; +mod error; mod field; mod interface; mod method; @@ -13,6 +14,7 @@ pub use { attribute::{AttributeInfo, AttributeInfoCode}, class::ClassInfo, constant_pool::{ConstantPoolReference, FieldMethodref}, + error::ParseError, field::FieldInfo, method::MethodInfo, opcode::Opcode, diff --git a/java_runtime/src/classes/java/lang.rs b/java_runtime/src/classes/java/lang.rs index 67ae16d2..e705c2ab 100644 --- a/java_runtime/src/classes/java/lang.rs +++ b/java_runtime/src/classes/java/lang.rs @@ -4,6 +4,7 @@ mod array_index_out_of_bounds_exception; mod array_store_exception; mod class; mod class_cast_exception; +mod class_format_error; mod class_loader; mod clone_not_supported_exception; mod cloneable; @@ -41,14 +42,14 @@ mod unsupported_operation_exception; pub use self::{ abstract_method_error::AbstractMethodError, arithmetic_exception::ArithmeticException, array_index_out_of_bounds_exception::ArrayIndexOutOfBoundsException, array_store_exception::ArrayStoreException, class::Class, - class_cast_exception::ClassCastException, class_loader::ClassLoader, clone_not_supported_exception::CloneNotSupportedException, - cloneable::Cloneable, comparable::Comparable, error::Error, exception::Exception, exception_in_initializer_error::ExceptionInInitializerError, - illegal_argument_exception::IllegalArgumentException, incompatible_class_change_error::IncompatibleClassChangeError, - index_out_of_bounds_exception::IndexOutOfBoundsException, instantiation_error::InstantiationError, integer::Integer, - interrupted_exception::InterruptedException, linkage_error::LinkageError, math::Math, negative_array_size_exception::NegativeArraySizeException, - no_class_def_found_error::NoClassDefFoundError, no_such_field_error::NoSuchFieldError, no_such_method_error::NoSuchMethodError, - null_pointer_exception::NullPointerException, number_format_exception::NumberFormatException, object::Object, runnable::Runnable, - runtime::Runtime, runtime_exception::RuntimeException, security_exception::SecurityException, string::String, string_buffer::StringBuffer, - string_index_out_of_bounds_exception::StringIndexOutOfBoundsException, system::System, thread::Thread, throwable::Throwable, - unsupported_operation_exception::UnsupportedOperationException, + class_cast_exception::ClassCastException, class_format_error::ClassFormatError, class_loader::ClassLoader, + clone_not_supported_exception::CloneNotSupportedException, cloneable::Cloneable, comparable::Comparable, error::Error, exception::Exception, + exception_in_initializer_error::ExceptionInInitializerError, illegal_argument_exception::IllegalArgumentException, + incompatible_class_change_error::IncompatibleClassChangeError, index_out_of_bounds_exception::IndexOutOfBoundsException, + instantiation_error::InstantiationError, integer::Integer, interrupted_exception::InterruptedException, linkage_error::LinkageError, math::Math, + negative_array_size_exception::NegativeArraySizeException, no_class_def_found_error::NoClassDefFoundError, no_such_field_error::NoSuchFieldError, + no_such_method_error::NoSuchMethodError, null_pointer_exception::NullPointerException, number_format_exception::NumberFormatException, + object::Object, runnable::Runnable, runtime::Runtime, runtime_exception::RuntimeException, security_exception::SecurityException, string::String, + string_buffer::StringBuffer, string_index_out_of_bounds_exception::StringIndexOutOfBoundsException, system::System, thread::Thread, + throwable::Throwable, unsupported_operation_exception::UnsupportedOperationException, }; diff --git a/java_runtime/src/classes/java/lang/class_format_error.rs b/java_runtime/src/classes/java/lang/class_format_error.rs new file mode 100644 index 00000000..0dbd369a --- /dev/null +++ b/java_runtime/src/classes/java/lang/class_format_error.rs @@ -0,0 +1,43 @@ +use alloc::vec; + +use java_class_proto::JavaMethodProto; +use jvm::{ClassInstanceRef, Jvm, Result}; + +use crate::{RuntimeClassProto, RuntimeContext, classes::java::lang::String}; + +// class java.lang.ClassFormatError +pub struct ClassFormatError; + +impl ClassFormatError { + pub fn as_proto() -> RuntimeClassProto { + RuntimeClassProto { + name: "java/lang/ClassFormatError", + parent_class: Some("java/lang/LinkageError"), + interfaces: vec![], + methods: vec![ + JavaMethodProto::new("", "()V", Self::init, Default::default()), + JavaMethodProto::new("", "(Ljava/lang/String;)V", Self::init_with_message, Default::default()), + ], + fields: vec![], + access_flags: Default::default(), + } + } + + async fn init(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef) -> Result<()> { + tracing::debug!("java.lang.ClassFormatError::({this:?})"); + + let _: () = jvm.invoke_special(&this, "java/lang/LinkageError", "", "()V", ()).await?; + + Ok(()) + } + + async fn init_with_message(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef, message: ClassInstanceRef) -> Result<()> { + tracing::debug!("java.lang.ClassFormatError::({this:?}, {message:?})"); + + let _: () = jvm + .invoke_special(&this, "java/lang/LinkageError", "", "(Ljava/lang/String;)V", (message,)) + .await?; + + Ok(()) + } +} diff --git a/java_runtime/src/loader.rs b/java_runtime/src/loader.rs index c11457a8..4bc809d6 100644 --- a/java_runtime/src/loader.rs +++ b/java_runtime/src/loader.rs @@ -38,6 +38,7 @@ pub fn get_runtime_class_proto(name: &str) -> Option { crate::classes::java::lang::ArrayStoreException::as_proto(), crate::classes::java::lang::Class::as_proto(), crate::classes::java::lang::ClassCastException::as_proto(), + crate::classes::java::lang::ClassFormatError::as_proto(), crate::classes::java::lang::ClassLoader::as_proto(), crate::classes::java::lang::Cloneable::as_proto(), crate::classes::java::lang::CloneNotSupportedException::as_proto(), diff --git a/jvm_rust/src/class_definition.rs b/jvm_rust/src/class_definition.rs index 71cd3114..aafed8e4 100644 --- a/jvm_rust/src/class_definition.rs +++ b/jvm_rust/src/class_definition.rs @@ -12,7 +12,7 @@ use core::{ use parking_lot::RwLock; -use classfile::{AttributeInfo, ClassInfo, ConstantPoolReference}; +use classfile::{AttributeInfo, ClassInfo, ConstantPoolReference, ParseError}; use java_class_proto::JavaClassProto; use java_constants::{ClassAccessFlags, FieldAccessFlags, MethodAccessFlags}; use jvm::{ClassDefinition, ClassInstance, Field, JavaType, JavaValue, Jvm, Method, Result}; @@ -96,9 +96,8 @@ impl ClassDefinitionImpl { ) } - pub fn from_classfile(data: &[u8]) -> Result { - let class = ClassInfo::parse(data).unwrap(); // TODO ClassFormatError - assert_eq!(class.magic, 0xCAFEBABE); + pub fn from_classfile(data: &[u8]) -> core::result::Result { + let class = ClassInfo::parse(data)?; let mut constant_values = Vec::new(); let fields = class diff --git a/src/runtime.rs b/src/runtime.rs index 649fd6e7..2c705e1b 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -174,8 +174,11 @@ where Ok(None) } - async fn define_class(&self, _jvm: &Jvm, data: &[u8]) -> jvm::Result> { - ClassDefinitionImpl::from_classfile(data).map(|x| Box::new(x) as Box<_>) + async fn define_class(&self, jvm: &Jvm, data: &[u8]) -> jvm::Result> { + match ClassDefinitionImpl::from_classfile(data) { + Ok(class) => Ok(Box::new(class) as Box<_>), + Err(err) => Err(jvm.exception("java/lang/ClassFormatError", &err.to_string()).await), + } } async fn define_array_class(&self, _jvm: &Jvm, element_type_name: &str) -> jvm::Result> { diff --git a/test_utils/src/lib.rs b/test_utils/src/lib.rs index 6b0ca6bc..7328818d 100644 --- a/test_utils/src/lib.rs +++ b/test_utils/src/lib.rs @@ -146,8 +146,11 @@ impl Runtime for TestRuntime { Ok(None) } - async fn define_class(&self, _jvm: &Jvm, data: &[u8]) -> jvm::Result> { - ClassDefinitionImpl::from_classfile(data).map(|x| Box::new(x) as Box<_>) + async fn define_class(&self, jvm: &Jvm, data: &[u8]) -> jvm::Result> { + match ClassDefinitionImpl::from_classfile(data) { + Ok(class) => Ok(Box::new(class) as Box<_>), + Err(err) => Err(jvm.exception("java/lang/ClassFormatError", &err.to_string()).await), + } } async fn define_array_class(&self, _jvm: &Jvm, element_type_name: &str) -> jvm::Result> { diff --git a/tests/test_class_format.rs b/tests/test_class_format.rs new file mode 100644 index 00000000..27d3e6c0 --- /dev/null +++ b/tests/test_class_format.rs @@ -0,0 +1,80 @@ +#![allow(dead_code)] // test_helper is shared with test_class.rs; not all helpers are used here + +mod test_helper; + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use test_helper::run_class; + +// Fixtures are derived deterministically from the committed test_data/Hello.class +// by byte manipulation, so corruption scenarios stay reproducible without +// committing corrupted binaries. +fn fixture(name: &str, bytes: &[u8]) -> (PathBuf, PathBuf) { + // relative path with trailing slash, like "./test_data/": classpath entries are + // turned into URLs and joined with the class file name + let dir = PathBuf::from(format!("./target/class_format_fixtures_{}/", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + + let path = dir.join(name); + fs::write(&path, bytes).unwrap(); + + (dir, path) +} + +fn hello_class() -> Vec { + fs::read("test_data/Hello.class").unwrap() +} + +#[tokio::test] +async fn test_truncated_class_raises_class_format_error() { + let (dir, path) = fixture("TruncatedHello.class", &hello_class()[..60]); + + let err = run_class(&path, &[dir.as_path()], &[]).await.unwrap_err().to_string(); + assert!(err.contains("java.lang.ClassFormatError"), "expected ClassFormatError, got: {err}"); + assert!(err.contains("Truncated"), "expected truncation cause in message, got: {err}"); +} + +#[tokio::test] +async fn test_unsupported_constant_pool_tag_raises_class_format_error() { + let mut bytes = hello_class(); + // offset 10 is the first constant pool tag; 10 (Methodref) in the committed fixture + assert_eq!(bytes[10], 10, "test_data/Hello.class layout changed; adjust the mutation offset"); + bytes[10] = 18; // CONSTANT_InvokeDynamic, unsupported + let (dir, path) = fixture("BadTagHello.class", &bytes); + + let err = run_class(&path, &[dir.as_path()], &[]).await.unwrap_err().to_string(); + assert!(err.contains("java.lang.ClassFormatError"), "expected ClassFormatError, got: {err}"); + assert!(err.contains("tag 18"), "expected offending tag in message, got: {err}"); +} + +#[tokio::test] +async fn test_bad_magic_raises_class_format_error() { + let mut bytes = hello_class(); + bytes[0] = 0x00; // magic becomes 0x00FEBABE + let (dir, path) = fixture("BadMagicHello.class", &bytes); + + let err = run_class(&path, &[dir.as_path()], &[]).await.unwrap_err().to_string(); + assert!(err.contains("java.lang.ClassFormatError"), "expected ClassFormatError, got: {err}"); + assert!(err.contains("magic"), "expected magic mismatch cause in message, got: {err}"); +} + +#[tokio::test] +async fn test_missing_class_still_raises_no_class_def_found_error() { + let (dir, _) = fixture("Unrelated.class", &hello_class()); + + let err = run_class(Path::new("NoSuchClass.class"), &[dir.as_path()], &[]) + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("java.lang.NoClassDefFoundError"), + "expected NoClassDefFoundError, got: {err}" + ); + assert!( + !err.contains("ClassFormatError"), + "not-found must stay distinct from unreadable, got: {err}" + ); +}