diff --git a/REPORT.md b/REPORT.md index 6b24aab0..bae40ee3 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,5 +1,25 @@ # REPORT +## [2026-09-16] `bootstrap_method_attr_index` 가 «실재하는» 부트스트랩 메서드를 가리키게 했다 (rustjava-bound-bootstrap-method-attr-index) +- 무엇을: `Dynamic`/`InvokeDynamic` 상수의 `bootstrap_method_attr_index` 가 **BootstrapMethods 테이블 안**을 가리키는지 + 검사한다(JVMS 4.4.10·4.7.23). ★**두 축이 한 술어다** — 속성이 «아예 없는» 경우는 «항목 0개짜리 표»여서 어떤 인덱스도 못 가리킨다. +- 왜: 채택 제안 `2026-09-16-bootstrap-methods-and-method-handle#p1` · `2026-09-16-ldc-tags-15-16-17#p0`(서로 다른 회차가 **독립으로** 같은 결함에 닿았다). +- 사용자 영향: ★**진단이 바뀐다** — 그 두 형상이 `UnsupportedOperationException`(「이 런타임이 아직 못 한다」) → + ★`ClassFormatError`(「이 파일이 깨졌다」). ★**어떤 JVM 도 못 읽는 파일을 «미지원»이라 부르던 것을 그만둔다.** +- ★★**의도된 «거부 확대»다 — 하류에는 회귀로 보인다.** 지금까지 조용히 「미지원」으로 넘어가던 클래스 파일이 **거부**된다. + ★그 전환이 이 회차의 산출물 자체이고, OpenJDK 26 은 같은 파일에 `ClassFormatError: Missing BootstrapMethods attribute` 를 낸다. +- ★**검증 지점을 하나로 모았다**(계약 1): `validate_class` 안의 `bootstrap_method_indices_resolve` **한 함수** · + ★**새 에러 타입 0**(기존 `ClassFileError::InvalidFormat` 에 접었다 — 호출부 변종 증가 0). + ★`validate_constant_pool` 이 아니라 `validate_class` 인 이유 = **풀과 클래스 속성을 «둘 다» 쥔 유일한 자리**이고, + 그 교차가 이 검사가 여태 없던 이유였다(그 자리의 낡은 주석이 그렇게 적고 「원하는 회차에 맡긴다」고 했다 — 이 회차가 그것이다). +- ★**개악 3종**: 상수 `true` → 새 테스트 red(축 ⒜⒞) · 상수 `false` → **24 테스트** red(축 ⒝⒟) · + ★내부 술어만 `false` → **8 테스트** red이고 `test_hello` 류는 **green** ⇒ ★**⒝ 와 ⒟ 가 갈린다**(`false` 하나로는 둘이 겹친다). +- 검증: `cargo test --all` **568 / 0 failed / 1 ignored**(수 불변 — 테스트 1개를 «치환»했다) · DoD 7줄 rc=0 · + ★픽스처 재생성 **멱등**(기존 10개 바이트 불변 · 신규 1개만 추가). +- 후속 추천: ⑴`BootstrapMethods` 중복 선언 거부(JVMS 4.7.23 은 «최대 1개» — 지금은 첫 것만 본다) + ⑵`MethodHandleKind` 의 위치 재판정(형제 티켓) ⑶`StringConcatFactory` 콜사이트 링크(L · 별 티켓). + 상세 = `docs/worklog/2026-09-16-bound-bootstrap-method-attr-index.md`. + ## [2026-09-16] `BootstrapMethods` 를 «구조»로 읽는다 — 콜사이트 링크는 0줄 (rustjava-invokedynamic-bootstrapmethods-and-methodhandle) - 무엇을: `AttributeInfo::BootstrapMethods` 를 **`Vec` → `Vec`**(JVMS 4.7.23)로 파싱하고, `CONSTANT_MethodHandle` 을 `MethodHandleRef`(`MethodHandleKind` 9종 + 클래스·이름·서술자)로 **해독**한다. diff --git a/STATE.md b/STATE.md index 0e83f532..aede8efe 100644 --- a/STATE.md +++ b/STATE.md @@ -4,6 +4,39 @@ (없음 — 2026-09-16 실측: 착수 시 진행 티켓 0 · 열린 PR 0. ※「열린 PR 0」은 ★**이 회차 PR 착지 시점 기준**이다 — 회신 시점에는 그 PR 자신이 열려 있다) ## 완료 +- [rustjava-bound-bootstrap-method-attr-index] ★★**`bootstrap_method_attr_index` 가 «실재하는» 부트스트랩 메서드를 가리키게 했다 — 「파손」을 되찾았다.** + 채택 제안 **둘**을 한 회차가 닫았다(worklog json `adoptedProposals` 에 **전건** 기록): + `2026-09-16-bootstrap-methods-and-method-handle#p1` · `2026-09-16-ldc-tags-15-16-17#p0` — + ★**서로 다른 회차가 «독립으로» 같은 결함에 닿았다**(그래서 총괄이 둘을 합쳐 발권했다). + ★**전/후**: `UnsupportedOperationException`(「이 런타임이 아직 못 한다」) → ★`ClassFormatError`(「이 파일이 깨졌다」). + ★**JVMS 근거**: **4.4.10**(Dynamic/InvokeDynamic 의 `bootstrap_method_attr_index` 는 `BootstrapMethods` 의 + `bootstrap_methods` 배열에 대한 «유효한 인덱스»여야 한다) · **4.7.23**(그 상수를 가진 클래스는 그 속성을 «가져야» 한다). + ★**참조 JVM**: OpenJDK 26 은 부재 형상에 `ClassFormatError: Missing BootstrapMethods attribute` 를 낸다. + ★★**두 축이 «한 술어»다 — 분기를 둘로 만들지 않았다**(티켓 계약 1): 속성 부재 = «항목 0개짜리 표»라 + 어떤 인덱스도 못 가리킨다 ⇒ `bootstrap_method_count.is_some_and(|count| (index as usize) < count)` 한 줄이 둘을 다 문다. + ★**자리 = `validate_class` 의 `bootstrap_method_indices_resolve`** — `validate_constant_pool` 이 아닌 이유는 + ★**풀과 «클래스 속성»을 둘 다 쥔 유일한 자리**이기 때문이고, 그 교차가 이 검사가 여태 없던 이유였다 + (그 자리의 낡은 주석이 스스로 그렇게 적고 「원하는 회차에 맡긴다」고 했다 — ★**이 회차가 그 회차다**). + ★**새 에러 타입 0** — 기존 `ClassFileError::InvalidFormat` 에 접었다(계약 2⒞: 늘리는 대신 접을 수 있으면 그쪽이 낫다). + ★**픽스처 +1**(`LdcDynamicBSMIndexPastEnd` = 1항목 표에 인덱스 1) — ★**생성기를 통해서만 만들 수 있다**(표를 쓰는 자리가 거기뿐). + ★**재생성 멱등 확인**: 기존 10개 **바이트 불변** · 신규 1개만 추가. + ★★**개악 3종 — `false` 하나로는 ⒝⒟ 가 «겹친다»**(그래서 셋을 돌렸다): + 상수 `true` → 새 테스트 red(**⒜⒞**) · 상수 `false` → **24 테스트** red(⒝⒟ 혼재) · + ★**내부 술어만 `false`** → **8 테스트** red(전건 dynamic 보유 클래스 = **⒝**)이고 `test_hello`·`test_switch`· + `test_odd_even`·`test_superclass` 는 **green**(= **⒟** 무영향) ⇒ ★**두 축이 실제로 갈렸다.** + ★`cargo test --all` **568 / 0 failed / 1 ignored**(★**수 불변** — 테스트를 «치환»했다. 수로는 안 보이므로 개악으로 물었다) · DoD **7줄 전건 rc=0**. + ★★**남긴 것**: `BootstrapMethods` **중복 선언**은 여전히 거부하지 않는다(JVMS 4.7.23 은 «최대 1개» · 지금은 `find_map` 이 첫 것만 본다) — + ★**이 회차 범위 밖이고 후속 추천에 적었다.** + ★★**게이트③ 착지 — PR #47 · `--merge`**(등재 repo `contracts/upstream-sync-repos.conf:22` · 스쿼시는 부모 2개를 1개로 접어 계보를 지운다). + 게이트② **1회차 approve**(반려 0) · 핀 `3b3667d6` **불이동**(동봉 전 실측 — 로컬·원격·PR head·리뷰 줄2 **4값 일치**) · + `ci-presence` **rc=0 CI_GREEN** · `mergeable` **MERGEABLE/CLEAN** · 자식 PR **0건** · + ★**배포 워크플로 0개 ⇒ 배포 0**(착지 diff 8파일 · `.github/workflows/` 6개 전건 deploy 어휘 0건). + ★★**묶지 «못한» 이유가 이 리니지의 산물이다** — `rustjava` 는 upstream 동기 등재라 묶음 경로에 `merge_strategy:` 를 + 담을 파일이 없고, 그러면 `bin/queue-lint` 검사22 와 집행 STOP **두 방어선이 «둘 다» 사라진다** + (`orch-upstream-sync-repos-cannot-bundle-gate3-ever`). ⇒ 별 `-merge` 티켓이 그 «선언을 담을 파일»이다. + ★★**형제 «둘»이 열려 있다 — 이 착지가 그 둘을 깬다**: **#48**(`StringConcatFactory` 링크 · 게이트② 대기) · + **#49**(상수풀 태그 pass-through 개악 탐지). ★**셋 다 `tests/test_class_format.rs` + 원장 2파일을 만진다** + (코드 파일은 갈린다 ⇒ **기능 의존 0**). ⇒ ★**그 둘은 각자 base 당기기가 필요하다** — 해소는 그쪽 회차 몫이고 여기서 만지지 않았다. - [rustjava-invokedynamic-bootstrapmethods-and-methodhandle] ★★**`BootstrapMethods` 를 «구조»로 읽는다 — ④-1 의 ⒜ 를 닫았다. ★콜사이트 링크 0줄.** 채택 제안 `2026-09-16-cp-tags-15-18-parse#p0`(worklog json `adoptedProposals` 에 기록). ★**`AttributeInfo::BootstrapMethods(Vec)` → `Vec`** · 신규 공개 타입 3종 @@ -867,7 +900,7 @@ green 전건 rc=0 · `cargo test --all` **261 passed / 0 failed / 1 ignored**(S3 ★**안 되는 것(전부 이름으로)**: ⑴클래스 로드 0 ⑵멤버 조회 0(그 메서드가 실재하는지 아무도 안 본다) ⑶접근 검사 0(JVMS 5.4.3.5) · ⑷★**`java.lang.invoke` 런타임 클래스가 «0개»다**(실측: `rustjava-runtime/src/classes/java/` 에 `invoke` 디렉터리 **부재** · 문자열 참조 **0건**) ⇒ **`MethodHandle` «객체»는 만들 수 없다** · ⑸종류↔대상 짝짓기는 **`validation.rs` 가 진다**(파서는 일부러 중복하지 않는다 — 테스트로 잠갔다) · - ⑹`Dynamic`/`InvokeDynamic` 의 `bootstrap_method_attr_index` 는 **여전히 배열 크기로 «경계 검사되지 않는다»**(④-2 후속과 한 묶음) · + ⑹`Dynamic`/`InvokeDynamic` 의 `bootstrap_method_attr_index` 는 ★**[2026-09-16 닫힘 · `rustjava-bound-bootstrap-method-attr-index`] 경계 검사된다**(부재 = 0항목 표로 함께 문다) · ⑺★**부트스트랩 «정적 인자»는 «인덱스 그대로»** 둔다(아래 ★). ★★**⑺ 이 이 회차의 급소다 — 「인덱스를 `ConstantPoolReference` 로 풀어라」는 «회귀»다**(M3 로 측정): `LambdaMetafactory.metafactory` 의 인자는 **MethodType·MethodHandle·MethodType** 이라 해석을 강제하면 diff --git a/classfile/src/validation.rs b/classfile/src/validation.rs index 44bcbd81..b6bfa5a6 100644 --- a/classfile/src/validation.rs +++ b/classfile/src/validation.rs @@ -15,6 +15,7 @@ pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> { || class.interfaces.iter().any(|name| !is_internal_class_name(name)) || !validate_constant_pool(&class.constant_pool) || !constant_pool_tags_fit_the_class_file_version(class) + || !bootstrap_method_indices_resolve(class) { return Err(ClassFileError::InvalidFormat); } @@ -97,6 +98,42 @@ fn constant_pool_tags_fit_the_class_file_version(class: &ClassInfo) -> bool { }) } +/// JVMS 4.4.10 and 4.7.23: `bootstrap_method_attr_index` is an index into the `bootstrap_methods` +/// array of the `BootstrapMethods` attribute, and that attribute must be present whenever the pool +/// holds a Dynamic or InvokeDynamic entry. Both halves are the same sentence — the index has to +/// name a real entry — so they are one predicate rather than two: an absent attribute is a table of +/// no entries, which no index can name. +/// +/// This lives in `validate_class` rather than `validate_constant_pool` because it is the only place +/// that holds both the pool and the class attributes. That crossing is the whole reason the check +/// did not exist before; the note it replaces said as much and left it to the round that wanted it. +/// +/// What this rejects was already being rejected by every real JVM — OpenJDK 26 answers +/// `ClassFormatError: Missing BootstrapMethods attribute` for the absent case. What we answered +/// was `UnsupportedOperationException`, i.e. *this runtime cannot do that yet*, about a file no +/// runtime can read. That sentence is what the lineage exists to make true, so narrowing it here +/// is the point rather than a side effect. +fn bootstrap_method_indices_resolve(class: &ClassInfo) -> bool { + let bootstrap_method_count = class.attributes.iter().find_map(|attribute| match attribute { + AttributeInfo::BootstrapMethods(methods) => Some(methods.len()), + _ => None, + }); + + class.constant_pool.values().all(|item| { + let index = match item { + ConstantPoolItem::Dynamic { + bootstrap_method_attr_index, .. + } + | ConstantPoolItem::InvokeDynamic { + bootstrap_method_attr_index, .. + } => *bootstrap_method_attr_index, + _ => return true, + }; + + bootstrap_method_count.is_some_and(|count| (index as usize) < count) + }) +} + fn validate_constant_pool(constant_pool: &BTreeMap) -> bool { constant_pool.values().all(|item| match item { ConstantPoolItem::Class { name_index } => constant_pool @@ -142,13 +179,9 @@ fn validate_constant_pool(constant_pool: &BTreeMap) -> bo .get(descriptor_index) .and_then(ConstantPoolItem::utf8) .is_some_and(|descriptor| is_method_descriptor(&descriptor)), - // The bootstrap method index is still not checked here, but the reason changed: - // `BootstrapMethods` is no longer a byte blob (see `AttributeInfo::BootstrapMethods`), so - // there now *is* something to bound it against — it just is not reachable from this - // function, which only gets the constant pool. Doing it needs `validate_class` to cross - // the pool with the class attributes, and that is a behaviour change (files that parse - // today would start being rejected), so it is left to the round that wants it. Tracked - // alongside the tag-vs-major-version check in `STATE.md` ④-2. + // The bootstrap method index is bounded by `bootstrap_method_indices_resolve`, not here: + // it needs the class attributes, and this function only gets the pool. What is left for + // this arm is the half that the pool alone can answer. ConstantPoolItem::Dynamic { name_and_type_index, .. } | ConstantPoolItem::InvokeDynamic { name_and_type_index, .. } => { constant_pool.get(name_and_type_index).and_then(ConstantPoolItem::name_and_type).is_some() } diff --git a/docs/worklog/2026-09-16-bound-bootstrap-method-attr-index.json b/docs/worklog/2026-09-16-bound-bootstrap-method-attr-index.json new file mode 100644 index 00000000..c4554017 --- /dev/null +++ b/docs/worklog/2026-09-16-bound-bootstrap-method-attr-index.json @@ -0,0 +1,47 @@ +{ + "schema": "worklog/v1", + "date": "2026-09-16", + "taskId": "rustjava-bound-bootstrap-method-attr-index", + "summary": "Bound bootstrap_method_attr_index against the BootstrapMethods table, and require that attribute when the pool needs it — one predicate, because an absent attribute is a zero-entry table.", + "changes": [ + "classfile/src/validation.rs: new bootstrap_method_indices_resolve, called from validate_class; replaces the note that deferred this check", + "test-data/src/ldc/make_ldc_fixtures.py: dynamic() takes attr_index; emits LdcDynamicBSMIndexPastEnd", + "test-data/ldc/LdcDynamicBSMIndexPastEnd.class: new fixture (index 1 into a one-entry table)", + "tests/test_class_format.rs: the deferred assertion flipped — both failure shapes now assert ClassFormatError" + ], + "verification": [ + "cargo test --all: 568 passed / 0 failed / 1 ignored (count unchanged: a test was replaced, not added)", + "mutation whole-predicate=true -> 1 test red (axes a, c)", + "mutation whole-predicate=false -> 24 tests red (axes b and d, conflated)", + "mutation inner-predicate=false -> 8 tests red, all dynamic-carrying; test_hello/test_switch stay green (axis b alone)", + "fixture regeneration idempotent: 10 existing .class files byte-identical, 1 added", + "DoD 7 commands all rc=0" + ], + "issues": [ + "BootstrapMethods is still not rejected when declared more than once; JVMS 4.7.23 allows at most one, and find_map takes the first. Out of scope for this round, filed as a proposal below." + ], + "adoptedProposals": [ + "2026-09-16-bootstrap-methods-and-method-handle#p1", + "2026-09-16-ldc-tags-15-16-17#p0" + ], + "proposals": [ + { + "title": "Reject a class declaring BootstrapMethods more than once", + "plainSummary": "A class file is only allowed one BootstrapMethods attribute. We currently read the first one and ignore any others.", + "userBenefit": "A hand-built or corrupted class file that carries two bootstrap tables is reported as broken instead of being silently read using whichever table came first.", + "why": "JVMS 4.7.23 says at most one BootstrapMethods attribute may appear in the attributes table of a ClassFile. bootstrap_method_indices_resolve uses find_map, which takes the first and never looks for a second, so the index is bounded against a table that may not be the only one.", + "tradeoff": "Another parse-time rejection of files that currently load, in a shape no compiler emits — so the benefit is narrow and the risk of surprising a real user is correspondingly small. It also needs a fixture the generator cannot currently build, since the attribute list is assembled per fixture.", + "effort": "S", + "target": "classfile/src/validation.rs, test-data/src/ldc/make_ldc_fixtures.py" + }, + { + "title": "Bound the static-argument indices of each bootstrap method", + "plainSummary": "Each bootstrap method carries a list of constant pool indices for its arguments. Nothing checks those point at real pool entries.", + "userBenefit": "The same honesty this round bought for the bootstrap method index, applied to its arguments: a file naming an argument that does not exist is broken, not unsupported.", + "why": "BootstrapMethod.arguments is deliberately kept as raw u16 indices (see the doc comment in attribute.rs) because resolving them would turn every lambda-carrying class from unsupported back into corrupt. Bounding them is not resolving them: checking that an index is present in the pool costs nothing semantic and closes the same class of lie.", + "tradeoff": "Must not drift into resolution. The attribute.rs comment explains at length why resolving is a regression; a bounds check has to stay a bounds check, and a future reader may not see the difference.", + "effort": "S", + "target": "classfile/src/validation.rs" + } + ] +} diff --git a/docs/worklog/2026-09-16-bound-bootstrap-method-attr-index.md b/docs/worklog/2026-09-16-bound-bootstrap-method-attr-index.md new file mode 100644 index 00000000..9af887da --- /dev/null +++ b/docs/worklog/2026-09-16-bound-bootstrap-method-attr-index.md @@ -0,0 +1,76 @@ +# 2026-09-16 — `bootstrap_method_attr_index` must name a bootstrap method that exists + +`taskId: rustjava-bound-bootstrap-method-attr-index` + +## What changed + +A `Dynamic` or `InvokeDynamic` constant carries `bootstrap_method_attr_index`, an index into the +`bootstrap_methods` array of the class's `BootstrapMethods` attribute. Nothing checked it. A class +could index past the end of that table, or carry such a constant with no `BootstrapMethods` +attribute at all, and this runtime answered `UnsupportedOperationException` — *this runtime cannot +do that yet* — about a file no JVM can read. + +`classfile/src/validation.rs` now has `bootstrap_method_indices_resolve`, called from +`validate_class`. Both failure shapes are **one predicate**, not two branches: an absent attribute +is a table of zero entries, which no index can name. + +```rust +bootstrap_method_count.is_some_and(|count| (index as usize) < count) +``` + +## Why it is in `validate_class` and not `validate_constant_pool` + +`validate_constant_pool` gets only the pool. This check needs the pool *and* the class attributes, +and `validate_class` is the only place holding both. That crossing is precisely why the check did +not exist: the comment standing at that spot said so, and left it "to the round that wants it". +This is that round; the comment is replaced by a pointer to the new function. + +## Specification + +- **JVMS 4.4.10** — `bootstrap_method_attr_index` must be a valid index into the `bootstrap_methods` + array of the `BootstrapMethods` attribute of this class file. +- **JVMS 4.7.23** — a class whose constant pool holds a `Dynamic` or `InvokeDynamic` entry must have + a `BootstrapMethods` attribute. +- **Reference runtime** — OpenJDK 26 answers `ClassFormatError: Missing BootstrapMethods attribute` + for the absent case. + +## What this costs — stated, not hidden + +Class files that used to pass parsing and fail later as *unsupported* are now **rejected** at parse +time. Downstream that reads as a regression; it is the deliverable. No new error variant was added — +it folds into the existing `ClassFileError::InvalidFormat`, so no caller gains a case to handle. +The check walks the constant pool once per class load, which is not free, but it is one pass over a +map that `validate_constant_pool` already walks. + +## Evidence + +Four axes, and the mutation that separates them: + +| axis | fixture / case | expected | +|---|---|---| +| ⒜ index past end | `LdcDynamicBSMIndexPastEnd` (index 1, one-entry table) | rejected | +| ⒝ valid index | `LdcDynamic`, `Ldc2WDynamic`, `StringConcat`, `Lambda`, `ConstantKinds` | passes | +| ⒞ attribute absent | `LdcDynamicNoBSM` | rejected | +| ⒟ attribute not needed | `Hello`, `Switch`, `OddEven`, `Superclass`, … | passes | + +Mutations: + +| mutation | red | covers | +|---|---|---| +| whole predicate → `true` | 1 test (`…naming_a_missing_bootstrap_method_is_malformed`) | ⒜ ⒞ | +| whole predicate → `false` | 24 tests | ⒝ and ⒟ **together** | +| inner predicate → `false` | 8 tests, all of them classes that carry a dynamic constant; `test_hello` etc. stay green | ⒝ **alone** | + +The third mutation exists because the second conflates two axes — rejecting *everything* makes both +"valid index passes" and "class without the attribute passes" go red at once, so it cannot show +which one is locked. Rejecting only dynamic-carrying classes separates them. + +`cargo test --all`: **568 passed / 0 failed / 1 ignored** — unchanged, because one test was +*replaced* rather than added. The count is not evidence here; the mutations are. + +Fixture regeneration is idempotent: re-running `make_ldc_fixtures.py` left the ten existing +`.class` files byte-identical and added only the new one. + +## Follow-ups + +See `proposals` in the sibling `.json`. diff --git a/test-data/ldc/LdcDynamicBSMIndexPastEnd.class b/test-data/ldc/LdcDynamicBSMIndexPastEnd.class new file mode 100644 index 00000000..8ecd5d7f Binary files /dev/null and b/test-data/ldc/LdcDynamicBSMIndexPastEnd.class differ diff --git a/test-data/src/ldc/make_ldc_fixtures.py b/test-data/src/ldc/make_ldc_fixtures.py index 03f9c516..1e5ce312 100644 --- a/test-data/src/ldc/make_ldc_fixtures.py +++ b/test-data/src/ldc/make_ldc_fixtures.py @@ -89,15 +89,18 @@ def build(cp, _attributes): def dynamic_without_bootstrap_methods(cp, _attributes): """A Dynamic entry naming bootstrap method 0 of an attribute that is not there. JVMS 4.7.23 requires the attribute whenever the pool holds a Dynamic/InvokeDynamic entry, so this is a - corrupt file — OpenJDK 26 says `ClassFormatError: Missing BootstrapMethods attribute`. - Bounding the index needs the attribute parsed, which is a different round's work, so this - fixture pins what we answer *today* rather than what we should.""" + corrupt file — OpenJDK 26 says `ClassFormatError: Missing BootstrapMethods attribute`.""" return cp.add(u1(17) + u2(0) + u2(cp.name_and_type("x", "Ljava/lang/Object;"))) -def dynamic(name, descriptor, bootstrap_method, bootstrap_descriptor): +def dynamic(name, descriptor, bootstrap_method, bootstrap_descriptor, attr_index=0): """A real condy, so the file is structurally complete: JVMS 4.7.23 requires the - BootstrapMethods attribute that a Dynamic entry indexes into.""" + BootstrapMethods attribute that a Dynamic entry indexes into. + + `attr_index` is the `bootstrap_method_attr_index` written into the entry. It defaults to 0, + the one entry this builder emits; passing anything else produces the out-of-range case, which + is the other half of the same rule and cannot be built any other way — the table is written + here, so only here can the index be made to overshoot it.""" def build(cp, attributes): bootstrap = cp.add( @@ -105,7 +108,7 @@ def build(cp, attributes): + u1(6) + u2(cp.methodref(cp.klass("java/lang/invoke/ConstantBootstraps"), cp.name_and_type(bootstrap_method, bootstrap_descriptor))) ) - entry = cp.add(u1(17) + u2(0) + u2(cp.name_and_type(name, descriptor))) + entry = cp.add(u1(17) + u2(attr_index) + u2(cp.name_and_type(name, descriptor))) body = u2(1) + u2(bootstrap) + u2(0) # one bootstrap method, no static arguments attributes.append(u2(cp.utf8("BootstrapMethods")) + u4(len(body)) + body) @@ -116,6 +119,8 @@ def build(cp, attributes): LOOKUP = "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;" null_constant = dynamic("x", "Ljava/lang/Object;", "nullConstant", LOOKUP) +# Same file, but the entry names bootstrap method 1 of a table holding only method 0. +past_end_constant = dynamic("x", "Ljava/lang/Object;", "nullConstant", LOOKUP, attr_index=1) # Long.MAX_VALUE, i.e. `J`-typed, which JVMS 6.5 puts on the `ldc2_w` side of the split. long_constant = dynamic("MAX_VALUE", "J", "getStaticFinal", LOOKUP) @@ -151,9 +156,11 @@ def build(cp, attributes): # (JVMS 4.4 ties tag 17 to major >= 55). OpenJDK 26: "Class file version does not support # constant tag 17". Widening `ldc` removed the accidental backstop that used to catch this. "LdcDynamicOldMajor.class": ("LdcDynamicOldMajor", null_constant, ldc, 1, 52), - # The band this round does NOT close: a Dynamic entry whose bootstrap method does not - # exist. Bounding that index needs BootstrapMethods parsed, which is another round's work. + # Negative control 4, two halves of one rule (JVMS 4.4.10 / 4.7.23): a Dynamic entry has to + # name a real bootstrap method. It can fail by the attribute being absent, or by the index + # overshooting a table that is present — OpenJDK 26 rejects both. "LdcDynamicNoBSM.class": ("LdcDynamicNoBSM", dynamic_without_bootstrap_methods, ldc, 1, 55), + "LdcDynamicBSMIndexPastEnd.class": ("LdcDynamicBSMIndexPastEnd", past_end_constant, ldc, 1, 55), } if __name__ == "__main__": diff --git a/tests/test_class_format.rs b/tests/test_class_format.rs index f58dec2d..1a426356 100644 --- a/tests/test_class_format.rs +++ b/tests/test_class_format.rs @@ -205,21 +205,30 @@ async fn test_a_constant_tag_below_its_minimum_class_file_version_is_malformed() } } -// The band this round leaves open, asserted as what it is rather than left unmentioned. Bounding -// `bootstrap_method_attr_index` needs the `BootstrapMethods` attribute parsed, which belongs to -// the invokedynamic-execution work, so today we still answer "unsupported" for a file OpenJDK 26 -// rejects outright ("Missing BootstrapMethods attribute"). When that round lands this assertion -// flips — and it should fail loudly then rather than quietly keep passing. +// The band that used to be left open, now closed — this is the flipped assertion the previous +// round asked for by name. A Dynamic entry has to name a real bootstrap method (JVMS 4.4.10, +// 4.7.23), and it can fail either way: the attribute absent, or the index past the end of a table +// that is present. Both were answered "this runtime does not support that yet" about files +// OpenJDK 26 rejects outright, which is the one sentence this lineage exists to keep honest. #[tokio::test] -async fn test_a_dynamic_constant_with_no_bootstrap_methods_attribute_is_still_only_unsupported() { - let path = Path::new("test-data/ldc/LdcDynamicNoBSM.class"); +async fn test_a_dynamic_constant_naming_a_missing_bootstrap_method_is_malformed() { + for (name, how) in [ + ("LdcDynamicNoBSM", "no BootstrapMethods attribute at all"), + ("LdcDynamicBSMIndexPastEnd", "index 1 into a one-entry table"), + ] { + let path = PathBuf::from(format!("test-data/ldc/{name}.class")); - let err = run_class(path, &[Path::new("./test-data/ldc/")], &[]).await.unwrap_err().to_string(); + let err = run_class(&path, &[Path::new("./test-data/ldc/")], &[]).await.unwrap_err().to_string(); - assert!( - err.contains("java.lang.UnsupportedOperationException"), - "known gap: expected the unsupported-feature diagnosis, got: {err}" - ); + assert!( + err.contains("java.lang.ClassFormatError"), + "{name} ({how}): expected ClassFormatError, got: {err}" + ); + assert!( + !err.contains("UnsupportedOperationException"), + "{name} ({how}): a file no JVM can read is not merely unsupported, got: {err}" + ); + } } // The same sentence, for the harder shape. A lambda's `BootstrapMethods` entry carries