Skip to content

Commit eb8b4eb

Browse files
author
jun0
committed
[rustjava-cp-tag-switch-passthrough-mutation-detectable] test(classfile): 태그 pass-through 개악을 end-to-end 로 «잡히게» 한다
「알 수 없는 상수풀 태그를 거부한다」는 테스트가 그 가지를 개악해도 green 이었다. - 근인(판별 실험): 옛 픽스처가 Hello.class 의 «참조되는» Methodref 슬롯을 덮어 파일이 여러 경로로 동시에 깨졌고, ClassFileError 가 문면을 평탄화해 「태그가 미지라 거부」와 「클래스가 무너져 거부」를 구별하지 못했다. ★desync 가설은 기각했다 — 4바이트를 정확히 소비하는 개악도 green 이었다. - 처방: 단언을 조이는 것이 아니라 입력이 그 가지에 «유일한 결함»으로 도달하게 했다. test-data/cp/UnreferencedTag{13,14,19}.class = 참조되지 않고 · 페이로드 0 · 상수풀 맨 끝인 엔트리 하나만 미지 태그. - 전/후: 같은 개악에 대해 전 ok → 후 red(문면 must be rejected: "" = 클래스가 실행됨). 다른 가지(태그 16) 개악 → 6 테스트 red ⇒ 스위트는 여전히 switch 전체를 지킨다. ★제품 코드 변경 0 — 개악은 실증용 임시이고 전부 되돌렸다.
1 parent f140107 commit eb8b4eb

9 files changed

Lines changed: 270 additions & 12 deletions

‎REPORT.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
# REPORT
22

3+
## [2026-09-16] 「알 수 없는 태그를 거부한다」는 테스트가 ★**그것을 지키지 않았다** — 지키게 했다 (rustjava-cp-tag-switch-passthrough-mutation-detectable)
4+
- 무엇을: `test_unsupported_constant_pool_tag_raises_class_format_error` 가 ★**상수풀 태그 switch 의 pass-through 가지를
5+
개악해도 green** 이었다. 그 가지가 «끝에서 끝까지» 관측되도록 **픽스처를 바꿔** 이제 **red** 가 되게 했다.
6+
★**제품 코드 변경 0**(개악은 실증용 임시 · 전부 되돌렸다 · `git status` 로 확인).
7+
- 왜: 채택 제안 `2026-09-16-ldc-tags-15-16-17#p1`. ★**「소비되지 않는 경보는 장식이다」의 테스트판** — 상수 pass 로 바꿔도
8+
통과하는 단언은 «없는 것과 같다».
9+
- 사용자 영향: **없다**(테스트만 바뀐다). 바뀐 것은 ★**그 단언이 실제로 무엇을 잠그는가**다.
10+
- ★★**근인 — 「통과한 진짜 이유」를 판별 실험으로 좁혔다.** 옛 테스트는 `Hello.class` **상수풀 1번**의 태그 바이트를 덮었는데,
11+
그 슬롯은 ★**코드가 `invokespecial` 로 «참조»하는 Methodref** 다 ⇒ 덮는 순간 파일이 **여러 경로로 동시에** 깨진다.
12+
`ClassFileError` 는 모든 파싱 실패를 ★**「Invalid class file」로 평탄화**하므로(그 파일이 스스로 적어 둔 사실)
13+
단언이 ★**「태그가 미지라 거부」와 「클래스가 무너져 거부」를 구별하지 못한다.**
14+
★**바이트 폭 어긋남(desync)은 근인이 «아니었다»** — 4바이트를 정확히 소비하는 개악으로도 **여전히 green** 이었다(판별 실험 B).
15+
- ★**처방은 단언 조이기가 «아니다»**(문면이 평탄해 불가능하다) — ★**입력이 그 가지에 «유일한 결함»으로 도달하게** 했다:
16+
`test-data/cp/UnreferencedTag{13,14,19}.class`(신규 생성기 `test-data/src/cp/make_cp_fixtures.py`) =
17+
★**참조되지 않고 · 페이로드가 없고 · 상수풀 «맨 끝»**인 엔트리 하나만 미지 태그다.
18+
★그 세 성질이 «전부» 값한다 — 맨 끝 + 페이로드 0이라야 pass-through 개악이 **정상 동작하는 클래스**를 만들고, 그래야 red 가 된다.
19+
- ★★**전/후 — 같은 개악, 다른 결과**: ⑴**전**: pass-through 개악 → 그 테스트 **ok**(스위트에서 무는 것은 `classfile` 단위 테스트 1건뿐)
20+
⑵**후**: 같은 개악 → ★**red**(실패 문면이 `must be rejected: ""` = 클래스가 «성공적으로 실행»됐다는 뜻).
21+
⑶**다른 가지 개악**(태그 16 거부) → **6 테스트 red** ⇒ 스위트가 여전히 switch 전체를 지킨다.
22+
- 검증: `cargo test --all` **568 / 0 failed / 1 ignored**(수 불변 — 테스트 1개 치환) · DoD 7줄 rc=0 · 픽스처 재생성 멱등 ·
23+
★**옛 픽스처(`BadTag*`)를 쓰던 다른 테스트 0건**(전수 확인) · `hello_class()`·`fixture()` 헬퍼는 여전히 4·5회 쓰인다(고아 0).
24+
- 후속 추천: ⑴같은 자를 다른 «조용한» 단언에 대 보기(개악 내성 감사) ⑵`ClassFileError` 에 원인 변종을 되살릴지 판정(상류 과제).
25+
상세 = `docs/worklog/2026-09-16-cp-tag-passthrough-detectable.md`.
26+
327
## [2026-09-16] `BootstrapMethods` 를 «구조»로 읽는다 — 콜사이트 링크는 0줄 (rustjava-invokedynamic-bootstrapmethods-and-methodhandle)
428
- 무엇을: `AttributeInfo::BootstrapMethods` 를 **`Vec<u8>` → `Vec<BootstrapMethod>`**(JVMS 4.7.23)로 파싱하고,
529
`CONSTANT_MethodHandle` 을 `MethodHandleRef`(`MethodHandleKind` 9종 + 클래스·이름·서술자)로 **해독**한다.

‎STATE.md‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,29 @@
44
(없음 — 2026-09-16 실측: 착수 시 진행 티켓 0 · 열린 PR 0. ※「열린 PR 0」은 ★**이 회차 PR 착지 시점 기준**이다 — 회신 시점에는 그 PR 자신이 열려 있다)
55

66
## 완료
7+
- [rustjava-cp-tag-switch-passthrough-mutation-detectable] ★★**「알 수 없는 태그를 거부한다」는 테스트가 그것을 «지키지 않았다» — 지키게 했다.**
8+
채택 제안 `2026-09-16-ldc-tags-15-16-17#p1`(worklog json `adoptedProposals` 기록).
9+
★**제품 코드 변경 «0»** — 개악은 실증용 임시이고 전부 되돌렸다(`git status classfile/ jvm-bytecode/` **0건**으로 확인).
10+
★★**대전제를 «먼저» 실증했다**(티켓 ⓐ): 태그 switch 의 `_ => Err(...)` 를 `_ => Ok(Integer(0))` 로 개악하니
11+
★**그 테스트는 «ok»** 였고 스위트에서 무는 것은 `constant_pool::tests::tags_outside_the_accepted_set_are_still_rejected`
12+
**단 1건**이었다 ⇒ ★**end-to-end 층에는 그 가지를 무는 것이 «없었다»**(직전 회차가 「M5 층 어긋남」으로 남긴 그것).
13+
★★**근인 — 판별 실험으로 좁혔다(추측 아님)**: 옛 테스트는 `Hello.class` **상수풀 1번**의 태그를 덮는데
14+
그 슬롯은 ★**코드가 참조하는 Methodref** 라 덮는 순간 파일이 **여러 경로로 동시에** 깨진다. 그리고 `ClassFileError` 가
15+
모든 파싱 실패를 ★**「Invalid class file」로 평탄화**하므로(그 테스트 파일이 스스로 적어 둔 사실) 단언이
16+
★**「태그가 미지라 거부」와 「클래스가 무너져 거부」를 구별하지 못한다.**
17+
★**바이트 어긋남(desync)은 근인이 «아니다»** — 4바이트를 정확히 소비하는 개악(B)으로도 **여전히 green** 이었다.
18+
⇒ ★**두 가설 중 하나를 실험으로 기각했다.**
19+
★★**그러므로 처방이 «단언 조이기»가 아니다** — 문면이 평탄해 조일 것이 없다. 티켓 계약 1 이 예측한 대로
20+
★**입력이 그 가지에 «유일한 결함»으로 도달하게** 만들었다: `test-data/cp/UnreferencedTag{13,14,19}.class`
21+
(생성기 `test-data/src/cp/make_cp_fixtures.py` 신규) = ★**참조되지 않고 · 페이로드 0 · 상수풀 «맨 끝»** 인 엔트리 하나.
22+
★**세 성질이 전부 값한다** — 맨 끝 + 페이로드 0 이라야 pass-through 개악이 ★**«정상 동작하는» 클래스**를 만들고,
23+
그래야 테스트가 red 가 된다(그렇지 않으면 «다르게 깨진» 파일이 되어 또 green 이다).
24+
★★**전/후 — 같은 개악, 다른 결과**: **전** = 그 테스트 **ok** / **후** = ★**red**
25+
(실패 문면 `a tag that cannot appear in a class file must be rejected: ""` — ★빈 출력 = 클래스가 «성공적으로 실행»됐다).
26+
★**⒞ 다른 가지 개악**(태그 16 거부) → **6 테스트 red** ⇒ 스위트가 여전히 switch 전체를 지킨다(이 회차가 좁히지 않았다).
27+
★`cargo test --all` **568 / 0 failed / 1 ignored**(★수 불변 — 테스트 1개 «치환») · DoD **7줄 전건 rc=0** · 픽스처 재생성 **멱등**.
28+
★**계약 2⒜ 전수 확인**: 옛 픽스처(`BadTag*`)를 쓰던 다른 테스트 **0건** · `hello_class()`·`fixture()` 헬퍼는 여전히 **4·5회** 쓰여 고아 0.
29+
★**계약 2⒝ 오탐**: 새 단언은 ★**오탐이 늘지 않는다** — 픽스처가 «유일한 결함»만 갖도록 지어져 있어 다른 변경이 이 테스트를 흔들 경로가 좁다.
730
- [rustjava-invokedynamic-bootstrapmethods-and-methodhandle] ★★**`BootstrapMethods` 를 «구조»로 읽는다 — ④-1 의 ⒜ 를 닫았다. ★콜사이트 링크 0줄.**
831
채택 제안 `2026-09-16-cp-tags-15-18-parse#p0`(worklog json `adoptedProposals` 에 기록).
932
★**`AttributeInfo::BootstrapMethods(Vec<u8>)` → `Vec<BootstrapMethod>`** · 신규 공개 타입 3종
@@ -900,8 +923,7 @@ green 전건 rc=0 · `cargo test --all` **261 passed / 0 failed / 1 ignored**(S3
900923
참조 JVM 은 `Missing BootstrapMethods attribute` 인데 우리는 **여전히 「미지원」**이다.
901924
★그 경계 검사는 **속성 파싱이 정말로 필요**하므로 ④-1(PR #45 리니지) 몫이다 — ★**픽스처와 테스트로 «현재 답»을 잠가 뒀으니
902925
그 회차가 닫으면 그 단언이 «시끄럽게» 진다.**
903-
★**남긴 것 둘 더**: ⑵★**M5 층 어긋남**: 상수풀 태그 pass-through 개악을
904-
`test_class_format` 이 **못 잡는다**(무는 것은 `classfile` 단위 테스트) ⑶서드파티 생성기 corpus **미측정**(이 머신에 jar 0개).
926+
★**남긴 것 둘 더**: ⑵★**M5 층 어긋남**: ★**[2026-09-16 닫힘 · `rustjava-cp-tag-switch-passthrough-mutation-detectable`] 이제 `test_class_format` 이 «잡는다»**(픽스처를 «유일한 결함»으로 다시 지었다 — 종전엔 참조되는 Methodref 슬롯을 덮어 «다른 이유»로 통과했다) ⑶서드파티 생성기 corpus **미측정**(이 머신에 jar 0개).
905927
3. ★InputStreamReader 디코더 — 아래 사료 절 셋째 항목 그대로 **살아 있다**(별건).
906928

907929
---- 이하 사료(2026-08-16 기재 · 크레이트 경로·태그 서술은 낡았다) ----
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"schema": "worklog/v1",
3+
"date": "2026-09-16",
4+
"taskId": "rustjava-cp-tag-switch-passthrough-mutation-detectable",
5+
"summary": "A test that claimed to prove 'we still reject unknown constant pool tags' passed for a different reason. Rebuilt its fixture so the unknown tag is the only defect, making the pass-through mutation detectable end to end.",
6+
"changes": [
7+
"test-data/src/cp/make_cp_fixtures.py + test-data/cp/UnreferencedTag{13,14,19}.class: unreferenced, payload-free, trailing pool entry carrying the unknown tag",
8+
"tests/test_class_format.rs: test_unsupported_constant_pool_tag_raises_class_format_error now loads those fixtures instead of overwriting Hello.class's first (referenced) entry"
9+
],
10+
"verification": [
11+
"before: pass-through branch mutated to accept -> the test still passed; only constant_pool::tests::tags_outside_the_accepted_set_are_still_rejected caught it",
12+
"competing hypothesis rejected by experiment: a mutation consuming exactly 4 bytes (no desync) also left the test green",
13+
"after: same mutation -> test red, failing with 'must be rejected: \"\"' (empty output = the class ran)",
14+
"a different branch (tag 16) mutated -> 6 tests red, so the suite still guards the whole switch",
15+
"cargo test --all: 568 passed / 0 failed / 1 ignored (count unchanged: a test was replaced)",
16+
"product code unchanged in the final diff; constant_pool.rs restored to sha 393e0594d7eb647e",
17+
"no other test referenced the old BadTag* fixtures; hello_class()/fixture() helpers still used",
18+
"DoD 7 commands all rc=0; fixture regeneration idempotent"
19+
],
20+
"issues": [
21+
"ClassFileError still flattens every parse failure into 'Invalid class file'. This round worked around that by constructing an input with a single defect, but any future test that wants to assert *why* a file was rejected faces the same wall."
22+
],
23+
"adoptedProposals": [
24+
"2026-09-16-ldc-tags-15-16-17#p1"
25+
],
26+
"proposals": [
27+
{
28+
"title": "Audit the other end-to-end assertions for mutation resistance",
29+
"plainSummary": "Check whether other tests in the class-format suite also pass for reasons other than the one they claim.",
30+
"userBenefit": "Tests that cannot fail are worse than absent ones, because they are counted as coverage. Finding the rest of them is cheap now that the method is known.",
31+
"why": "This round found one such test by mutating the branch it named and observing green. The same procedure applies mechanically to the other assertions in tests/test_class_format.rs, several of which also rely on corrupting a referenced slot of Hello.class and then asserting only the flattened error kind.",
32+
"tradeoff": "Each one found may need its own purpose-built fixture, as this one did, so the cost is per-test rather than a single sweep. Some may turn out to be adequately guarded by classfile unit tests, in which case the honest outcome is a note rather than a change.",
33+
"effort": "M",
34+
"target": "tests/test_class_format.rs"
35+
},
36+
{
37+
"title": "Decide whether ClassFileError should carry a cause again",
38+
"plainSummary": "Every parse failure currently reports the same flat message, so tests cannot assert why a file was rejected.",
39+
"userBenefit": "Users get a diagnosis that says what is wrong with their class file, and tests can assert the specific reason instead of just the exception kind.",
40+
"why": "The flattening is why this round could not fix the test by tightening its assertion, and it is recorded as a known limitation at the top of tests/test_class_format.rs. Restoring variants would let the assertion name the cause directly, which is a stronger lock than a carefully shaped fixture.",
41+
"tradeoff": "The note says restoring variants needs upstream changes, so this is not purely local — and this repo is a fork whose upstream contact is deliberately read-only. It may be better solved locally, or not at all.",
42+
"effort": "M",
43+
"target": "classfile/src/error.rs"
44+
}
45+
]
46+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# 2026-09-16 — Making the constant-pool tag switch's pass-through branch observable
2+
3+
`taskId: rustjava-cp-tag-switch-passthrough-mutation-detectable`
4+
5+
## The premise, demonstrated before touching anything
6+
7+
`ConstantPoolItem::parse_tagged` ends in `_ => Err(...)` — the branch that rejects tags that cannot
8+
appear in a class file. Mutating it to accept:
9+
10+
```rust
11+
_ => Ok((data, Self::Integer(0))),
12+
```
13+
14+
`test_unsupported_constant_pool_tag_raises_class_format_error` — the test whose stated job is
15+
"we still reject unknown constant pool tags" — **still passed**. Across the whole suite exactly one
16+
test caught the mutation, and it was a `classfile` unit test, not the end-to-end one.
17+
18+
## Why it passed — narrowed by experiment, not by guessing
19+
20+
The old test overwrote the tag byte of `Hello.class`'s **first** constant pool entry. That entry is
21+
a `Methodref` the code invokes, so overwriting it breaks the file along several independent paths
22+
at once. `ClassFileError` flattens every parse failure into `"Invalid class file"` (a limitation
23+
the test file already documented), so the assertion cannot distinguish *rejected because the tag is
24+
unknown* from *rejected because the class fell apart*.
25+
26+
The obvious competing hypothesis — that a pass-through consuming zero bytes desynchronises the
27+
reader and breaks the pool that way — was **tested and rejected**: a mutation consuming exactly
28+
four bytes, matching the `Methodref` payload it replaced, still left the test green.
29+
30+
## The fix is not a tighter assertion
31+
32+
There is nothing to tighten; the message is flat. The input has to reach the branch as the **only**
33+
defect. `test-data/src/cp/make_cp_fixtures.py` emits `UnreferencedTag{13,14,19}.class`: a class
34+
valid in every respect except one pool entry that is
35+
36+
* **unreferenced** — nothing points at it, so no downstream consumer can reject it instead;
37+
* **payload-free** and **last** — which is what an unassigned tag looks like, and which means a
38+
pass-through consuming nothing leaves the reader correctly positioned at `access_flags`.
39+
40+
All three properties are load-bearing. Without the last two, a mutated parser produces a
41+
*differently broken* class and the test goes green again for a new wrong reason.
42+
43+
## Evidence
44+
45+
| | pass-through mutated | result |
46+
|---|---|---|
47+
| before this round | reject → accept | test **ok** (the defect) |
48+
| after this round | reject → accept | test **red**, failing with `must be rejected: ""` — the empty output meaning the class ran to completion |
49+
| after this round | a *different* branch (tag 16) mutated | **6 tests red** — the suite still guards the whole switch |
50+
51+
`cargo test --all`: 568 passed / 0 failed / 1 ignored — unchanged, because one test was replaced.
52+
The count is not the evidence; the before/after mutation pair is.
53+
54+
**Product code is untouched in the final diff.** The mutations were temporary demonstrations and
55+
were reverted; `git status classfile/ jvm-bytecode/` reports zero changed files, and the restored
56+
`constant_pool.rs` hashes back to `393e0594d7eb647e`.
57+
58+
## Fallout check
59+
60+
No other test used the old in-test fixtures (`BadTag*`: zero references). The `hello_class()` and
61+
`fixture()` helpers are still used 4 and 5 times respectively, so nothing was orphaned.
62+
63+
The new assertion should not add false positives: the fixture is built to carry exactly one defect,
64+
so there is little surface for an unrelated change to disturb it.
65+
66+
## Follow-ups
67+
68+
See `proposals` in the sibling `.json`.
136 Bytes
Binary file not shown.
136 Bytes
Binary file not shown.
136 Bytes
Binary file not shown.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#!/usr/bin/env python3
2+
"""Emit the constant-pool tag fixtures under `test-data/cp/`.
3+
4+
These exist for one reason: to make the tag switch's pass-through branch observable end to end.
5+
6+
`tests/test_class_format.rs` already had a test for "we still reject unknown constant pool tags",
7+
built by overwriting the tag byte of `test-data/Hello.class`'s first pool entry. That entry is a
8+
Methodref the code invokes, so overwriting it breaks the class along several independent paths at
9+
once — the operand of `invokespecial` stops being a method reference, and so on. `ClassFileError`
10+
collapses every parse failure into a flat "Invalid class file", so the assertion cannot tell
11+
"rejected because the tag is unknown" from "rejected because the class fell apart". Measured: with
12+
the pass-through branch mutated from reject to accept, that test still passed.
13+
14+
The fixtures here make the unknown tag the **only** thing wrong:
15+
16+
* the entry is unreferenced — nothing in the code or the class structure points at it, so no
17+
downstream consumer can reject it on the entry's behalf;
18+
* it is the **last** pool entry and carries **no payload**, which is what an unassigned tag
19+
looks like — there is no defined size for it.
20+
21+
Both properties are load-bearing. Being last and payload-free means a pass-through that consumes
22+
nothing leaves the reader correctly positioned at `access_flags`, so a mutated parser produces a
23+
*working* class rather than a differently-broken one. That is what turns the mutation into a
24+
red test instead of a green one for the wrong reason.
25+
26+
Tags 13 and 14 are unassigned by JVMS 4.4; 19 (Module) is assigned but legal only inside a
27+
module-info, so it cannot appear here either.
28+
29+
Regenerate with: python3 test-data/src/cp/make_cp_fixtures.py
30+
"""
31+
32+
import struct
33+
from pathlib import Path
34+
35+
OUT = Path(__file__).resolve().parents[2] / "cp"
36+
37+
u1 = lambda x: struct.pack(">B", x)
38+
u2 = lambda x: struct.pack(">H", x)
39+
u4 = lambda x: struct.pack(">I", x)
40+
41+
42+
class Pool:
43+
def __init__(self):
44+
self.entries = [] # 1-based, no long/double so no double slots
45+
46+
def add(self, blob):
47+
self.entries.append(blob)
48+
return len(self.entries)
49+
50+
def utf8(self, s):
51+
b = s.encode()
52+
return self.add(u1(1) + u2(len(b)) + b)
53+
54+
def klass(self, name):
55+
return self.add(u1(7) + u2(self.utf8(name)))
56+
57+
def bytes(self):
58+
return u2(len(self.entries) + 1) + b"".join(self.entries)
59+
60+
61+
def unreferenced_unknown_tag(name, tag):
62+
"""A class that is valid in every respect except for one trailing, unreferenced pool entry
63+
whose tag cannot appear in a class file."""
64+
cp = Pool()
65+
this_class = cp.klass(name)
66+
super_class = cp.klass("java/lang/Object")
67+
main_name, main_desc, code_name = cp.utf8("main"), cp.utf8("([Ljava/lang/String;)V"), cp.utf8("Code")
68+
69+
# Last, and payload-free — see the module docstring; both properties are load-bearing.
70+
cp.add(u1(tag))
71+
72+
body = b"\xb1" # return
73+
code_attr = u2(0) + u2(1) + u4(len(body)) + body + u2(0) + u2(0)
74+
method = u2(0x0009) + u2(main_name) + u2(main_desc) + u2(1) + u2(code_name) + u4(len(code_attr)) + code_attr
75+
76+
return (
77+
b"\xca\xfe\xba\xbe" + u2(0) + u2(52) + cp.bytes()
78+
+ u2(0x0021) + u2(this_class) + u2(super_class)
79+
+ u2(0) + u2(0) + u2(1) + method
80+
+ u2(0) # no class attributes
81+
)
82+
83+
84+
FIXTURES = {f"UnreferencedTag{tag}.class": (f"UnreferencedTag{tag}", tag) for tag in (13, 14, 19)}
85+
86+
if __name__ == "__main__":
87+
OUT.mkdir(parents=True, exist_ok=True)
88+
for filename, args in FIXTURES.items():
89+
(OUT / filename).write_bytes(unreferenced_unknown_tag(*args))
90+
print(f"wrote {OUT / filename}")

0 commit comments

Comments
 (0)