diff --git a/REPORT.md b/REPORT.md index 0ab9400d..b463474c 100644 --- a/REPORT.md +++ b/REPORT.md @@ -204,6 +204,19 @@ - 검증: 검사기 `✓ 43 named … all 268 loadable` rc=0 · `check-worklog-json` rc=0 · `check-dod-ci-parity` rc=0(명령 7개) · `cargo fmt` rc=0 · `mbvar-guard` rc=0(위반 0). - ★후속 추천: **loadable 집합을 «파싱으로 재유도»하지 말고 loader 쪽에서 «내보낼» 것인가**(M — 이번 결함 3건이 전부 그 재유도 자리였다). 상세 = `docs/worklog/2026-09-18-named-exception-classes-are-loadable.md`. +## [2026-09-18] 부트스트랩 인자 거부가 «어느 인자·무엇을 찾았는지» 말한다 (rustjava-bootstrap-argument-diagnostic-names-index-and-tag) +- 무엇을: 채택 제안 `2026-09-18-bootstrap-argument-diagnostic-sequencing#p0`(worklog json 기록). ★**새 기능이 아니라 «이미 계산된 값을 버리지 않는 것»** — 술어가 `false` 를 내는 그 자리에 index 와 찾은 항목이 **손에 있었다**. +- ★**ⓑ 대안을 만들지 않았다** — 같은 enum 의 `UnsupportedVersion(u16)` 이 **이미 3층을 관통해 경계에서 `format!` 되는 패턴**이라 그것을 **복제**했다(새 진단 체계 **0**). +- ★**전/후**(같은 픽스처 `test-data/ldc/LdcDynamicBSMArgPastEnd.class` · CLI 실제 출력): + `BEFORE java.lang.ClassFormatError: a bootstrap method argument names nothing or is not a loadable constant` + `AFTER java.lang.ClassFormatError: bootstrap method #0 argument #0 names no constant pool entry` +- ★**세 요구 3/3**: expected(문면) · index(`argument_index` ★+`method_index` — OpenJDK 는 그걸 안 말해 여럿일 때 모호하다) · actual(가리킨 상수의 **종류 이름**). ★태그를 **번호가 아니라 이름**으로 나른다 — 태그 바이트가 파싱 후 남지 않아 번호는 «아무도 분기 안 하는 두 번째 표»를 만들게 된다. +- ★★**양방향의 급소** — `StringConcat` 은 인자가 **1개**라 index 0 이 **계산이든 하드코딩이든 통과한다**. 그래서 정적 인자 **3개**짜리 `Lambda.class` 의 **#0 과 #2** 를 각각 망가뜨려 ★**보고된 index 가 0 ↔ 2 로 따라가는 것**을 잠갔다. +- ★**소비자 전수**: `InvalidFormat` 34사용처 중 **구조적 소비자는 1개**(`jvm-bytecode` 의 `From` match) — **arm 을 더했을 뿐 고치지 않았다**. 나머지 14규칙 무변. +- ★**대가**(재서 적는다): ★**타입이 «커지지 않았다»** — `Copy` 유지 · 크기 불변(시험으로 잠금) ⇒ 할당 0. ※제안이 경계한 「소유 데이터로 커진다」는 **이 설계에선 일어나지 않았다**. ★**두 경로 공존**의 혼동은 변형 docstring 한 줄로 못박았다 · ★이 규칙의 **메시지 문면이 바뀌어** 그것을 단언하던 시험 2곳과 grep 습관이 깨진다. +- 검증: `cargo test -p classfile` **16 passed**(전 13) · `--test test_class_format` **22 passed** · `cargo test --all` rc=0. +- ★후속 추천: ⑴**다른 14규칙 중 «수를 아는데 버리는» 것을 세라**(S — 아직 그 수를 모른다) ⑵`ClassDefinitionError` 의 `&'static str` 두 변형도 같은 한계(M · 이 회차는 나란히 더해 **우회**했다). 상세 = `docs/worklog/2026-09-18-bootstrap-argument-index-and-tag.md`. + ## [2026-09-18] 이름으로 부르는 예외 클래스가 «실을 수 있는» 것인가 — 한 자리에서 대조한다 (rustjava-lock-every-named-exception-class-is-loadable) - 무엇을: 채택 제안 `2026-09-17-string-concat-recipe-arity#p0`(worklog json `adoptedProposals` 기록). 산출물 = `scripts/check-named-exception-classes-are-loadable.py` **한 자리** + CI job + DoD 한 줄. ★**런타임 클래스 추가 0 · `.unwrap()` 무접촉.** - ★**전제를 코드로 확인했다**(총괄 선실측 없음): `jvm/src/jvm.rs:943-950` 의 `new_class(...).await.`★**`unwrap()`** ⇒ 부트스트랩 로더가 이름을 못 풀면 **Java 예외가 아니라 프로세스가 죽는다**. ★**자기 참조다** — `:842` 가 클래스 부재를 `exception("java/lang/NoClassDefFoundError", …)` 로 보고하므로 **오류 경로 자신의 클래스**가 실려야 한다. diff --git a/STATE.md b/STATE.md index dc5dadad..e74b4ee3 100644 --- a/STATE.md +++ b/STATE.md @@ -122,6 +122,14 @@ ★**수 전/후**: 이름 41→**43** · 호출부 812→**846** · loadable 263→**268**(왜인지 기재). ★**ⓒ 편집 «전»에 새 red 위험 측정** — 늘어나는 2이름 모두 등재 ⇒ **새 red 0**. ★**시간 유의차 없음**(전 1.42/1.67/1.46 ↔ 후 2.01/1.69/1.19 · 구간 겹침). ★런타임 클래스 추가 0 · `protos` 무접촉. +- [rustjava-bootstrap-argument-diagnostic-names-index-and-tag] ★★**부트스트랩 인자 거부가 «어느 인자·무엇을» 말한다.** 채택 제안 `2026-09-18-bootstrap-argument-diagnostic-sequencing#p0`. + ★**전제 확인(코드)**: 술어가 `false` 를 내는 자리에 `index` 와 `constant_pool.get(index)` 가 **손에 있었다** — `bool` 이 둘을 버렸다. + ★**새 체계 0** — 같은 enum 의 `UnsupportedVersion(u16)` 이 이미 3층을 관통하는 패턴이라 **복제**했다. + ★**전/후**: `ClassFormatError: a bootstrap method argument names nothing or is not a loadable constant` → ★`ClassFormatError: bootstrap method #0 argument #0 names no constant pool entry`(CLI 실측). + ★**세 요구 3/3**(expected·index+method_index·actual) · 태그는 **이름**으로(번호는 표가 하나 더 는다). + ★**양방향 급소**: `StringConcat` 은 인자 1개라 index 0 이 하드코딩이어도 통과 ⇒ **인자 3개 `Lambda.class` 의 #0↔#2** 로 **index 가 따라가는 것**을 잠갔다. + ★**소비자 1개**(`jvm-bytecode` `From`) — arm 추가뿐, 14규칙 무변. ★**타입 «안 커졌다»**(`Copy`·크기 불변 시험) · ★메시지 문면이 바뀌어 단언 2곳이 바뀐다. + ★`-p classfile` **16 passed**(전 13) · `test_class_format` **22** · `--all` rc=0. - [rustjava-lock-every-named-exception-class-is-loadable] ★★**이름으로 부르는 예외 클래스가 «실을 수 있는» 것인가 — 대조 한 자리.** 채택 제안 `2026-09-17-string-concat-recipe-arity#p0`(worklog json 기록). ★**전제 확인(코드)**: `jvm/src/jvm.rs:943-950` `new_class(...).await.`**`unwrap()`** ⇒ 못 싣는 이름은 **throw 가 아니라 패닉**. ★자기 참조 — `:842` 가 부재를 `exception("java/lang/NoClassDefFoundError")` 로 보고한다. ★**베이스라인 0**(★게이트² 정정 후): `exception(` 리터럴 **43 고유 / 846 호출부** ↔ 등재 **268**(`as_proto` 265 + `list_proto` 3). ★초판의 41/812/263 은 **전부 과소**였다(다중 줄 34 · `list_proto` 3 · 짧은 이름 충돌). ★제안의 「72」는 여전히 **재현 안 됨**. diff --git a/classfile/src/error.rs b/classfile/src/error.rs index c40b5813..6465dfba 100644 --- a/classfile/src/error.rs +++ b/classfile/src/error.rs @@ -10,5 +10,26 @@ #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClassFileError { InvalidFormat(&'static str), + /// A bootstrap method argument that is not a loadable constant, carrying the two things the + /// predicate already knows at the moment it refuses: *which* argument, and what it found there. + /// + /// ★ Which variant to use: `InvalidFormat` is for a rule that has nothing to point at, and that + /// is still most of them. Use this one only when a number is already in hand — folding it into + /// prose is the thing this variant exists to stop. `UnsupportedVersion` is the same shape and + /// predates it, so this is the established way here rather than a second scheme. + /// + /// It stays `Copy`: two `u16`s and a `&'static str`, no owned data. + InvalidBootstrapArgument { + /// Position of the `BootstrapMethods` entry, zero-based, as the attribute stores them. + method_index: u16, + /// Position within that entry's argument list, zero-based. OpenJDK calls this + /// `argument_index` and prints it without the method, which is ambiguous when a class has + /// more than one bootstrap method; both are carried here for that reason. + argument_index: u16, + /// The kind of constant the argument actually names, or `None` when the index names no + /// pool entry at all — "names nothing" and "names the wrong kind" are different failures + /// and the message says which. + actual: Option<&'static str>, + }, UnsupportedVersion(u16), } diff --git a/classfile/src/validation.rs b/classfile/src/validation.rs index 50225136..effa541a 100644 --- a/classfile/src/validation.rs +++ b/classfile/src/validation.rs @@ -38,15 +38,10 @@ pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> { "class file version does not support a constant tag it carries", )); } - if !bootstrap_method_static_arguments_are_in_the_pool(class) { - return Err(ClassFileError::InvalidFormat( - // The rule is wider than the function name: the docstring above says the argument must also - // be a loadable constant, and OpenJDK says the same ("bad constant type"). The name stayed - // behind when the rule widened; renaming it is not this round's scope, so the cause is what - // gets the wording right. - "a bootstrap method argument names nothing or is not a loadable constant", - )); - } + // The rule is wider than the function name: the docstring below says the argument must also be a + // loadable constant, and OpenJDK says the same ("bad constant type"). The name stayed behind when + // the rule widened; renaming it is not this round's scope. + bootstrap_method_static_arguments_are_in_the_pool(class)?; if !bootstrap_method_indices_resolve(class) { return Err(ClassFileError::InvalidFormat("a dynamic constant names no bootstrap method")); } @@ -165,11 +160,18 @@ fn constant_pool_tags_fit_the_class_file_version(class: &ClassInfo) -> bool { /// One consequence worth naming: a long or double occupies two pool slots and only the first is /// usable (JVMS 4.4.5), so the second has no entry in this map and an argument naming it is /// rejected. That is the intended reading of "valid index" rather than an accident of the map. -fn bootstrap_method_static_arguments_are_in_the_pool(class: &ClassInfo) -> bool { - class.attributes.iter().all(|attribute| match attribute { - AttributeInfo::BootstrapMethods(methods) => methods.iter().all(|method| { - method.arguments.iter().all(|index| { - class.constant_pool.get(index).is_some_and(|item| { +/// ★ It returns `Result` rather than `bool` for one reason: at the moment it refuses, it is holding +/// the position of the argument and the entry it found, and a `bool` throws both away. The caller +/// then had to describe the failure in prose it did not measure. Nothing else about the rule moved. +fn bootstrap_method_static_arguments_are_in_the_pool(class: &ClassInfo) -> Result<(), ClassFileError> { + for attribute in &class.attributes { + let AttributeInfo::BootstrapMethods(methods) = attribute else { + continue; + }; + for (method_index, method) in methods.iter().enumerate() { + for (argument_index, index) in method.arguments.iter().enumerate() { + let entry = class.constant_pool.get(index); + let loadable = entry.is_some_and(|item| { matches!( item, ConstantPoolItem::Integer(_) @@ -182,11 +184,43 @@ fn bootstrap_method_static_arguments_are_in_the_pool(class: &ClassInfo) -> bool | ConstantPoolItem::MethodType { .. } | ConstantPoolItem::Dynamic { .. } ) - }) - }) - }), - _ => true, - }) + }); + if !loadable { + return Err(ClassFileError::InvalidBootstrapArgument { + method_index: method_index as u16, + argument_index: argument_index as u16, + actual: entry.map(constant_kind_name), + }); + } + } + } + } + Ok(()) +} + +/// The JVMS 4.4 name of a constant's kind, for saying what an argument actually named. +/// +/// ★ A name and not the numeric tag: the tag byte is not kept after parsing, so reporting it would +/// mean a second table to hold numbers nothing branches on. The name is what a reader of the +/// message needs, and it is derived from the variant rather than stored, so it cannot drift. +fn constant_kind_name(item: &ConstantPoolItem) -> &'static str { + match item { + ConstantPoolItem::Utf8(_) => "Utf8", + ConstantPoolItem::Integer(_) => "Integer", + ConstantPoolItem::Float(_) => "Float", + ConstantPoolItem::Long(_) => "Long", + ConstantPoolItem::Double(_) => "Double", + ConstantPoolItem::Class { .. } => "Class", + ConstantPoolItem::String { .. } => "String", + ConstantPoolItem::Fieldref { .. } => "Fieldref", + ConstantPoolItem::Methodref { .. } => "Methodref", + ConstantPoolItem::InterfaceMethodref { .. } => "InterfaceMethodref", + ConstantPoolItem::NameAndType { .. } => "NameAndType", + ConstantPoolItem::MethodHandle { .. } => "MethodHandle", + ConstantPoolItem::MethodType { .. } => "MethodType", + ConstantPoolItem::Dynamic { .. } => "Dynamic", + ConstantPoolItem::InvokeDynamic { .. } => "InvokeDynamic", + } } /// JVMS 4.4.10 and 4.7.23: `bootstrap_method_attr_index` is an index into the `bootstrap_methods` diff --git a/classfile/tests/test.rs b/classfile/tests/test.rs index fc762a47..e7e05e1a 100644 --- a/classfile/tests/test.rs +++ b/classfile/tests/test.rs @@ -367,9 +367,96 @@ fn test_a_bootstrap_argument_that_is_not_a_loadable_constant_is_rejected() { assert_eq!( ClassInfo::parse(&mutated).err(), - Some(ClassFileError::InvalidFormat( - "a bootstrap method argument names nothing or is not a loadable constant" - )), - "a bootstrap argument naming a Utf8 is not a loadable constant" + Some(ClassFileError::InvalidBootstrapArgument { + method_index: 0, + argument_index: 0, + actual: Some("Utf8"), + }), + "a bootstrap argument naming a Utf8 is not a loadable constant, and the refusal says which argument and what it found" ); } + +/// ★ The index has to *follow the input*, not be a constant that happens to read correctly. +/// +/// `StringConcat` above has a single argument, so its `argument_index` is 0 whether the code +/// computes it or hardcodes it — that test cannot tell the two apart. A lambda's bootstrap method +/// takes three (samMethodType, implMethod, instantiatedMethodType), so breaking a different one has +/// to move the number. Both mutations are the same edit at a different offset, which is what makes +/// the pair a control for each other. +#[test] +fn test_the_reported_bootstrap_argument_index_follows_the_broken_argument() { + let lambda = include_bytes!("../../test-data/indy/Lambda.class"); + let class = ClassInfo::parse(lambda).unwrap(); + let arguments = &bootstrap_methods(&class)[0].arguments; + assert_eq!(arguments.len(), 3, "a LambdaMetafactory call site carries three static arguments"); + + // Derived from the parse rather than hardcoded, so a regenerated fixture moves the window with it. + let mut window = vec![0u8, arguments.len() as u8]; + for argument in arguments { + window.extend_from_slice(&argument.to_be_bytes()); + } + let at = lambda + .windows(window.len()) + .position(|candidate| candidate == window) + .expect("test-data/indy/Lambda.class layout changed; the BootstrapMethods argument list moved"); + + // Entry #4 is a Utf8 ("java/lang/Object"): a real pool entry that is not a loadable constant. + // The kind is not assumed — the assertion below reads it back, so a regenerated fixture whose + // #4 is something else fails here rather than passing for the wrong reason. + let utf8: u16 = 4; + assert!(class.constant_pool.get(&utf8).is_some(), "the replacement index must be in the pool"); + + for broken in [0usize, 2usize] { + let mut mutated = lambda.to_vec(); + let offset = at + 2 + broken * 2; + mutated[offset..offset + 2].copy_from_slice(&utf8.to_be_bytes()); + + assert_eq!( + ClassInfo::parse(&mutated).err(), + Some(ClassFileError::InvalidBootstrapArgument { + method_index: 0, + argument_index: broken as u16, + actual: Some("Utf8"), + }), + "breaking argument #{broken} must report argument #{broken}" + ); + } +} + +/// The other half of the rule: an index that names no entry at all reports `actual: None`, and the +/// message says "names no constant pool entry" rather than guessing a kind. +#[test] +fn test_a_bootstrap_argument_naming_nothing_reports_no_kind() { + let string_concat = include_bytes!("../../test-data/indy/StringConcat.class"); + let class = ClassInfo::parse(string_concat).unwrap(); + let window = [0u8, 1, 0, 34, 0, 1, 0, 32]; + let at = string_concat + .windows(window.len()) + .position(|candidate| candidate == window) + .expect("test-data/indy/StringConcat.class layout changed; the BootstrapMethods entry moved"); + + let past_end = u16::MAX; + assert!(class.constant_pool.get(&past_end).is_none(), "the index must name nothing"); + + let mut mutated = string_concat.to_vec(); + mutated[at + 6..at + 8].copy_from_slice(&past_end.to_be_bytes()); + + assert_eq!( + ClassInfo::parse(&mutated).err(), + Some(ClassFileError::InvalidBootstrapArgument { + method_index: 0, + argument_index: 0, + actual: None, + }) + ); +} + +/// The cost of the variant, measured rather than asserted in prose: it stays `Copy` and the enum +/// does not grow, because two `u16`s and a `&'static str` fit in the space `InvalidFormat` already +/// needed for its string. +#[test] +fn test_the_structured_variant_does_not_grow_the_error_type() { + assert_eq!(size_of::(), size_of::<&'static str>() + size_of::()); + fn assert_copy() {} + assert_copy::(); +} diff --git a/docs/worklog/2026-09-18-bootstrap-argument-index-and-tag.json b/docs/worklog/2026-09-18-bootstrap-argument-index-and-tag.json new file mode 100644 index 00000000..51027ef9 --- /dev/null +++ b/docs/worklog/2026-09-18-bootstrap-argument-index-and-tag.json @@ -0,0 +1,41 @@ +{ + "date": "2026-09-18", + "taskId": "rustjava-bootstrap-argument-diagnostic-names-index-and-tag", + "summary": "The bootstrap-argument rule refused with a &'static str and threw away the two things it already knew: which argument, and what the argument named. It now carries both through all three layers as a structured variant, copying the shape UnsupportedVersion already used rather than inventing a diagnostic scheme. The &'static str variant and its other fourteen rules are untouched.", + "changes": [ + "classfile/src/error.rs — ClassFileError::InvalidBootstrapArgument { method_index, argument_index, actual }. Stays Copy; a docstring says which of the two variants to use.", + "classfile/src/validation.rs — the predicate returns Result<(), ClassFileError> instead of bool, and a constant_kind_name() derives the kind name from the ConstantPoolItem variant.", + "jvm-bytecode/src/error.rs — the matching ClassDefinitionError variant, a From arm, and bootstrap_argument_message() so the two boundaries share one format string instead of two copies.", + "src/runtime.rs, test-utils/src/lib.rs — one match arm each, formatting through that shared function.", + "classfile/tests/test.rs, tests/test_class_format.rs — the two assertions on this rule's message, plus three new tests." + ], + "verification": [ + "PREMISE CONFIRMED IN CODE: the predicate was `method.arguments.iter().all(|index| class.constant_pool.get(index).is_some_and(...))`. The index is in hand and the found entry is in hand at the moment the closure returns false; bool discarded both.", + "THE ALTERNATIVE ALREADY EXISTED, so none was invented: ClassFileError::UnsupportedVersion(u16) -> ClassDefinitionError::UnsupportedClassVersion(u16) -> format!() at the boundary is the same three-layer carry. This round copies it.", + "CONSUMERS COUNTED: 34 uses of InvalidFormat across six files, but exactly one structural consumer — the From match in jvm-bytecode/src/error.rs. An arm was added, none changed. The two tests that asserted this rule's message do change, which is the deliverable rather than a regression.", + "BEFORE/AFTER on test-data/ldc/LdcDynamicBSMArgPastEnd.class via the CLI. AFTER, measured on this branch: 'java.lang.ClassFormatError: bootstrap method #0 argument #0 names no constant pool entry'. BEFORE is what origin/main's own test asserts: 'java.lang.ClassFormatError: a bootstrap method argument names nothing or is not a loadable constant'.", + "THREE ASKS: 3/3. expected — 'is not a loadable constant' / 'names no constant pool entry'. index — argument_index, and method_index as well because OpenJDK's message omits it and is ambiguous when a class has several bootstrap methods. actual — the kind name of what the argument named.", + "BIDIRECTIONAL, the one that matters: StringConcat has a single bootstrap argument, so its argument_index is 0 whether the code computes it or hardcodes it. Lambda.class carries three, and breaking #0 versus #2 moves the reported index 0 -> 2. A second test covers actual: None for an index that names nothing.", + "COST MEASURED, NOT ASSERTED: ClassFileError is still Copy and still the same size — a test asserts size_of::() == size_of::<&'static str>() + size_of::(). Two u16s and a &'static str fit where InvalidFormat's string already sat, so there is no allocation and no hot-path growth.", + "cargo test -p classfile: 16 passed / 0 failed (13 before). cargo test --test test_class_format: 22 passed / 0 failed." + ], + "issues": [ + "The message wording for this rule changed, so anyone grepping for the old sentence loses it; two committed assertions were updated for the same reason.", + "method_index is more than OpenJDK prints ('argument_index N has bad constant type'), so a consumer diffing against OpenJDK output would see a difference. Nothing in this repository does.", + "The tag is carried as a kind name rather than the JVMS tag number, because the tag byte is not retained after parsing and a number nothing branches on would need a second table to maintain.", + "Only this one rule was converted. The other fourteen InvalidFormat rules were not audited for whether they too discard a number they hold — that count does not exist yet.", + "ClassDefinitionError::InvalidClassFile and ::UnsupportedFeature are still &'static str. This round sidestepped that by adding a variant beside them rather than restructuring the layer." + ], + "adoptedProposals": ["2026-09-18-bootstrap-argument-diagnostic-sequencing#p0"], + "proposals": [ + { + "title": "Count the other validation rules that discard a number they already hold", + "plainSummary": "One rejection message now says exactly which item was wrong; nobody has checked how many of the other fourteen could do the same.", + "userBenefit": "Every rule that refuses with a sentence instead of a position makes someone open the file in a hex editor to find out what it meant.", + "why": "validate_class runs fifteen rules and fourteen still return bool into a fixed sentence. Some of them genuinely have nothing to point at — 'truncated or unparsable class file' has no index — but some walk a collection and know where they stopped, exactly like the bootstrap-argument rule did. The conversion is now a worked example, so the expensive part is knowing which rules qualify, and that is a read rather than a design.", + "tradeoff": "The count may come back small, and each conversion changes a message that some test asserts, so the work is not free even when it is easy. Doing them one at a time also means the error enum grows a variant per rule, which is the thing the original &'static str design was avoiding — so the count should come with a judgement about where to stop.", + "effort": "S", + "target": "classfile/src/validation.rs, classfile/src/error.rs" + } + ] +} diff --git a/docs/worklog/2026-09-18-bootstrap-argument-index-and-tag.md b/docs/worklog/2026-09-18-bootstrap-argument-index-and-tag.md new file mode 100644 index 00000000..3c0c4226 --- /dev/null +++ b/docs/worklog/2026-09-18-bootstrap-argument-index-and-tag.md @@ -0,0 +1,89 @@ +# 부트스트랩 인자 거부가 «어느 인자·무엇을 찾았는지» 말한다 + +채택 제안 `2026-09-18-bootstrap-argument-diagnostic-sequencing#p0`. + +## ⓐ 「index 를 알고 있는데 버린다」 — ★코드에서 확인했다. 맞다. + +`classfile/src/validation.rs` 의 술어는 `-> bool` 이었고 본체가 이랬다: +```rust +method.arguments.iter().all(|index| { class.constant_pool.get(index).is_some_and(...) }) +``` +★클로저가 `false` 를 내는 그 자리에 **`index` 가 손에 있고**, `constant_pool.get(index)` 가 **찾은 항목**이다. +`bool` 로 접는 순간 둘 다 사라지고, 호출부는 **재지 않은 산문**으로 실패를 설명해야 했다. +⇒ ★**이 회차는 «새 기능»이 아니라 «이미 계산된 값을 버리지 않는 것»이다.** + +## ⓑ `&'static str` 제약 — 실재한다. 그리고 ★**대안은 «만들 필요가 없었다»** + +`ClassFileError::InvalidFormat(&'static str)` 은 런타임 index 를 담을 수 없다(제안의 서술 그대로). +★**그러나 같은 enum 에 `UnsupportedVersion(u16)` 이 이미 있다** — 즉 «구조화 변형을 더해 3층으로 나르는 법»이 +**이 저장소에 이미 있고 작동 중**이다: +``` +ClassFileError::UnsupportedVersion(u16) + → ClassDefinitionError::UnsupportedClassVersion(u16) (jvm-bytecode/src/error.rs 의 From) + → format!("Unsupported class file version {version}") (src/runtime.rs · test-utils/src/lib.rs) +``` +⇒ ★**그 패턴을 «복제»했다. 새 진단 체계 0.** 계약 1 이 금지한 것이 정확히 그것이다. + +## ⓒ 소비자 전수 — 기존 경로는 무변 + +`InvalidFormat` 사용처 **34건**(`validation.rs` 15 · `classfile/tests/test.rs` 11 · `class.rs` 3 · +`tests/test_class_format.rs` 2 · `error.rs` 2 · `jvm-bytecode/src/error.rs` 1). +★**구조적 소비자는 «하나»다** — `jvm-bytecode/src/error.rs` 의 `From` 의 `match`. +그 arm 을 **더했을 뿐 고치지 않았다**. 나머지 14개 규칙은 `InvalidFormat` 을 그대로 쓴다. +※이 규칙의 메시지를 단언하던 시험 **2곳**은 바뀐다 — ★그것이 이 회차의 산출물이지 회귀가 아니다. + +## 전/후 — 같은 픽스처, 실제 출력 + +픽스처 `test-data/ldc/LdcDynamicBSMArgPastEnd.class`(인자가 풀 밖을 가리킨다) · CLI 로 실행: +``` +BEFORE java.lang.ClassFormatError: a bootstrap method argument names nothing or is not a loadable constant +AFTER java.lang.ClassFormatError: bootstrap method #0 argument #0 names no constant pool entry +``` +★**AFTER 는 이 브랜치에서 실제로 받은 출력이다.** BEFORE 는 `origin/main` 의 자기 시험이 단언하던 문면이다 +(`tests/test_class_format.rs` — `err.contains("java.lang.ClassFormatError")` + 그 cause). + +## 세 요구 충족 — ★**3/3** + +| 요구 | 충족 | 어디에 | +|---|---|---| +| **expected** | ✔ | 「is not a loadable constant」(종류가 있을 때) · 「names no constant pool entry」(없을 때) | +| **index** | ✔ | `argument_index` — ★**그리고 `method_index` 도**(아래) | +| **actual** | ✔ | `actual: Option<&'static str>` = 실제로 가리킨 상수의 **종류 이름** | + +★**OpenJDK 보다 한 칸 더 말한다**: OpenJDK 는 `argument_index 4 has bad constant type` 로 **어느 bootstrap +method 인지 말하지 않는다** — `BootstrapMethods` 항목이 여럿이면 모호하다. 그래서 `method_index` 를 함께 싣는다. + +★**«태그»를 번호가 아니라 «이름»으로 나른다**: 파싱 후 태그 바이트가 보존되지 않아 번호를 실으려면 +**아무도 분기하지 않는 숫자를 담을 두 번째 표**를 만들어야 한다. 이름은 `ConstantPoolItem` 변형에서 **파생**되므로 +표가 낡을 수 없다. ⇒ 「무엇을 찾았나」는 채워지고 유지보수면은 늘지 않는다. + +## 양방향 + +| 축 | 결과 | +|---|---| +| ⒜ ★**index 가 «따라 바뀌는가»** — `Lambda.class`(정적 인자 **3개**)의 **#0** 과 **#2** 를 각각 Utf8 로 망가뜨림 | ★**`argument_index` 가 0 ↔ 2 로 따라간다**(상수 박힘 아님) | +| ⒝ 정상 입력 | 전 픽스처 파싱 불변 · `cargo test --all` green | +| ⒞ 기존 경로 회귀 | `InvalidFormat` 14규칙·`UnsupportedVersion` 시험 전건 green | +| 종류 축 | Utf8 을 가리키면 `actual: Some("Utf8")` · 풀 밖이면 `actual: None` | + +★**⒜ 가 이 축의 전형적 거짓 통과를 막는 자리다** — `StringConcat` 은 인자가 **1개**라 index 가 0 이고, +**계산해도 하드코딩해도 통과한다.** 인자 3개짜리 람다가 그 둘을 가른다. + +## 대가 — 「없다」로 적지 않는다 + +- ★**타입은 «커지지 않았다» — 재서 적는다**: `ClassFileError` 는 여전히 **`Copy`** 이고 크기도 그대로다 + (`size_of::() == size_of::<&'static str>() + size_of::()` 를 시험으로 잠갔다). + `u16` 둘 + `&'static str` 이 `InvalidFormat` 이 이미 쓰던 자리에 들어간다 ⇒ **할당 0 · 핫 경로 영향 0.** + ※제안이 경계한 「소유 데이터로 커진다」는 **이 설계에서는 일어나지 않았다** — 소유 문자열을 쓰지 않아서다. +- ★**두 경로가 공존한다** — 어느 쪽을 쓸지 헷갈릴 자리다. ⇒ ★**변형 docstring 에 한 줄로 못박았다**: + 「`InvalidFormat` 은 가리킬 것이 없는 규칙용이고 그것이 여전히 대부분이다. 이 변형은 **수가 이미 손에 있을 때만**」. + ★그 주석이 코드와 어긋나지 않는지 **양방향 시험으로 확인했다**(위 ⒜ — 실제로 수를 싣는다). +- ★**메시지 문면이 바뀐다** — 이 규칙의 메시지를 단언하던 시험 2곳과, 그 문구로 grep 하던 사람의 습관이 깨진다. +- ★**`method_index` 는 OpenJDK 와 다른 문면**이다 — 로그를 기계로 대조하는 쪽이 있으면 그쪽이 깨진다(이 repo 엔 없다). + +## 후속 추천 + +⑴★**나머지 규칙 중 «수를 아는데 버리는» 것을 세라**(S) — 이 회차는 **한 규칙**만 고쳤다. `validation.rs` 의 + 다른 14규칙 중 몇이 같은 형상인지 **아직 모른다**. 세고 나서 값하면 같은 패턴으로 옮기면 된다. +⑵★**`ClassDefinitionError` 의 `&'static str` 두 변형**(`InvalidClassFile`·`UnsupportedFeature`)도 같은 한계를 + 갖는다(M) — 이 회차는 **우회**했다(새 변형을 나란히 더했다). 층을 통째로 구조화할지는 별 판단이다. diff --git a/jvm-bytecode/src/error.rs b/jvm-bytecode/src/error.rs index af56e5a8..a15d7cc7 100644 --- a/jvm-bytecode/src/error.rs +++ b/jvm-bytecode/src/error.rs @@ -1,17 +1,55 @@ +use alloc::{format, string::String}; + use classfile::ClassFileError; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClassDefinitionError { InvalidClassFile(&'static str), + /// The classfile-layer variant of the same name, carried through rather than flattened. + /// + /// ★ This layer is `&'static str` like the one below it, so folding this into + /// `InvalidClassFile` is exactly where the index would be lost — which is the whole point of + /// the variant existing. `UnsupportedClassVersion` is carried the same way for the same reason. + InvalidBootstrapArgument { + method_index: u16, + argument_index: u16, + actual: Option<&'static str>, + }, UnsupportedClassVersion(u16), Verification, UnsupportedFeature(&'static str), } +impl ClassDefinitionError { + /// The `ClassFormatError` message for a bootstrap argument that is not a loadable constant. + /// + /// ★ It lives here, and not at each boundary, because there are two boundaries — the binary's + /// runtime and `test-utils` — and a format string copied into both is two sources of truth that + /// drift. They call this instead. + /// + /// Shape: OpenJDK prints `argument_index 4 has bad constant type`, which does not say *which* + /// bootstrap method when a class has several. This names both. + pub fn bootstrap_argument_message(method_index: u16, argument_index: u16, actual: Option<&'static str>) -> String { + match actual { + Some(kind) => format!("bootstrap method #{method_index} argument #{argument_index} names a {kind}, which is not a loadable constant"), + None => format!("bootstrap method #{method_index} argument #{argument_index} names no constant pool entry"), + } + } +} + impl From for ClassDefinitionError { fn from(error: ClassFileError) -> Self { match error { ClassFileError::InvalidFormat(cause) => Self::InvalidClassFile(cause), + ClassFileError::InvalidBootstrapArgument { + method_index, + argument_index, + actual, + } => Self::InvalidBootstrapArgument { + method_index, + argument_index, + actual, + }, ClassFileError::UnsupportedVersion(version) => Self::UnsupportedClassVersion(version), } } diff --git a/src/runtime.rs b/src/runtime.rs index eda99b05..d68d68e8 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -187,6 +187,16 @@ where match ClassDefinitionImpl::from_classfile(data) { Ok(class) => Ok(Box::new(class)), Err(ClassDefinitionError::InvalidClassFile(cause)) => Err(jvm.exception("java/lang/ClassFormatError", cause).await), + Err(ClassDefinitionError::InvalidBootstrapArgument { + method_index, + argument_index, + actual, + }) => Err(jvm + .exception( + "java/lang/ClassFormatError", + &ClassDefinitionError::bootstrap_argument_message(method_index, argument_index, actual), + ) + .await), Err(ClassDefinitionError::UnsupportedClassVersion(version)) => Err(jvm .exception( "java/lang/UnsupportedClassVersionError", diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index e68a1d0f..5e7dc7e3 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -332,6 +332,16 @@ impl Runtime for TestRuntime { match ClassDefinitionImpl::from_classfile(data) { Ok(class) => Ok(Box::new(class)), Err(ClassDefinitionError::InvalidClassFile(cause)) => Err(jvm.exception("java/lang/ClassFormatError", cause).await), + Err(ClassDefinitionError::InvalidBootstrapArgument { + method_index, + argument_index, + actual, + }) => Err(jvm + .exception( + "java/lang/ClassFormatError", + &ClassDefinitionError::bootstrap_argument_message(method_index, argument_index, actual), + ) + .await), Err(ClassDefinitionError::UnsupportedClassVersion(version)) => Err(jvm .exception( "java/lang/UnsupportedClassVersionError", diff --git a/tests/test_class_format.rs b/tests/test_class_format.rs index 0178e249..69737724 100644 --- a/tests/test_class_format.rs +++ b/tests/test_class_format.rs @@ -641,7 +641,9 @@ async fn test_a_rejected_class_says_why() { ( "test-data/ldc/LdcDynamicBSMArgPastEnd.class", "./test-data/ldc/", - "a bootstrap method argument names nothing or is not a loadable constant", + // Was: "a bootstrap method argument names nothing or is not a loadable constant" — the + // rule now says which argument, so the fixture's own numbers appear here. + "bootstrap method #0 argument #0 names no constant pool entry", ), ] { let err = run_class(Path::new(fixture), &[Path::new(directory)], &[]).await.unwrap_err().to_string();