diff --git a/REPORT.md b/REPORT.md index 7cc068db..5462d274 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,44 @@ # REPORT +## [2026-09-17] 신원 4축을 «각각» 관측 가능하게 했다 — 감사의 「고칠 것이 없다」를 정정한다 (rustjava-adopt-cp-tag-passthrough-detectable-p0-fix) +- 무엇을: `string_concat.rs` 의 부트스트랩 신원 **4축**(kind·class·name·descriptor) 중 ★**3축이 «관측되지 않고» 있었다** — + 근접 실패 픽스처가 **class 축 하나**뿐이었기 때문이다. 나머지 3축의 픽스처를 만들었다. ★**제품 코드 무접촉.** +- 왜: 게이트② **request-changes**(PR #55 · 핀 `ab13a3c7`). 검수자가 **축을 하나씩** 지워 ★**kind·name·descriptor 개악이 + «전 스위트 green 인 채로» 살아남는 것**을 찾았다(직전 감사 회차의 굵은 개악 M7 은 4축을 한꺼번에 지워 그것을 못 봤다). +- 사용자 영향: 없다(테스트 픽스처). ★바뀐 것은 ★**「아무것이나 링크해도 테스트가 green」인 상태가 사라진 것**이다. +- ★★**전/후**: kind·name·descriptor 축 단독 삭제 → **SURVIVED**(`--all` 570/0/1 green) ⇒ ★**KILLED** + (신규 `test_each_axis_of_the_factory_identity_is_observable`) · class 축은 **여전히 KILLED**(이제 2개를 죽인다 — 옛 커버리지 유지). +- ★**픽스처는 «적법한 파일»이어야 값한다**: `NotMakeConcatWithConstants`(name · `makeConcat` 은 실재 부트스트랩) · + `NotFactoryDescriptor`(descriptor · 여전히 적법한 서술자) · `NotInvokeStaticFactory`(kind **7** · JVMS 4.4.8 상 Methodref 와 적법). + ⇒ 넷 다 **신원 검사까지 도달**해 `UnsupportedOperationException` 로 거부된다(상류가 먼저 거부하면 «다른 이유»로 통과하는 것이다). +- ★★**감사 방법론에 «역»을 남겼다**: 원 회신은 「죽지 않았다 ≠ 테스트가 약하다」를 적었는데 그 역이 빠져 있었다 ⇒ + ★**「죽었다 ≠ 그 가지가 «전부» 덮였다」 — 다축 술어를 통째로 지우는 굵은 개악은 «어느 한 축이 덮였다»만 증명한다.** +- ★**하네스가 실제로 트리를 오염시켰다**(러너 SIGKILL → `finally` 미실행) — ★계약 ⒡ 의 **「치환 1건 단언」이 그것을 잡았다**. + 복원 후 4축 전부 재측했고, 이 사건이 「하네스를 커밋하지 않는다」 결정의 **실증**이다. +- 검증: 회귀 표본 2종(BSM 경계 off-by-one · `Ldc2W` arm 제거) **여전히 KILLED** · + `test_class_format` **11 → 12** · `cargo test --all --no-fail-fast` **570 → 571 / 0 failed** · 픽스처 재생성 **멱등** · DoD 7명령 rc=0. + +## [2026-09-16] `test_class_format.rs` 변이 저항 감사 — ★**«고칠 것이 없다»는 «한 자리에서» 거짓이었다**(위 `-fix` 가 정정) (rustjava-adopt-cp-tag-passthrough-detectable-p0) +- 무엇을: 이 스위트의 단언 **11개**가 「자기가 이름 붙인 가지」의 개악에 **실제로 죽는지** 쟀다. ★**코드 변경 0**(감사 회차). +- 왜: 채택 제안 `2026-09-16-cp-tag-passthrough-detectable#p0` — 「통과하지만 아무것도 재지 않는 단언이 더 있는지 보라」. +- 사용자 영향: 없다(측정). 바뀐 것은 ★**「없다」를 «추측»에서 «실측»으로 옮긴 것**이다. +- ★★**결과: 11/11 이 «적어도 하나»의 개악에 죽는다** — 개악 **10종**(매직 검사 해제 · 태그 pass-through 수용 · + `ldc2_w` 폭 제한 제거 · 버전 하한 `true` · BSM 인덱스 `true` · indy 를 「파손」으로 · 신원 4축 제거 · + ldc 미지원 arm 을 「파손」으로 · 파스 실패를 「미지원」으로 · not-found 를 「파손」으로). 표는 worklog 에. +- ★★**중간 함정을 기록한다** — 1차 목록(M1~M7)에서는 **3개가 살아남았다.** 그때 「약한 단언 3건」이라 적었으면 **거짓**이다: + 실제로는 ★**내 개악 목록에 그 가지들이 빠져 있었다**(ldc 미지원 arm · 파스 실패 매핑 · not-found 경로). + M8·M9·M10 을 더하자 **전부 죽었다**. ⇒ ★**「죽지 않았다」는 «테스트가 약하다»와 «내가 그 가지를 안 건드렸다»를 구별하지 못한다.** +- ★★**ⓒ 제안의 전제는 쓰인 시점에 이미 거짓이었다**: 원문은 「several of which **also rely on corrupting a + referenced slot of `Hello.class`**」라고 했으나, 제안이 실린 커밋(`eb8b4eb` = PR #49)의 같은 파일에서 + `Hello.class` 파생은 **3곳**뿐이고 ★**«참조 슬롯»을 덮는 것은 «0»** 이다(절단 · 매직 바이트 · 무손상 들러리). + ⇒ 「several」은 ★**그 회차가 «방금 고친 그 하나»의 일반화**였다. ★그렇다고 감사가 헛되지 않다 — 「없다」를 + **재서** 아는 것과 **추측**하는 것은 다르다. +- ★**도구를 «남기지 않았다»**: 개악 하네스는 제품 **소스 문자열**을 매칭하므로 리팩터 뒤 ★**조용히 개악을 건너뛴다** — + ★**하네스 자체가 「통과하지만 아무것도 재지 않는」 산출물**이 된다(이 제안이 사냥하는 그 형태). ⇒ **표를 문서로** 남겼다. + ※`scripts/survey-ldc-constant-tags.py` 를 남긴 판단과 다른 이유: 그쪽은 **클래스 파일(데이터)** 을 읽어 낡지 않는다. +- 검증: 개악 10종 적용·복원 후 워킹트리 청결 · `cargo test --all` **570 / 0 failed / 1 ignored**(27 스위트 전건 합산) · DoD 7명령 rc=0. +- ★**감사의 경계**: 답한 질문은 「각 테스트가 «자기 가지»의 개악에 죽는가」이지 「어떤 구멍도 없다」가 아니다 · + **픽스처의 «유일 결함성»은 별도 축**(후속 추천) · 진행 중 PR #53·#54 의 새 테스트 2개는 범위 밖(각자 라운드에서 개악 대조 완료). +- 후속 추천: **픽스처**의 유일 결함성을 같은 방식으로 감사(M) — 상세 = `docs/worklog/2026-09-16-class-format-mutation-audit.md`. ## [2026-09-16] 부트스트랩 메서드의 «정적 인자» 인덱스를 경계 검사한다 (rustjava-adopt-bound-bootstrap-method-attr-index-p1) - 무엇을: JVMS 4.7.23 의 `bootstrap_arguments` 는 상수 풀 인덱스인데 ★**아무도 그것이 실재하는지 보지 않았다.** 이제 풀에 «없는» 인덱스를 가리키면 **거부**한다. diff --git a/STATE.md b/STATE.md index 3b4a97fe..dbe798e2 100644 --- a/STATE.md +++ b/STATE.md @@ -4,6 +4,57 @@ (없음 — 2026-09-16 실측: 착수 시 진행 티켓 0 · 열린 PR 0. ※「열린 PR 0」은 ★**이 회차 PR 착지 시점 기준**이다 — 회신 시점에는 그 PR 자신이 열려 있다) ## 완료 +- [rustjava-adopt-cp-tag-passthrough-detectable-p0-fix] ★★**신원 4축을 «각각» 관측 가능하게 했다 — 감사의 「고칠 것이 없다」를 정정한다.** + 게이트② **request-changes** 승계(PR #55 · 핀 `ab13a3c7`). ★**제품 코드 무접촉** — 없던 것은 **픽스처**다. + ★★**무엇이 틀렸나**: 직전 감사의 **M7**(「신원 4축 검사 제거」)은 네 비교를 ★**한꺼번에** 지운다 ⇒ 그 red 가 증명하는 것은 + ★**「4축 중 «적어도 하나»가 관측된다」**뿐인데, 회신은 그것을 **「이 가지는 덮여 있다」**로 읽었다. + 검수자가 **축을 하나씩** 지워 재니 ★**kind·name·descriptor 는 «전 스위트 green 인 채로» 살아남았다**(class 축만 죽었다). + ★**근인은 픽스처의 수**다 — 근접 실패가 `NotStringConcatFactory`(class 축) **하나뿐**이라 나머지 세 비교는 **어떤 파일도 관측 못 했다**. + ★생성기 docstring 이 이미 그 문장을 적어 뒀는데(「Without such a fixture the identity check is not observable」) + **한 축에만 이행**돼 있었고, 감사는 그것을 「저항한다」로 덮었다. + ★★**감사 방법론에 «역»을 남겼다 — 이것이 이 회차의 진짜 산출물이다**: + 원 회신의 「죽지 **않았다** ≠ 테스트가 약하다」에 더해 ⇒ ★**「죽**었다** ≠ 그 가지가 «전부» 덮였다」.** + ★**다축 술어(`A || B || C || D`)를 통째로 지우는 «굵은» 개악은 «가장 잘 덮인 축»이 red 를 내고 나머지 축에 대해선 아무 말도 하지 않는다** + ⇒ ★**축이 여럿인 검사는 «축 하나씩» 찔러라.** + ★**만든 것**: `make_indy_fixtures.py` 에 `bootstrap_kind`(기본 6 = 종전 하드코딩값 ⇒ **기존 산출물 불변**) + 픽스처 3개 — + `NotMakeConcatWithConstants`(name · `makeConcat` 은 **실재하는** StringConcatFactory 부트스트랩) · + `NotFactoryDescriptor`(descriptor · 끝의 varargs 만 제거 — **여전히 적법한 서술자**) · + `NotInvokeStaticFactory`(kind **7 = REF_invokeSpecial** — JVMS 4.4.8 상 Methodref 와 **적법**). + ★**셋 다 «적법한 클래스 파일»이라 신원 검사까지 도달한다**(실측: 넷 다 `UnsupportedOperationException: invokedynamic` · + `ClassFormatError` 아님) — 상류가 먼저 거부하면 그 픽스처는 «다른 이유»로 통과하는 것이고 그것이 이 리니지가 고치려는 형태다. + ★**전/후**: kind·name·descriptor 단독 삭제 **SURVIVED**(`--all` 570/0/1 green) → ★**KILLED**(신규 테스트) · + class 축 **여전히 KILLED**(이제 **2개**를 죽인다 ⇒ 옛 커버리지 유지) · 회귀 표본 2종(BSM off-by-one · `Ldc2W` arm 제거) **여전히 KILLED**. + ★`test_class_format` **11 → 12** · `cargo test --all --no-fail-fast` **570 → 571 / 0 failed / 1 ignored** · 픽스처 재생성 **멱등** · DoD 7줄 rc=0. + ★★**하네스가 실제로 워킹트리를 오염시켰다** — 러너 시간 상한 **SIGKILL** 로 `finally` 복원이 안 돌아 name 축이 `false` 로 남았고, + 그 상태의 측정 2건이 **오염**됐다. ★**계약 ⒡ 의 「치환 1건 단언」이 다음 축에서 «앵커 0건»으로 즉시 잡았다** ⇒ 복원 후 **4축 전부 재측**. + ⇒ ★**이 사건이 「소스를 치환하는 하네스를 커밋하지 않는다」는 직전 회차 결정의 «실증»이다**(죽는 순간 트리를 오염시킨다). + ★★**게이트③ 착지 — PR #55 · `--merge`**(등재 repo). 게이트② **approve**(★이 PR 은 감사 1회차가 **request-changes** 를 받고 + 이 `-fix` 회차가 승계해 통과한 것이다) · 핀 `30a31bda` **불이동**(착수 실측 20:30:58Z · 워밍 후 재조회). + ★**충돌은 원장 2파일뿐**(측정 20:31:08Z) — 형제 **#53·#54** 착지분과 겹쳤고 `tests/test_class_format.rs` 는 **자동 병합**됐다. + 해소는 전건 보존·합집합·**시간순**: 이쪽 두 항목(`30a31bd` 04:04 · `ab13a3c` 02:28)이 main 쪽 둘(01:49 · 01:16)보다 **뒤**라 위에 얹었다. + ★줄 소실 **0** · 부활·조작 **0**. + ★★**계약 12 에서 «ours 축 불일치»가 떴고 뭉개지 않았다** — 실체는 ★**diff 정렬 artifact**(같은 4줄 `);`·`}`·`}`·빈 줄이 + 다른 hunk 에 귀속)였고, ★**정렬 후 다중집합이 «완전히 동일»**함을 보여 양측 델타가 둘 다 살아 있음을 확인했다. + ⇒ ★**「hunk 문자열이 다르다」를 곧바로 「델타가 빠졌다」로 읽지 마라** — 이 저장소에서 두 번째로 만난 형태다. +- [rustjava-adopt-cp-tag-passthrough-detectable-p0] ★★**변이 저항 감사 — ★그 「고칠 것이 없다」는 «한 자리에서» 거짓이었다(위 `-fix` 가 정정).** + 채택 제안 `2026-09-16-cp-tag-passthrough-detectable#p0`(worklog json `adoptedProposals` 기록). ★**코드 변경 «0»**. + ★★**결과: `tests/test_class_format.rs` 의 단언 11개 «전건»이 자기가 이름 붙인 가지의 개악에 죽는다** + (개악 10종 · 표는 worklog). ⇒ 「통과하지만 아무것도 재지 않는 단언」은 ★**더 없다**. + ★★**중간 함정을 남긴다 — 이 회차에서 가장 값진 기록이다**: 1차 개악 목록(M1~M7)에서 **3개가 살아남았고**, + 그때 「약한 단언 3건 발견」이라 적었으면 **거짓 보고**였다. 실제 원인은 ★**내 목록에 그 가지가 빠진 것**이었다 + (ldc 미지원 arm · 파스 실패 매핑 · not-found 경로) — M8·M9·M10 을 더하니 전부 죽었다. + ⇒ ★**「죽지 않았다」는 «테스트가 약하다»와 «내가 그 가지를 안 건드렸다»를 구별하지 못한다.** 변이 감사를 하는 다음 사람은 이것부터 의심하라. + ★★**ⓒ 제안 전제의 반증**: 「several ... corrupting a **referenced slot of `Hello.class`**」는 제안이 실린 커밋 + (`eb8b4eb` = PR #49) 시점에 **이미 거짓**이다 — 그 파일의 `Hello.class` 파생 3곳은 **절단**·**매직 바이트**·**무손상 들러리**이고 + ★**참조 슬롯을 덮는 것은 0**이다(`bytes[6..8]` 버전 개악은 **실물 픽스처**에 적용된다). ⇒ 「several」은 + ★**그 회차가 방금 고친 «그 하나»의 일반화**였다. ★**그래도 감사는 값했다** — 「없다」를 «재서» 아는 것과 «추측»하는 것은 다르다. + ★**도구를 일부러 남기지 않았다**: 개악 하네스는 제품 **소스 문자열**을 매칭해 치환하므로 리팩터 뒤 ★**조용히 그 개악을 건너뛴다** + ⇒ ★**하네스 자체가 「통과하지만 아무것도 재지 않는」 산출물**이 된다(이 제안이 사냥하는 바로 그 형태). 대신 **개악 표를 문서로** 남겼다. + ※데이터를 읽는 `scripts/survey-ldc-constant-tags.py` 를 남긴 판단과 갈리는 이유가 이것이다(그쪽은 소스가 아니라 클래스 파일을 읽어 낡지 않는다). + ★**경계**: 답한 질문은 「각 테스트가 «자기 가지» 개악에 죽는가」이지 「어떤 구멍도 없다」가 아니다 · + **픽스처의 유일 결함성**은 별도 축이라 안 봤다(후속 추천) · 진행 중 PR **#53**·**#54** 의 새 테스트 2개는 범위 밖이다 + (각 회차가 자기 라운드에서 양방향 개악 대조를 이미 붙였다). + ★`cargo test --all` **570 / 0 failed / 1 ignored**(27 스위트 전건 합산) · 개악 10종 적용·복원 후 워킹트리 **청결** · DoD **7줄 전건 rc=0**. - [rustjava-adopt-bound-bootstrap-method-attr-index-p1] ★★**부트스트랩 «정적 인자» 인덱스를 경계 검사한다 — 「감지되나 판정되지 않던」 자리를 닫았다.** 채택 제안 `2026-09-16-bound-bootstrap-method-attr-index#p1`(worklog json `adoptedProposals` 기록). ★**전/후**: `UnsupportedOperationException` → ★`ClassFormatError`. ★참조 JVM(OpenJDK 26.0.1) → diff --git a/docs/worklog/2026-09-16-class-format-mutation-audit.json b/docs/worklog/2026-09-16-class-format-mutation-audit.json new file mode 100644 index 00000000..54389000 --- /dev/null +++ b/docs/worklog/2026-09-16-class-format-mutation-audit.json @@ -0,0 +1,49 @@ +{ + "schema": "worklog/v1", + "date": "2026-09-16", + "taskId": "rustjava-adopt-cp-tag-passthrough-detectable-p0", + "summary": "Audited every assertion in tests/test_class_format.rs for mutation resistance. Corrected after gate-2 review: all 11 die to a mutation of the branch they name, but that was read as 'the branch is covered' — and for the four-axis factory identity check it was not. Three of the four axes survived single-axis deletion with the whole suite green; this round added the missing near-miss fixtures so each axis is observable.", + "changes": [ + "No code change. docs/worklog, REPORT.md and STATE.md record the mutation table and the result.", + "test-data/src/indy/make_indy_fixtures.py: near_miss_call_site takes bootstrap_kind (default 6, the previous hard-coded value, so existing output is byte-identical); three fixtures added, one per remaining identity axis", + "test-data/indy/NotMakeConcatWithConstants.class, NotFactoryDescriptor.class, NotInvokeStaticFactory.class: near misses differing in method name, descriptor and reference kind respectively", + "tests/test_class_format.rs: test_each_axis_of_the_factory_identity_is_observable", + "docs/worklog/2026-09-16-class-format-mutation-audit.md: §정정 section — what the audit got wrong, and the methodology line it was missing" + ], + "verification": [ + "10 mutations applied one at a time to product code and reverted, each followed by cargo test --test test_class_format; the table of which tests died is in the .md", + "all 11 tests are killed by at least one mutation: M1 bad_magic, M2 unsupported_constant_pool_tag, M3/M4 ldc_of_an_illegal_constant, M4 constant_tag_below_its_minimum, M5 dynamic_constant_naming_a_missing_bootstrap_method, M6 three unsupported-not-malformed tests, M7 string_concat linking, M8 ldc_of_method_handle_family, M9 truncated plus five others, M10 missing_class", + "trap recorded: the first pass (M1-M7) left three tests alive, and calling that 'three weak assertions' would have been wrong — the branches those tests name were missing from my mutation list, not from the tests. Adding M8/M9/M10 killed all three", + "premise re-measured at the commit that wrote the proposal (eb8b4eb): of three Hello.class-derived fixtures, zero corrupt a referenced constant pool slot — truncation, a magic byte, and an untouched copy used as a bystander", + "working tree restored after every mutation; git status clean apart from the untracked .serena/ directory", + "full suite: 570 passed / 0 failed / 1 ignored, summed across all 27 result lines", + "DoD 7 commands all rc=0", + "single-axis mutations of the factory identity, before this round: kind, name and descriptor each SURVIVED with cargo test --all at 570 passed / 0 failed; owning class was KILLED", + "after: all three are KILLED by test_each_axis_of_the_factory_identity_is_observable, and the kind mutation also makes cargo test --all non-green", + "owning class still KILLED and now kills two tests, so the new fixtures did not displace the old coverage", + "regression sample of two previously-dead mutations: BSM bound off-by-one (< to <=) KILLED, dropping the Ldc2W arm KILLED", + "baseline: test_class_format 11 -> 12 passed; cargo test --all --no-fail-fast 570 -> 571 passed / 0 failed / 1 ignored", + "fixtures come from the generator: rerunning make_indy_fixtures.py reproduces them and leaves NotStringConcatFactory byte-identical", + "all four new/old near misses are refused with UnsupportedOperationException: invokedynamic and not ClassFormatError, i.e. each is a valid file that reaches the identity check", + "the first harness run was SIGKILLed by a runner timeout before its restore, leaving one axis mutated; the replacement-count assertion caught it on the next axis, and every figure above was re-measured on a clean tree" + ], + "issues": [ + "The audit's original conclusion was wrong at one place: 'each test dies to a mutation of the branch it names' does not imply 'the branch is fully covered'. A coarse mutation of a multi-axis predicate only proves one axis is observed.", + "Fixture single-defectness is still a separate axis and was not re-audited here.", + "Still no harness committed — and this round produced the evidence for that: a source-rewriting harness leaves the tree mutated when it is killed." + ], + "adoptedProposals": [ + "2026-09-16-cp-tag-passthrough-detectable#p0" + ], + "proposals": [ + { + "title": "Audit the fixtures for single-defectness, not just the assertions", + "plainSummary": "We checked that each test fails when the thing it tests is broken. We did not check that each test file is broken in exactly one way.", + "userBenefit": "A test whose fixture is wrong in two ways keeps passing after one of them is fixed, and nobody notices the other stopped being covered.", + "why": "The round that produced this proposal fixed a test whose fixture was corrupt along several paths at once, and built replacements that carry exactly one defect. That discipline has been applied per round since, but never measured across the existing fixtures: for each fixture, does it still fail for its stated reason once every other defect is repaired?", + "tradeoff": "It needs a per-fixture repair to be meaningful — 'remove the named defect and check the file now loads' — which is close to rebuilding each fixture. For the generated ones the generator can do it cheaply; for the byte-mutated ones there is nothing to generate from, so the work is manual and the payoff may be a row of 'already fine'.", + "effort": "M", + "target": "test-data/src/, tests/test_class_format.rs" + } + ] +} diff --git a/docs/worklog/2026-09-16-class-format-mutation-audit.md b/docs/worklog/2026-09-16-class-format-mutation-audit.md new file mode 100644 index 00000000..7d028a37 --- /dev/null +++ b/docs/worklog/2026-09-16-class-format-mutation-audit.md @@ -0,0 +1,165 @@ +# 2026-09-16 — `tests/test_class_format.rs` 변이 저항 감사 + +티켓 `rustjava-adopt-cp-tag-passthrough-detectable-p0` — 채택 제안 `2026-09-16-cp-tag-passthrough-detectable#p0`. + +★★**[정정 2026-09-17 · 게이트② request-changes 승계 `-fix`] 아래 「결론: 고칠 것이 없다」는 «한 자리에서 거짓»이었다.** +검수자가 **더 좁은** 개악으로 찔러 ★**`string_concat.rs` 신원 4축 중 «3축»(kind·name·descriptor)이 «전 스위트 green 인 채로» 살아남는 것**을 찾았다. +⇒ 이 회차가 그 3축의 근접 실패 픽스처를 만들어 닫았다. **전말은 맨 아래 「§정정」 절에 있다 — 그 절을 읽고 이 문단을 읽어라.** + +★**원 결론(그대로 둔다)**: 11개 테스트 **전건**이 «자기가 이름 붙인 가지»의 개악에 **죽는다**. +★**그리고 제안의 전제는 «쓰인 시점에 이미 거짓»이었다**(아래 ⓒ). ⇒ **코드 변경 0** · 산출물은 **이 기록**이다. +★**그 두 문장은 여전히 참이다 — 틀린 것은 «그래서 고칠 것이 없다»는 «추론»이다**(아래 §정정). + +## 감사 방법 — 제안이 말한 그 절차 그대로 + +제안: 「This round found one such test by **mutating the branch it named and observing green**. +The same procedure applies mechanically to the other assertions.」 +⇒ 각 테스트가 **이름 붙인 제품 가지**를 하나씩 개악하고, `cargo test --test test_class_format` 를 돌려 +★**어떤 테스트가 죽는지**를 기록했다. **죽지 않는 테스트 = 다른 이유로 통과하는 테스트**다. + +## 개악 표 (10종 · 전부 제품 코드 · 각 1회 적용 후 복원) + +| id | 파일 | 개악 | 죽은 테스트 | +|---|---|---|---| +| **M1** | `classfile/src/class.rs` | `if magic != 0xCAFEBABE {` → `if false {` | `bad_magic` | +| **M2** | `classfile/src/constant_pool.rs` | 태그 switch `_ => Err(...)` → `_ => Ok((data, Self::Integer(0)))` | `unsupported_constant_pool_tag` | +| **M3** | `classfile/src/opcode.rs` | `ldc2_w` 폭 제한 제거(무엇이든 수용) | `ldc_of_an_illegal_constant` | +| **M4** | `classfile/src/validation.rs` | `class.major_version >= minimum_major_version` → `true` | `constant_tag_below_its_minimum_class_file_version` · `ldc_of_an_illegal_constant` | +| **M5** | `classfile/src/validation.rs` | `bootstrap_method_count.is_some_and(...)` → `true` | `dynamic_constant_naming_a_missing_bootstrap_method` | +| **M6** | `jvm-bytecode/src/verifier.rs` | indy 를 `UnsupportedFeature` → `InvalidClassFile` | `every_method_handle_family_tag` · `lambda_class` · `only_the_string_concat_bootstrap_is_linked` | +| **M7** | `jvm-bytecode/src/string_concat.rs` | `StringConcatFactory` 신원 4축 검사 제거 | `only_the_string_concat_bootstrap_is_linked` | +| **M8** | `jvm-bytecode/src/verifier.rs` | `ldc`-of-MethodHandle 을 `UnsupportedFeature` → `InvalidClassFile` | `ldc_of_method_handle_family` | +| **M9** | `jvm-bytecode/src/error.rs` | `InvalidFormat => InvalidClassFile` → `UnsupportedFeature("parse")` | **6개**: `truncated` · `bad_magic` · `unsupported_constant_pool_tag` · `ldc_of_an_illegal_constant` · `constant_tag_below_its_minimum…` · `dynamic_constant_naming…` | +| **M10** | `jvm/src/jvm.rs` | 클래스 부재를 `NoClassDefFoundError` → `ClassFormatError` | `missing_class_still_raises_no_class_def_found_error` | + +## 결과 — ★**11/11 이 «적어도 하나»의 개악에 죽는다** + +| 테스트 | 죽인 개악 | +|---|---| +| `truncated_class_raises_class_format_error` | M9 | +| `unsupported_constant_pool_tag_raises_class_format_error` | M2 · M9 | +| `only_the_string_concat_bootstrap_is_linked` | M6 · M7 | +| `bad_magic_raises_class_format_error` | M1 · M9 | +| `missing_class_still_raises_no_class_def_found_error` | M10 | +| `class_carrying_every_method_handle_family_tag_is_unsupported_not_malformed` | M6 | +| `ldc_of_method_handle_family_reports_unsupported_feature_not_malformed` | M8 | +| `ldc_of_an_illegal_constant_is_still_malformed` | M3 · M4 · M9 | +| `a_constant_tag_below_its_minimum_class_file_version_is_malformed` | M4 · M9 | +| `a_dynamic_constant_naming_a_missing_bootstrap_method_is_malformed` | M5 · M9 | +| `lambda_class_reports_unsupported_feature_not_malformed` | M6 | + +★**중간 함정 하나를 적어 둔다**: 1차 표(M1~M7)에서는 **3개가 살아남았다**. +그때 「약한 단언 3건 발견」이라고 적었으면 **거짓**이었다 — 실제로는 ★**내 개악 목록에 그 가지가 빠져 있었다** +(ldc 미지원 arm · 파스 실패 매핑 · not-found 경로). ⇒ M8·M9·M10 을 더하자 전부 죽었다. +★**「죽지 않았다」는 «테스트가 약하다»와 «내가 그 가지를 안 건드렸다»를 구별하지 못한다.** + +## ⓒ 제안의 전제 — ★**쓰인 시점에 이미 거짓이었다** + +원문 `why`: 「several of which **also rely on corrupting a referenced slot of `Hello.class`** and then +asserting only the flattened error kind」. + +실측 — 제안이 실린 커밋(`eb8b4eb` = PR #49) 시점의 같은 파일에서 `Hello.class` 파생 픽스처는 **3곳**뿐이고, +그중 ★**«참조되는 상수풀 슬롯»을 덮는 것은 «0»** 이다: +- `hello_class()[..60]` — **절단**(슬롯 덮어쓰기가 아니다) +- `bytes[0] = 0x00` — **매직 바이트**(헤더이지 풀 슬롯이 아니다) +- `fixture("Unrelated.class", &hello_class())` — 손상 **0**(부재 테스트의 들러리) +※`bytes[6..8]`(버전)은 **실물 픽스처**에 적용되지 `Hello.class` 에 적용되지 않는다. + +⇒ ★**「several」은 그 회차가 «방금 고친 그 하나»의 일반화였다.** 그 회차는 유일한 사례를 없애면서 +「나머지도 그럴 것」이라고 적었고, **그 나머지는 애초에 없었다.** +★**그렇다고 감사가 헛되지 않다** — 「없다」를 «재서» 아는 것과 «추측»하는 것은 다르고, 그 차이가 이 회차의 산출물이다. + +## ⓑ 이미 같은 축이 있는가 — 없다 + +이 저장소에 변이 테스트 도구(`cargo-mutants` 등) 설정 **0건**(실측). 변이 저항은 **회차마다 손으로** 확인해 왔다 +(이 리니지의 done 회신들이 그 기록이다). + +## ★도구를 «남기지 않았다» — 그 이유가 이 제안의 주제와 같다 + +개악 하네스는 **제품 «소스 문자열»을 매칭해 치환**한다. 그 문자열은 리팩터마다 바뀌고, 안 맞으면 하네스는 +★**조용히 「그 개악을 건너뛴다」** — 즉 ★**하네스 자체가 «통과하지만 아무것도 재지 않는» 산출물**이 된다. +그것은 이 제안이 사냥하는 바로 그 형태다. ⇒ **표를 문서로 남기고 스크립트는 남기지 않는다**(위 표에 파일·치환이 +그대로 있어 손으로 재현된다). ※데이터를 읽는 도구(`scripts/survey-ldc-constant-tags.py`)와 다른 판단인 이유가 이것이다 — +그쪽은 소스가 아니라 **클래스 파일**을 읽어 낡지 않는다. + +## 감사의 «경계» — 무엇을 재지 않았나 + +- ★이 감사가 답한 질문은 **「각 테스트가 «자기가 이름 붙인 가지»의 개악에 죽는가」** 다. + ★**「어떤 개악에도 죽지 않는 구멍이 없다」는 아니다** — 개악 목록은 내가 골랐고, 위 함정이 보여 주듯 목록은 늘 불완전할 수 있다. +- ★**픽스처의 «유일 결함성»은 별도 축**이다(#49 가 세운 그 규율). 이번엔 단언 축만 봤다. +- ★진행 중인 PR **#53**(중복 `BootstrapMethods`)·**#54**(BSM 정적 인자)의 새 테스트 2개는 **이 감사 범위 밖**이다 — + 각 회차가 **자기 라운드에서 이미 양방향 개악 대조를 붙였다**(그 done 회신에 표가 있다). + + +--- + +# §정정 (2026-09-17 · `rustjava-adopt-cp-tag-passthrough-detectable-p0-fix`) + +## 무엇이 틀렸나 — 「11/11 죽었다」는 맞고, 「그래서 덮여 있다」가 틀렸다 + +위 표의 **M7**(「신원 4축 검사 제거」)은 `string_concat.rs` 의 네 비교를 ★**한꺼번에** 지운다. +그 개악이 죽었다는 사실이 증명하는 것은 ★**「네 축 중 «적어도 하나»가 관측된다」**뿐인데, +회신은 그것을 ★**「이 가지는 덮여 있다」**로 읽었다. + +검수자가 **축을 하나씩** 지워 재니: + +| 개악(축 하나만 `false` 로) | 이 회차 «전» | 이 회차 «후» | +|---|---|---| +| **R6** owning class | **KILLED** (`only_the_string_concat_bootstrap_is_linked`) | **KILLED** (2개 — 위 테스트 **+** 신규) | +| **R8** reference kind | ★**SURVIVED** — `--test` 11/0 · `--all` **570/0/1 green** | ★**KILLED** (`each_axis_of_the_factory_identity_is_observable`) | +| **R11** method name | ★**SURVIVED** — 동일 | ★**KILLED** (동일) | +| **R7** descriptor | ★**SURVIVED** — 동일 | ★**KILLED** (동일) | + +★**근인은 픽스처의 수**다: 근접 실패 픽스처가 `NotStringConcatFactory`(**owning class 축**) **하나뿐**이라 +나머지 세 비교는 **어떤 파일도 관측하지 못했다**. ★**생성기 docstring 이 이미 그 문장을 적어 뒀다** — +「Without such a fixture the identity check is not observable … would leave every test green while +silently linking anything」 — ⇒ 그 문장은 **한 축에만** 이행돼 있었고, 이 감사는 그 사실을 「저항한다」로 덮었다. + +## ★★감사 방법론에 남기는 한 줄 — 위 교훈의 «역» + +원 회신은 ★**「죽지 않았다 ≠ 테스트가 약하다」**(내 개악 목록이 불완전할 수 있다)를 값지게 적었다. +★**그 «역»이 빠져 있었고, 그 빈칸이 이 오류를 만들었다**: + +> ★★**「죽었다 ≠ 그 가지가 «전부» 덮였다».** +> ★**다축 술어(`A || B || C || D`)를 «통째로» 지우는 굵은 개악은 «어느 한 축이 덮였다»만 증명한다.** +> ⇒ ★**축이 여럿인 검사는 «축 하나씩» 찔러라.** 굵은 개악의 red 는 «가장 잘 덮인 축»이 낸 것이고, +> 나머지 축에 대해서는 ★**아무 말도 하지 않는다.** + +## 이 회차가 만든 것 — 픽스처 3개(제품 코드 무접촉) + +`make_indy_fixtures.py` 에 `bootstrap_kind` 파라미터를 더하고(기존 기본값 6 = REF_invokeStatic) 항목 3개를 추가했다: +`NotMakeConcatWithConstants`(name 축 · **`makeConcat`** 은 실재하는 StringConcatFactory 부트스트랩이라 «있을 법한» 근접 실패다) · +`NotFactoryDescriptor`(descriptor 축 · 끝의 `[Ljava/lang/Object;` 만 제거 — **여전히 적법한 메서드 서술자**) · +`NotInvokeStaticFactory`(kind 축 · **7 = REF_invokeSpecial** — JVMS 4.4.8 상 Methodref 와 짝지어도 **적법**). +★**셋 다 «적법한 클래스 파일»이라 신원 검사까지 도달한다** — 상류가 먼저 거부하면 그 픽스처는 «다른 이유»로 통과하는 것이고, +그것이 바로 이 리니지가 고치려는 형태다(실측: 넷 다 `UnsupportedOperationException: invokedynamic` 로 거부 · `ClassFormatError` 아님). +★**제품 코드는 고치지 않았다** — 제품은 이미 4축을 «본다». 없던 것은 **그것을 관측 가능하게 하는 픽스처**다. + +## ★하네스가 워킹트리를 오염시켰다 — 실제로 일어났고, 앵커 단언이 잡았다 + +이 회차의 1차 개악 하네스가 러너 시간 상한에 **SIGKILL** 돼 `finally` 복원이 **돌지 않았고**, +`string_concat.rs` 의 **name 축이 `false` 로 남았다**. 그 상태에서 돌린 R6·R8 측정은 ★**2축이 꺼진 오염된 값**이었다. +★**그것을 잡은 것이 계약 ⒡ 의 「치환 1건 단언」이다** — 다음 축을 치환하려다 `앵커 0건` 으로 즉시 죽었다. +⇒ 복원 후 **4축을 전부 재측**했고 위 표는 **청결한 트리에서의 값**이다. +★**이 사건은 위 「도구를 남기지 않았다」 결정의 «실증»이기도 하다** — 제품 소스를 치환하는 하네스는 +**죽는 순간 트리를 오염시킨다**. 그래서 이번에도 **커밋하지 않았다**(스크래치에서만 썼다). + +## 검증 (이 회차) + +- ⒜ R8·R11·R7 **전건 KILLED**(각각 `each_axis_of_the_factory_identity_is_observable`) · + R8 은 `cargo test --all` 에서도 **green 이 아니다**(1 failed). +- ⒝ R6(class) **여전히 KILLED** — 게다가 **2개**를 죽인다(옛 커버리지가 밀려나지 않았다). +- ⒞ 회귀 표본 2종: **R3**(BSM 경계 off-by-one `<` → `<=`) → KILLED · **R9**(`Ldc2W` arm 제거) → KILLED. +- ⒟ 기준선: `cargo test --test test_class_format` **11 → 12 passed**(신규 `test_each_axis_of_the_factory_identity_is_observable`) · + `cargo test --all --no-fail-fast` **570 → 571 passed / 0 failed / 1 ignored**(27 스위트 전건 합산). +- ⒠ 픽스처는 **생성기 산출물**이다 — `python3 test-data/src/indy/make_indy_fixtures.py` 재실행으로 재생산되고, + ★**기존 `NotStringConcatFactory.class` 는 바이트 동일**(멱등). +- ⒡ **치환 1건 단언 하네스** 사용(위 오염 사건이 그 값어치의 증거다). + +## 잃는 것 (이 회차) + +- ★**픽스처 3개(각 약 0.4KB) + 테스트 1개**가 늘었다. `cargo test --all` **570 → 571**, + `test_class_format` 소요 **0.25s → 0.74s**(클래스 3개를 더 로드한다) · 전체 스위트 체감 변화는 **측정 오차 수준**이다. +- ★**생성기에 파라미터가 하나 늘었다**(`bootstrap_kind`) — 기본값이 종전 하드코딩 값(6)이라 **기존 산출물은 불변**이다. +- ★**안 만들면**: 생성기 docstring 이 예고한 그 상태 — ★**「모든 테스트가 green 인 채로 아무것이나 링크된다」**가 그대로 남는다. + (그 상태는 가정이 아니라 **이 회차 전까지의 사실**이었고, 위 표의 «전» 열이 그 증거다.) diff --git a/test-data/indy/NotFactoryDescriptor.class b/test-data/indy/NotFactoryDescriptor.class new file mode 100644 index 00000000..c32d45b7 Binary files /dev/null and b/test-data/indy/NotFactoryDescriptor.class differ diff --git a/test-data/indy/NotInvokeStaticFactory.class b/test-data/indy/NotInvokeStaticFactory.class new file mode 100644 index 00000000..f28bc585 Binary files /dev/null and b/test-data/indy/NotInvokeStaticFactory.class differ diff --git a/test-data/indy/NotMakeConcatWithConstants.class b/test-data/indy/NotMakeConcatWithConstants.class new file mode 100644 index 00000000..658db4bb Binary files /dev/null and b/test-data/indy/NotMakeConcatWithConstants.class differ diff --git a/test-data/src/indy/make_indy_fixtures.py b/test-data/src/indy/make_indy_fixtures.py index 381b970d..d8a414ef 100644 --- a/test-data/src/indy/make_indy_fixtures.py +++ b/test-data/src/indy/make_indy_fixtures.py @@ -30,6 +30,7 @@ u2 = lambda x: struct.pack(">H", x) u4 = lambda x: struct.pack(">I", x) +FACTORY_CLASS = "java/lang/invoke/StringConcatFactory" FACTORY_DESCRIPTOR = ( "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;" "Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;" @@ -64,17 +65,22 @@ def bytes(self): return u2(len(self.entries) + 1) + b"".join(self.entries) -def near_miss_call_site(name, bootstrap_class, bootstrap_name, bootstrap_descriptor): +def near_miss_call_site(name, bootstrap_class, bootstrap_name, bootstrap_descriptor, bootstrap_kind=6): """A class whose single `invokedynamic` names a bootstrap that is a near miss for the - string-concat factory: same shape, one axis different.""" + string-concat factory: same shape, one axis different. + + `bootstrap_kind` is the `reference_kind` of the CONSTANT_MethodHandle. It defaults to 6 + (REF_invokeStatic), what the real factory uses; 7 (REF_invokeSpecial) is the near miss for + that axis. Both pair with a Methodref under JVMS 4.4.8, so the file stays valid and the + identity check is the only thing that can refuse it — which is the point of these fixtures.""" cp = Pool() this_class = cp.klass(name) super_class = cp.klass("java/lang/Object") main_name, main_desc, code_name = cp.utf8("main"), cp.utf8("([Ljava/lang/String;)V"), cp.utf8("Code") bootstrap = cp.add( - u1(15) # kind 6 = REF_invokeStatic, same as the real factory - + u1(6) + u1(15) + + u1(bootstrap_kind) + u2(cp.methodref(cp.klass(bootstrap_class), cp.name_and_type(bootstrap_name, bootstrap_descriptor))) ) recipe = cp.string("linked-by-mistake") @@ -106,6 +112,37 @@ def near_miss_call_site(name, bootstrap_class, bootstrap_name, bootstrap_descrip "makeConcatWithConstants", FACTORY_DESCRIPTOR, ), + # One fixture per remaining axis. Without all four, deleting a single axis from the identity + # check leaves every test green — measured: with only the owning-class fixture present, the + # kind, name and descriptor axes could each be removed and `cargo test --all` stayed at + # 570 passed / 0 failed. A mutation that deletes all four at once dies on the class fixture + # alone, which is why the audit that ran it read "this branch is covered". + # + # Differs in the method name. `makeConcat` is a real StringConcatFactory bootstrap, so this is + # the near miss a compiler could actually hand us. + "NotMakeConcatWithConstants.class": ( + "NotMakeConcatWithConstants", + FACTORY_CLASS, + "makeConcat", + FACTORY_DESCRIPTOR, + ), + # Differs in the descriptor: the trailing `[Ljava/lang/Object;` (the constants varargs) is + # gone. Still a well-formed method descriptor, so nothing upstream rejects it. + "NotFactoryDescriptor.class": ( + "NotFactoryDescriptor", + FACTORY_CLASS, + "makeConcatWithConstants", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;" + "Ljava/lang/String;)Ljava/lang/invoke/CallSite;", + ), + # Differs in the reference kind: 7 (REF_invokeSpecial) instead of 6 (REF_invokeStatic). + "NotInvokeStaticFactory.class": ( + "NotInvokeStaticFactory", + FACTORY_CLASS, + "makeConcatWithConstants", + FACTORY_DESCRIPTOR, + 7, + ), } if __name__ == "__main__": diff --git a/tests/test_class_format.rs b/tests/test_class_format.rs index 334dd3a5..4551ac57 100644 --- a/tests/test_class_format.rs +++ b/tests/test_class_format.rs @@ -100,6 +100,38 @@ async fn test_only_the_string_concat_bootstrap_is_linked() { } } +// The identity check above is four comparisons, and the test above can only observe one of them. +// `NotStringConcatFactory` differs in the owning class, so deleting *that* comparison links it and +// the test fails — but deleting any of the other three changes nothing any fixture can see. Measured +// before these fixtures existed: with the kind, name or descriptor comparison removed one at a +// time, `cargo test --all` stayed at 570 passed / 0 failed. +// +// So each axis gets a fixture that differs in that axis alone. Every one of them is a valid class +// file that reaches the identity check — nothing upstream can reject them — which is what makes +// each comparison observable rather than merely present. +#[tokio::test] +async fn test_each_axis_of_the_factory_identity_is_observable() { + for (name, axis) in [ + ("NotStringConcatFactory", "owning class"), + ("NotMakeConcatWithConstants", "method name"), + ("NotFactoryDescriptor", "descriptor"), + ("NotInvokeStaticFactory", "reference kind"), + ] { + let path = PathBuf::from(format!("test-data/indy/{name}.class")); + + let err = run_class(&path, &[Path::new("./test-data/indy/")], &[]).await.unwrap_err().to_string(); + + assert!( + err.contains("java.lang.UnsupportedOperationException") && err.contains("invokedynamic"), + "{name}: a bootstrap differing in {axis} must not be linked, got: {err}" + ); + assert!( + !err.contains("ClassFormatError"), + "{name}: it has to reach the identity check, so it must be a readable file, got: {err}" + ); + } +} + #[tokio::test] async fn test_bad_magic_raises_class_format_error() { let mut bytes = hello_class();