diff --git a/REPORT.md b/REPORT.md index 36a8d3ff..70728be2 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,5 +1,38 @@ # REPORT +## [2026-09-11] null 가드 «감사» — 세고, 그중 데이터 전송 버퍼 18곳을 닫았다 (rustjava-null-guard-audit-remaining-runtime-entrypoints) +- 무엇을: 런타임 전체에서 `ClassInstanceRef` 인자를 받아 **곧바로 역참조하는** 진입점을 **셌고** + (★**트리 병기 · 재측 2026-09-12** — base `6da7d66f`: **N 1,175 · M 52 · K 38** → head: **M 34 · K 20** · **ΔK 18**), + 그중 **`java/io` 데이터 전송 버퍼 + `String.getChars` 18곳**에 선행 회차와 **같은 모양**의 진입부 `is_null()` 가드를 넣었다. + 회귀 잠금 = 픽스처 `test-data/NullBufferGuards`(**13케이스** · 가드 **12/18** 커버). 채택 제안 `2026-09-11-null-guard-string-init-and-arraycopy#p0`. +- 왜: 선행 회차가 닫은 것은 **손으로 열거한** 9곳이었고, ★그 열거 자신이 `arraycopy` 의 `dest` 를 빠뜨렸다. + ⇒ 이번엔 **열거가 아니라 술어로 셌다**. sink 는 `jvm.rs` 가 `&Box`/`impl AsClassInstance` 로 받는 **17개 메서드**다. +- 사용자 영향: 게스트가 스트림·Writer·`String.getChars` 에 **null 버퍼**를 넘겨도 에뮬레이터가 죽지 않고 `NullPointerException` 이 난다. + 정상 입력 동작 **불변**(가드 앞에서 패닉하던 입력만 예외로 바뀐다) · 예외 종류·메시지 형태는 선행 회차와 동일. +- 검증: ★★**커버리지를 «추론»하지 않고 «측정»했다 — 가드 18곳을 «하나씩» 빼고 각각 빌드·실행**(2×18 회차). + ⇒ **12 곳은 red(덮인다) · 6 곳은 green(★안 덮인다 — 전부 파일 핸들 필요분)**. 그 밖에 `getChars` **가드 위치** 원복 개악도 red. + DoD 7종 rc=0 · `cargo test --all` **554/0/1**(★전체 실행 · 계수 불변이 맞다). +- ★★**계측기를 먼저 검증했고, 초판이 틀렸다**: 선행 9곳을 정답지로 대니 **3/9** 만 잡혔다(근인 = `"\n"+src` 에 정규식을 돌리고 인덱스는 `src` 에 쓴 **off-by-one**). + 고쳐서 **8/9**. 남은 1건은 helper **안**에서 deref 하므로 ★**이 계측은 절차간을 못 보고, 따라서 K 는 «하한»이다**(검수자의 「899/560 상한」과 방향이 반대다). +- ★★**[게이트② 정정 3건] 초판이 낸 수 중 «둘»이 틀렸고, 둘 다 «수를 섞은» 형태다.** + ⑴**K 46** 은 base 가 아니라 **`eaad8e9c`**(두 회차 전) 트리의 수였다 — 라벨만 `6da7d66f` 였고, `46 − 18 = 28 ≠ 20` 이 그 증상이었다. + ⇒ ★**base·head 를 나란히 적는 형태로 바꿨다.** ⑵**픽스처 12/18** 도 그 시점엔 **10/18** 이었고, + ★**내가 적은 근인이 «뒤집혀» 있었다** — `InputStreamReader` 가 오버라이드해서가 «아니라» **오버라이드가 없어서** `Reader::read([C)` 로 해소된다 + (등재 서술자로 확인: `InputStreamReader` 는 `([CII)I` 만, `Reader` 가 `([C)I` 를 등재한다). + ⇒ 그때 더한 중첩 클래스 2케이스는 **이미 덮인 가드를 또 덮었다** ⇒ **지웠고**, 대신 **3인자 호출**로 그 두 가드를 실제로 덮었다. + ⑶`String.getChars` 가드가 **JDK 의 예외 선후를 뒤집고 있었다** ⇒ **범위 검사 뒤로 옮기고** 선후 잠금 케이스를 넣었다. +- ★**감사 스크립트를 커밋했다**(`scripts/audit-null-guards.py` · ★CI 미배선 = **잠금이 아니라 감사**) — 초판의 「배선 없는 검사기는 낡는다」를 뒤집는다. + ★**근거는 이 회차 자신이다**: 작성자가 자기 수를 교차검증할 수단이 없어 ⑴이 났다. +- ★★**[게이트② R2] 그런데 그 커밋된 스크립트가 M 을 «2 낮게» 셌다** — `fn` 정규식이 **제네릭·`pub(crate)` 선언 34개를 못 봤다**. + ★고쳤고 **세 트리 × 세 독립 구현이 전건 일치**한다(N **1,175** / M **60·52·34** / K **46·38·20**). + ★★**「내 수가 맞고 검수 구현이 세부에서 다르다」던 전 회차 문안을 «철회»한다 — 틀린 것은 내 쪽이었다.** + ★★**K 가 무사했던 것은 «설계가 아니라 운»이다** — 눈먼 fn 2건이 우연히 `as_proto` 미등재였을 뿐이고, + 등재된 제네릭 진입점이 **하나만** 있었어도 K 가 조용히 낮아져 ★**이 감사가 「닫혔다」고 답했을 것**이다. ⇒ 그 눈먼 구간을 docstring 에 박았다. +- ★**잔여 20건을 «일괄 가드»로 닫지 마라** — `URL(context, spec, handler)` 는 JDK 규격상 **null handler 가 합법**이다. + ⇒ 후속의 본체는 「가드를 넣는 것」이 아니라 **「null 이 합법인지 규격으로 가르는 것」**이다. +- 후속 추천: `rustjava-null-guard-spec-triage-for-constructor-and-collection-params`(P3) — 잔여 20건을 **규격 기준으로 삼분** + (NPE 의무 / null 합법 / 도달 불가)하고 첫 갈래만 닫는다. + ## [2026-09-11] null 인자가 호스트를 죽이던 9경로에 가드 (rustjava-null-guard-string-init-and-arraycopy-p0) - 무엇을: `String.` 7개 오버로드와 `System.arraycopy`(`src`·`dest`)에 진입부 `is_null()` 가드를 넣어, null 을 넘겼을 때 **Rust 패닉(= 호스트 프로세스 abort)** 대신 **`NullPointerException`** 이 나게 했다. diff --git a/STATE.md b/STATE.md index 4a9beeae..f2e56988 100644 --- a/STATE.md +++ b/STATE.md @@ -4,6 +4,53 @@ (없음 — 2026-09-11 실측: 진행 티켓 0 · 열린 PR 0. ※「열린 PR 0」은 ★**이 PR(#35) 착지 시점 기준**이다 — 회신 시점에는 #35 자신이 열려 있다) ## 완료 +- [rustjava-null-guard-audit-remaining-runtime-entrypoints] ★★**런타임 전체를 «세고»(감사) 그중 «데이터 전송 버퍼» 18곳에 가드를 넣었다.** + 채택 제안 `2026-09-11-null-guard-string-init-and-arraycopy#p0`. 픽스처 `test-data/NullBufferGuards` **13케이스**(가드 **12/18** 커버 — ★**18곳 전건 단일 가드 개악으로 «측정»**). + ★★**감사 표 — 재측 2026-09-12 · ★트리 병기**(정본 = `scripts/audit-null-guards.py`): + **base `6da7d66f`** = **N 1,175 · M 52 · K 38** → **head** = **N 1,175 · M 34 · K 20** ⇒ **ΔM = ΔK = 18**(자기정합). + ★★**[게이트② R2 정정] 커밋한 스크립트가 M 을 «2 낮게» 셌다**(50/32 로 신고) — `fn` 정규식이 이름 뒤 `(` 를 즉시 요구하고 + 접두를 `pub `/`async ` 로만 받아 ★**제네릭 fn·`pub(crate)`/`pub(super)` 선언 34개가 통째로 안 보였다**. + ★고친 뒤 **세 트리 × 세 독립 구현이 전건 일치**한다(아래). + ★★**그리고 K 가 무사했던 것은 «설계가 아니라 운»이다** — 눈먼 fn 2건이 **우연히** `as_proto` 미등재였을 뿐이고, + 제네릭·`pub(crate)` 진입점이 **하나라도** 등재됐으면 K 가 **조용히** 낮아져 ★**이 감사가 「닫혔다」고 답했을 것**이다. + ⇒ 그 «눈먼 구간»을 스크립트 docstring 에 박았다. + ★★**[게이트② F1 정정] 초판이 적은 「M 58 · K 46」은 «다른 트리»의 수다** — **`eaad8e9c`**(= 선행 9곳 회차 «이전» main)에서 재고 + 라벨만 `6da7d66f` 로 달았다. ⇒ ★**`46 − 18 = 28 ≠ 20`** 이라는 산술 모순이 그 혼입의 증상이었고, 검수자가 그것으로 잡았다. + ★**이 저장소가 반복해 규탄한 「base 를 병기하지 않으면 수가 섞인다」의 교과서적 재현**이다 — ★그래서 이제 **두 트리를 나란히** 적는다. + ★**판정 술어를 적어 둔다**(「보아하니」 금지): sink = `jvm/src/jvm.rs` 가 **`&Box`/`impl AsClassInstance`** 로 받는 **17개 메서드** + (`array_length`·`load_array`·`store_array`·`get_field`·`put_field`·`invoke_virtual`·`invoke_special`·`monitor_*` …) — 그 인자에 `&p`/`&mut p` 로 넘기면 **Deref/DerefMut 가 강제**된다. + ★★**계측기를 «먼저 검증»했다 — 그리고 초판이 틀렸다.** 선행 회차가 닫은 **9곳**을 정답지로 삼아 가드 전/후 트리에 같은 자를 댔는데 + 초판은 **3/9** 만 되찾았다(근인 = 정규식을 `"\n"+src` 에 돌리고 인덱스를 `src` 에 적용한 **off-by-one** — 괄호 스캔이 한 칸 밀렸다). + 고친 뒤 **8/9**. ★**남은 1건(`init_with_string`)은 `Self::value_range` «안»에서 deref 한다** ⇒ ★★**이 계측은 절차간(interprocedural)을 못 본다 — 그래서 K 는 «하한»이다.** + ★**검수자의 「899 중 560 · 상한값」과 방향이 반대다**: 그쪽은 fn 단위 크루드 스캔(상한), 이쪽은 sink 한정 + 절차내(하한). + ★★**세 번째 패닉 impl 을 찾았다** — `Deref`(:108)·`DerefMut`(:114) 외에 **`AsClassInstance::as_class_instance`**(`as_deref().unwrap()`)가 있다(monitor 계열이 그 경로다). + ★**범위를 «좁게» 골랐다 — 「전부 고쳐라」가 아니다**: `java/io` **데이터 전송**(read/write 버퍼) + `String.getChars` **18곳**. + ★★**생성자류를 «일부러» 뺐다 — 일괄 가드가 «틀리는» 자리가 실재한다**: `URL(context, spec, handler)` 는 JDK 규격상 **handler == null 이 «합법»**(기본 핸들러를 쓰라는 뜻)이다. + ⇒ ★**잔여 20건은 «가드를 넣을지»가 아니라 «null 이 합법인지»를 규격으로 먼저 가려야 한다** — 후속의 본체가 그것이다. + ★개악 대조 **양방향 · 4종**: ⑴18곳 전건 되돌림 → `test_class` **FAILED** ⑵**단일 가드**(`get_chars(dst)`)만 제거 → **FAILED** + ⑶`reader.rs read(buf)` 만 · ⑷`writer.rs write_chars(chars)` 만 제거 → **각각 FAILED** ↔ 복원 전건 **ok**. + ★★**[게이트② R1 정정 — 근인 서술이 «뒤집혀» 있었다]** 종전 문안은 「`InputStreamReader` 가 **오버라이드해서** base 에 닿지 못한다」였는데 + ★**정반대다 — 오버라이드가 «없어서» base 에 닿는다.** 실측: `InputStreamReader` 는 `read` 를 **`([CII)I` 로만** 등재하고 + `Reader` 가 **`([C)I`** 를 등재한다(`OutputStreamWriter`/`Writer` 도 같은 형상) ⇒ 1인자 호출은 **`Reader::read`** 로 해소된다. + ⇒ ★**그래서 그때 더한 중첩 클래스 2케이스는 «이미 덮인 가드»를 또 덮었고 커버리지는 10/18 그대로였다**(검수 지적이 옳다) — **그 2케이스를 «지웠다».** + ★**대신 «미등재 서술자»를 겨눴다** — `read(char[],int,int)`·`write(char[],int,int)` **3인자 호출**이 그 두 가드에 닿는 유일한 경로다. + ★★**판정 축은 «클래스 이름»이 아니라 «서술자 + 등재 위치»다**(검수가 준 그 축을 그대로 썼다). + ★★**그리고 이번엔 «추론»이 아니라 «18곳 전건 단일 가드 개악»으로 쟀다** ⇒ ★**12/18 · 미커버 6**(전부 파일 핸들 필요분 · 목록은 worklog). + ★★**[게이트② F4] `String.getChars` 가드를 «범위 검사 뒤»로 옮겼다 — «선택»이다.** + JDK 는 범위를 먼저 보므로 «잘못된 범위 + null dst» 면 **SIOOBE 가 이긴다**. 초판 위치는 그 선후를 **NPE 로 뒤집었다**(계약 4 「동작 의미를 바꾸지 마라」 위반). + ★픽스처에 **선후 잠금 케이스**를 넣었고, 가드를 옛 위치로 되돌리면 그 케이스가 **red** 다(개악 ⑸). + ★`cargo test --all` **554 / 0 / 1**(★**전체 실행** · 증감 0 이 맞다 — `test_class` 는 픽스처를 한 함수가 순회한다) · DoD 7종 rc=0. + ★★**게이트③ 착지 — PR #37 · `--merge`**(등재 repo · 스쿼시는 부모 2개를 1개로 접어 계보를 지운다). + 게이트② **3회차 만에 approve**(초판 → `-fix` → `-fix2`) · 핀 `77dec927` **불이동**(동봉 전 실측) · `ci-presence` **rc=0 CI_GREEN** · + 자식 PR **0건** · ★**배포 워크플로 0개 ⇒ 배포 0**. + ★★**반려 셋이 전부 «수»였다** — F1(두 트리 혼입 · K 46↔38) · R2(스크립트가 M 을 2 낮게) · R1(커버리지 근인 역전 · 10↔12). + ⇒ ★**이 리니지가 남긴 규율: 「감사 회차는 «계측기»부터 정답지로 검증하라」**(그 검증이 off-by-one 과 눈먼 구간을 각각 잡았다). + ★**검수자가 «독립 재구현 + 행 단위 대칭차 0» 로 세 트리를 재현**했고, `da3727c`(수정 전)에서 두 가드가 **green** 임을 대조군으로 확인했다. + ★**검수 minor 3건은 이 회차가 «고치지 않았다»**(계약 7 — 총괄 승계 판정): ⒜`.gitignore` 에 `__pycache__` 부재 + ⒝새 `PARAM` 앵커가 `Option>` 같은 **중첩 타입 파라미터를 세지 않는다** — ★**검수 실측 «현재 0건»**(기록해 둔다 · 다음 회차가 다시 재지 않도록) + ⒞감사 스크립트 CI 미배선(= 선언된 선택이지 결함 아님). + ★★**[게이트② F5] 감사 스크립트를 «커밋했다» — 초판의 「배선 없는 검사기는 낡는다」 판단을 뒤집는다.** + 근거: ★**작성자가 자기 수를 교차검증할 수단이 없어 F1 이 났다.** `scripts/audit-null-guards.py`(★CI 미배선 = **잠금이 아니라 감사**). - [rustjava-null-guard-string-init-and-arraycopy-p0] ★★**null 인자 8경로의 «호스트 abort» 를 `NullPointerException` 으로 바꿨다.** 채택 제안 `2026-09-11-s5-duplicate-issuance-stale-next-close#p0`. ★**가드 9곳**(`string.rs` 7 · `system.rs` 2) · 픽스처 `test-data/NullArgGuards` 9케이스. ★**제안은 「8경로」라 했는데 실측은 «9곳»이다** — `System.arraycopy` 가 `src`·`dest` **둘 다** 뚫려 있었고(제안·STATE ③-2 는 `src` 만 셌다) 그 둘은 다른 인자다. diff --git a/docs/worklog/2026-09-11-null-guard-audit-and-io-buffer-guards.json b/docs/worklog/2026-09-11-null-guard-audit-and-io-buffer-guards.json new file mode 100644 index 00000000..83d84fc9 --- /dev/null +++ b/docs/worklog/2026-09-11-null-guard-audit-and-io-buffer-guards.json @@ -0,0 +1,62 @@ +{ + "schema": 1, + "date": "2026-09-11", + "title": "null 가드 감사 — base 6da7d66f 에서 N 1175 / M 52 / K 38 을 세고 java/io 데이터 전송 버퍼 18곳을 닫았다", + "services": [ + "RustJava" + ], + "taskId": "rustjava-null-guard-audit-remaining-runtime-entrypoints", + "summary": "선행 회차는 손으로 «열거한» 9곳을 닫았고 그 열거 자신이 arraycopy 의 dest 를 빠뜨렸다. 그래서 이번엔 술어로 셌다. ★수는 «트리와 함께» 읽어라(재측 2026-09-12) — eaad8e9c: 1,175/60/46 · base 6da7d66f: 1,175/52/38 · head: 1,175/34/20 ⇒ ΔM = ΔK = 18 = 이 회차의 가드 수. sink 는 jvm/src/jvm.rs 가 &Box/impl AsClassInstance 로 받는 17개 메서드로 한정했다. ★[게이트² F1] 초판이 적은 K 46 은 eaad8e9c 트리의 수인데 라벨만 base 였다 — 46−18=28≠20 이 증상. ★[게이트² R2] 그 다음 회차가 커밋한 스크립트는 M 을 2 낮게 셌다 — fn 정규식이 이름 뒤 ( 를 즉시 요구하고 접두를 pub/async 로만 받아 제네릭 fn 과 pub(crate)/pub(super) 선언 34개를 통째로 못 봤다(놓친 2건 = arrays.rs sort_primitive(array) · timer_task_queue.rs add(task)). 고친 뒤 세 트리 × 세 독립 구현이 전건 일치한다. ★그리고 K 가 흔들리지 않은 것은 설계가 아니라 «운»이다 — 그 2건이 우연히 as_proto 미등재였을 뿐이고, 등재된 제네릭 진입점이 하나만 있었어도 K 가 조용히 낮아져 이 감사가 「닫혔다」고 답했을 것이다. ★[게이트² R1] 커버리지 근인 서술도 뒤집혀 있었다 — InputStreamReader 가 오버라이드해서가 아니라 «오버라이드가 없어서» 1인자 호출이 Reader::read([C) 로 해소된다(등재 서술자 실측). 그래서 전 회차가 더한 중첩 클래스 2케이스는 이미 덮인 가드를 또 덮었고 커버리지는 10/18 그대로였다 ⇒ 그 2케이스를 지우고 3인자 호출로 그 두 가드를 실제로 덮었다. ★이번엔 추론하지 않고 가드 18곳을 하나씩 빼고 각각 빌드·실행해 측정했다: 12/18 · 미커버 6(전부 파일 핸들 필요분). 닫은 범위는 java/io 데이터 전송 버퍼 + String.getChars 18곳이고, 잔여 20건은 일괄 가드가 틀리는 자리를 포함하므로(URL(context,spec,handler) 는 JDK 규격상 null handler 가 합법) 후속으로 남겼다.", + "changes": [ + "rustjava-runtime/src/classes/java/io/{buffered_output_stream,data_input_stream,data_output_stream,file_input_stream,file_output_stream,input_stream_reader,output_stream_writer,random_access_file,reader,writer}.rs — 진입부 is_null() 가드 17곳", + "rustjava-runtime/src/classes/java/lang/string.rs — get_chars(dst) 가드 1곳 · [F4] 위치를 «범위 검사 뒤»로(JDK 의 예외 선후 보존)", + "test-data/src/NullBufferGuards.java + .class + .txt — 13케이스(가드 12/18 커버) · ★[R1] 중첩 $BareReader/$BareWriter 와 그 2케이스 제거 · 3인자 호출 2케이스 추가 · 1인자 케이스 라벨 정정", + "scripts/audit-null-guards.py — 감사 스크립트(★CI 미배선 = 잠금이 아니라 감사) · ★[R2] fn 스캐너가 제네릭·모든 가시성을 받도록 수정 + 파라미터 깊이0 분할 + «눈먼 구간» docstring", + "STATE.md · REPORT.md — 감사 표(트리 병기)·술어·범위 근거·잔여 목록·정정 기록" + ], + "verification": [ + "정상 → green: cargo test --test test_class ok · cargo fmt --check rc=0 · clippy stable/beta/wasm32 rc=0 · cargo test --all 554 passed / 0 failed / 1 ignored(★전체 실행) · check-worklog-json.py rc=0 · check-dod-ci-parity.py rc=0", + "★커버리지 측정(추론 아님): 가드 18곳을 하나씩 제거하고 각각 빌드·실행 → 12곳 red(덮인다) · 6곳 green(file_input_stream · file_output_stream · random_access_file×4)", + "★3인자 케이스가 실제로 무는 것을 확인: 수정 전 input_stream_reader.read(buf)·output_stream_writer.write(chars) 는 단일 제거해도 green 이었고, 3인자 케이스 추가 후 둘 다 red 로 바뀌었다", + "개악 → red: 18곳 전건 되돌림 → FAILED @ class_instance.rs:108 · getChars 가드 위치 원복 → 선후 잠금 케이스가 FAILED", + "★감사 스크립트 3트리 재측(수정 후): eaad8e9c 1,175/60/46 · 6da7d66f 1,175/52/38 · head 1,175/34/20 — 검수 독립 구현과 전건 일치", + "픽스처 형식: javac --release 8 ⇒ major 52 · invokedynamic/CP 태그 15~18 0건" + ], + "issues": [ + "★[게이트③ 집행] PR #37 을 --merge 로 착지시켰다(upstream-sync 등재 repo — 스쿼시는 부모 2개를 1개로 접어 계보를 지운다). 게이트② 3회차 만에 approve(초판 → -fix → -fix2) · 핀 77dec927 불이동(원장 동봉 «전» 실측) · ci-presence rc=0 CI_GREEN · 자식 PR 0건 · 배포 워크플로 0개 ⇒ 배포 0.", + "★[게이트² R1] 커버리지 근인 서술이 뒤집혀 있었다 — 오버라이드가 «있어서»가 아니라 «없어서» base 로 해소된다. 판정 축은 클래스 이름이 아니라 «서술자 + 등재 위치»다. 전 회차가 더한 2케이스는 중복이었고 제거했다.", + "★[게이트² R2] 커밋한 감사 스크립트가 M 을 2 낮게 셌다(제네릭·pub(crate) 눈먼 구간 34선언). 고쳤고 세 구현이 일치한다. ★전 회차의 「내 수가 맞고 검수는 구현 세부 차」라는 취지의 문안을 철회한다 — 틀린 것은 내 쪽이었다.", + "★[게이트² R2] K 가 무사했던 것은 «운»이다 — 눈먼 fn 2건이 우연히 as_proto 미등재였다. 등재된 제네릭 진입점이 하나만 있었어도 K 가 조용히 낮아져 감사가 「닫혔다」고 답했을 것이고, 그것이 감사 도구의 «침묵 실패»다. 눈먼 구간을 docstring 에 박았다.", + "★[게이트² R3] 잔여 20건 분해가 오기였다(open_connection 을 java/net 으로, java/util/regex 3건을 java/util 에 합산). 도구 출력 기준 = java/io 4 · java/net 3 · java/util 6 · java/util/regex 3 · java/util/zip 3 · org/rustjava/net 1.", + "★[게이트² F4] String.getChars 가드가 JDK 의 예외 선후를 뒤집고 있었다 ⇒ 범위 검사 뒤로 옮기고 선후 잠금 케이스를 넣었다.", + "★픽스처 커버리지는 12/18 이고 미커버 6 은 전부 실파일 핸들이 필요하다 — 가드는 들어갔으나 테스트가 잠그지 않는다 ⇒ proposals[1].", + "★이 계측은 절차간 deref 와 섀도잉을 못 본다. 전자는 위음성(init_with_string), 후자는 위양성(url.rs 의 handler) ⇒ N·M·K 를 «하한 + 알려진 오탐 1건»으로 읽어라.", + "★부수 발견 — 패닉 impl 은 둘이 아니라 셋이다: Deref · DerefMut · AsClassInstance::as_class_instance. 셋째는 monitor_* 경로다.", + "★[검수 minor · 기록] 새 PARAM 앵커는 Option> 같은 중첩 타입 파라미터를 세지 않는다 — 검수 실측 «현재 0건»이라 무해하다. ★그러나 이것도 «조용히 낮아지는» 형태이므로 다음 회차가 재지 않아도 되게 여기 적어 둔다.", + "★[검수 minor · 범위 밖] .gitignore 에 __pycache__ 항목이 없다 — 이 회차는 손으로 잡아 뺐고 커밋에는 0건이지만 방어가 «사람의 주의»뿐이다. 후속 티켓 감." + ], + "proposals": [ + { + "title": "rustjava-null-guard-spec-triage-for-constructor-and-collection-params", + "plainSummary": "남은 20곳을 «가드를 넣을 자리»로 보지 말고, JDK 규격 기준으로 세 갈래(NPE 의무 / null 이 합법 / 애초에 도달 불가)로 가른 뒤 첫 갈래만 닫는다.", + "userBenefit": "null 이 합법인 API(예: URL 의 handler)에 잘못된 예외를 심지 않으면서, 진짜로 죽는 자리만 막는다.", + "why": "잔여 20건은 대부분 생성자·컬렉션 내부 파라미터라 «null 이 오면 NPE» 가 자명하지 않다. URL(context, spec, handler) 는 JDK 가 null handler 를 명시적으로 허용하므로 일괄 가드는 규격 위반이 된다. ⇒ 세는 것은 끝났고 다음은 «가르는 것»이다.", + "tradeoff": "규격 확인에 시간이 든다(자동화 불가 — Javadoc 판단). 그리고 삼분 결과 첫 갈래가 작으면 이 후속의 코드 산출이 거의 없을 수 있다 — 그래도 «가른 기록»이 남는 것이 값이다.", + "effort": "M(규격 삼분이 대부분 · 코드는 그 뒤)", + "target": "RustJava rustjava-runtime (java/net · java/util · java/util/zip · java/io 생성자류 20곳)" + }, + { + "title": "rustjava-null-guard-fixture-for-file-backed-io", + "plainSummary": "임시 파일을 쓰는 픽스처를 세워, 이번에 가드는 넣었으나 테스트가 잠그지 않는 6곳(FileInputStream·FileOutputStream·RandomAccessFile)을 회귀로 묶는다.", + "userBenefit": "그 6곳의 가드가 나중에 지워져도 CI 가 말한다 — 지금은 조용히 사라질 수 있다.", + "why": "이 회차의 픽스처는 18곳 중 12곳만 덮는다. 파일 핸들이 필요한 6곳은 인스턴스를 만들 수 없어 빠졌고, 그 사실이 이 워크로그에만 적혀 있다(기계는 모른다).", + "tradeoff": "테스트가 임시 파일을 만들고 지워야 한다 — 기존 픽스처 관례(순수 계산)에서 벗어나고, 실패 시 잔여 파일이 남을 수 있다.", + "effort": "S", + "target": "RustJava test-data (FileInputStream·FileOutputStream·RandomAccessFile 6곳)" + } + ], + "adoptedProposals": [ + "2026-09-11-null-guard-string-init-and-arraycopy#p0" + ], + "declinedProposals": [] +} diff --git a/docs/worklog/2026-09-11-null-guard-audit-and-io-buffer-guards.md b/docs/worklog/2026-09-11-null-guard-audit-and-io-buffer-guards.md new file mode 100644 index 00000000..dcd45930 --- /dev/null +++ b/docs/worklog/2026-09-11-null-guard-audit-and-io-buffer-guards.md @@ -0,0 +1,149 @@ +# 2026-09-11 — null 가드 감사(세기) + `java/io` 버퍼 18곳 가드 + +채택 제안 `2026-09-11-null-guard-string-init-and-arraycopy#p0`. + +선행 회차는 **손으로 열거한** 9곳을 닫았고, ★그 열거 자신이 `arraycopy` 의 `dest` 를 빠뜨렸다. +그래서 이번엔 **술어로 셌다**. + +## 감사 — 수와 술어 (★**재측 2026-09-12** · 트리별 병기) + +★★**[게이트② F1 정정] 수는 «트리와 함께» 적는다 — 초판이 두 트리를 섞었다.** +정본 = **`scripts/audit-null-guards.py`**(이 회차가 커밋했다). 재현: `python3 scripts/audit-null-guards.py []`. + +| 트리 | N | M | K | +|---|---|---|---| +| `eaad8e9c`(두 회차 전 · ★초판이 잘못 인용한 트리) | 1,175 | 60 | 46 | +| **base `6da7d66f`**(= 이 PR 의 `merge-base`) | **1,175** | **52** | ★**38** | +| **head** | **1,175** | **34** | **20** | +| **Δ**(base→head) | 0 | **18** | ★**18** = 이 회차의 가드 수(자기정합) | + +★★**[게이트² R2] 이 표의 M 은 «한 번 더» 틀렸었다 — 커밋한 스크립트가 2 낮게 셌다**(50/32 로 신고). +근인은 `fn` 정규식 한 줄이다: 이름 뒤 `(` 를 **즉시** 요구하고 접두를 `pub `/`async ` 로만 받아 +★**제네릭 fn(`fn sort_primitive(`)과 `pub(crate)`/`pub(super)` 선언 34개가 통째로 안 보였다** +(놓친 2건 = `java/util/arrays.rs sort_primitive(array)` · `java/util/timer_task_queue.rs add(task)`). +★고친 뒤 **세 트리 × 세 독립 구현(커밋본·검수 재구현·전 검수)이 전건 일치**한다. +★★**그리고 K 가 흔들리지 않은 것은 «설계가 아니라 운»이다** — 그 2건이 **우연히** `as_proto` 미등재였을 뿐이고, +제네릭·`pub(crate)` 진입점이 **하나라도** 등재됐으면 ★**K 가 조용히 낮아져 이 감사가 「닫혔다」고 답했을 것**이다. +⇒ 그 «눈먼 구간»을 스크립트 docstring 에 박았다(무엇을 못 보는지). + +술어: **N** = `rustjava-runtime/src/**` 의 fn 파라미터 중 타입이 `ClassInstanceRef<…>`(★`this`·`_` 접두 제외) · +**M** = N 중 그 파라미터가 **deref 강제 sink** 에 `&p`/`&mut p`(또는 `p.as_class_instance()`)로 도달하고 ★**그 «앞»에 `p.is_null()` 가 없는** 것 · +**K** = M 중 그 fn 이 같은 파일 `JavaMethodProto::new(… Self::fn …)` 에 등재 = **게스트가 부를 수 있다**. + +★★**초판의 「M 58 · K 46」은 «다른 트리»의 수다** — **`eaad8e9c`**(선행 9곳 회차 «이전» main)에서 재고 라벨만 `6da7d66f` 로 달았다 +(★그 트리의 «옳은» 수는 **60/46** 이다 — 58 은 위 R2 의 undercount 가 겹친 값이다). +⇒ ★**`46 − 18 = 28 ≠ 20`** 이라는 산술 모순이 그 혼입의 «증상»이었고, 검수자가 술어를 재구현해 그것으로 잡았다. +★**이 저장소가 반복해 규탄한 「base 를 병기하지 않으면 수가 섞인다」의 교과서적 재현이다.** +★★**[철회] 전 회차가 「N·M 의 차는 구현 세부이고 내 쪽이 정본」이라는 뜻으로 적은 문안을 «철회»한다 — 틀린 것은 내 쪽이었다.** +검수 재구현(N 1,175 · M 60/52/34)이 옳았고, 커밋본을 고치니 **그 값에 정확히 수렴**했다. +※그보다 앞선 검수의 N **1,130** 은 또 다른 구현이고, M/K 는 **52·34 / 38·20 으로 이미 일치**했다. + +**deref 강제 sink = 17개**(`jvm/src/jvm.rs` 의 시그니처로 확정 — `&Box` · `&mut Box<…>` · `impl AsClassInstance`): +`array_element_type` `array_length` `array_raw_buffer` `array_raw_buffer_mut` `get_field` `interrupt_java_thread` +`invoke_special` `invoke_virtual` `is_java_thread_interrupted` `load_array` `monitor_enter` `monitor_exit` +`object_notify` `object_wait_prepare` `put_field` `shallow_clone` `store_array` + +★**왜 sink 를 한정했는가**: 초판은 「`&p` 가 나오면 deref」로 셌는데 그것은 **과대계상**이다 — +`Self::value_range(jvm, &value)` 처럼 **`&ClassInstanceRef` 를 받는** 헬퍼는 deref 하지 «않는다». +한정 전 220 → 한정 후 152 → (off-by-one 수정 후) 58 → ★(**R2 의 `fn` 눈먼 구간까지 수정 후**) **60**. ※전부 `eaad8e9c` 기준. + +## ★계측기를 «먼저» 검증했다 — 그리고 초판이 틀렸다 + +정답지 = 선행 회차가 닫은 **9곳**. 가드 «전»(`eaad8e9c`)·«후» 트리에 같은 자를 대고 차집합을 봤다. + +- **초판: 3/9** — 근인은 **off-by-one** 이다. 정규식을 `"\n" + src` 에 돌려 놓고 인덱스는 `src` 에 적용해 + 괄호 스캔이 **한 칸 밀렸고**, 시그니처의 `)` 에서 멈추지 못해 파라미터 문자열이 본문까지 삼켰다. +- **수정 후: 8/9** · 새로 생긴 것 **0**. +- ★**남은 1건 = `init_with_string(value)`** — 그 fn 은 `Self::value_range(jvm, &value)` **안에서** deref 한다. + ⇒ ★★**이 계측은 절차간(interprocedural)을 못 본다. 따라서 N·M·K 는 «하한»이다.** + ※검수자가 선행 회차에 적은 「899 중 560 · **상한값**」과 **방향이 반대**다 — 그쪽은 fn 단위 크루드 스캔이고 이쪽은 sink 한정 + 절차내다. **두 수를 같은 축으로 비교하지 마라.** + +## 부수 발견 — 패닉 impl 은 «둘»이 아니라 «셋»이다 + +`jvm/src/class_instance.rs` 에 `Deref`(`as_ref().unwrap()`) · `DerefMut`(`as_mut().unwrap()`) 말고 +★**`AsClassInstance::as_class_instance`(`as_deref().unwrap()`)** 가 있다 — `monitor_enter`/`monitor_exit`/`object_wait_prepare`/`object_notify` 가 그 경로다. + +## 이 회차가 «닫은» 선 — `java/io` 데이터 전송 + `String.getChars` (18곳) + +`BufferedOutputStream.write(byte[],int,int)` · `DataInputStream.readFully(byte[])` · +`DataOutputStream.{writeBytes,writeChars,writeUTF}(String)` · `FileInputStream.read(byte[])` · +`FileOutputStream.write(byte[],int,int)` · `InputStreamReader.read(char[])` · `OutputStreamWriter.write(char[])` · +`RandomAccessFile.{read(byte[]),read(byte[],int,int),write(byte[]),write(byte[],int,int)}` · +`Reader.read(char[])` · `Writer.{write(char[]),write(String),write(String,int,int)}` · `String.getChars(int,int,char[],int)` + +★**선의 근거**: 전부 **JDK 규격이 NPE 를 명시**하는 «데이터를 옮기는 버퍼»이고, 게스트가 가장 흔히 null 을 넘기는 자리다. +★**가드 형태는 선행 회차 그대로**(진입부 `is_null()` → `jvm.exception("java/lang/NullPointerException", " is null")`) — 새 형태를 만들지 않았다. + +## ★남긴 20건 — 「가드를 넣어라」가 «아니다» + +``` +java/io 4 read_utf_from_input(input) · init(file) · init_with_append(file) · init_with_file(file) +java/net 3 init(url) · init_with_context_spec_handler(handler) · set_url(url) +java/util 6 init(list)×4 [abstract_list · array_list_itr · linked_list_itr · vector_itr] + · on_access(map) · init(map) +java/util/regex 3 init(pattern) · matches(pattern) · split_with_limit(input) +java/util/zip 3 init_with_zip_entry(zip_entry) · get_input_stream(entry) · init(file) +org/rustjava/net 1 open_connection(url) + ── 합계 20 +``` +★★**[게이트² R3 정정] 전 회차의 분해는 오기였다** — `open_connection` 을 `java/net` 으로 세고 `java/util/regex` 3건을 `java/util` 에 합쳐 +「4·8·3·1」로 적었다. 위가 도구 출력 그대로다. +★★**일괄 가드는 «틀린다»** — `URL(URL context, String spec, URLStreamHandler handler)` 는 JDK 규격상 +**`handler == null` 이 합법**이다(기본 핸들러를 쓰라는 뜻). 실제로 이 repo 의 구현도 그 파라미터를 **섀도잉해 무시**한다 +(⇒ 그 1건은 이 계측의 **오탐**이기도 하다 — 이름 기반 절차내 스캔은 섀도잉을 못 본다). +⇒ **후속의 본체는 「규격으로 삼분하는 것」**(NPE 의무 / null 합법 / 도달 불가)이고, 첫 갈래만 닫는다. + +## 검증 — 양방향 + +- **정상 → green**: `cargo test --test test_class` **ok** · DoD 7종 rc=0 · `cargo test --all` **554/0/1** + (★계수 불변이 맞다 — `test_class` 는 `test-data/` 를 **한 테스트 함수**가 순회한다). +- **개악 → red**: ★★**커버리지를 «추론»하지 않고 «측정»했다 — 18곳을 «하나씩» 빼고 각각 빌드·실행했다**(2회전 = 36빌드). + ⇒ **12곳 red(덮인다) · 6곳 green(안 덮인다)**. 그 밖에 `getChars` **가드 위치** 원복 개악도 red. + ★**이 측정이 R1 을 잡는 «지문»이다** — 이미 덮인 가드를 또 덮으면 red 가 «안» 나므로, 중복 케이스가 즉시 드러난다. + +## 픽스처 커버리지 — ★**측정값 12/18 · 미커버 6** (게이트² R1 정정) + +★★**[R1] 전 회차의 근인 서술이 «뒤집혀» 있었다 — 그 문장이 다음 사람을 오도한다.** +종전 문안: 「`InputStreamReader` 가 **오버라이드하므로** `Reader` base 에 닿지 못한다」 ⇒ ★**정반대다.** +**등재 서술자 실측**: `InputStreamReader` 는 `read` 를 **`([CII)I` 로만** 등재하고 `Reader` 가 **`([C)I`** 를 등재한다 +(`OutputStreamWriter` `write([CII)V` ↔ `Writer` `write([C)V` 도 같은 형상). +⇒ ★**오버라이드가 «없어서» 1인자 호출이 `Reader::read([C)` 로 해소된다.** + +⇒ 그래서 전 회차가 더한 **중첩 클래스 2케이스는 «이미 덮인 가드»를 또 덮었고** 커버리지는 **10/18 그대로**였다(검수 지적이 옳다). +★**그 2케이스와 `$BareReader`/`$BareWriter` 를 지웠다.** +★**대신 «그 가드가 실제로 사는 서술자»를 겨눴다** — `read(char[],int,int)` · `write(char[],int,int)` **3인자 호출**. +★★**판정 축은 «클래스 이름»이 아니라 «서술자 + 등재 위치»다**(검수가 준 축 그대로). + +★★**그리고 이번엔 «추론»하지 않고 «쟀다» — 가드 18곳을 하나씩 빼고 각각 빌드·실행**: + +| | 가드 | 판정 | +|---|---|---| +| **덮는다(12)** | `buffered_output_stream.write_bytes` · `data_input_stream.read_fully` · `data_output_stream`×3 · ★`input_stream_reader.read` · ★`output_stream_writer.write` · `reader.read` · `writer.write_chars` · `writer.write_string` · `writer.write_string_offset` · `string.get_chars` | 단일 제거 → **red** | +| ★**미커버(6)** | `file_input_stream.read_array` · `file_output_stream.write_bytes_offset` · `random_access_file`×4 | 단일 제거 → **green** | + +★**미커버 6 은 전부 «실파일 핸들이 있어야 인스턴스가 생기는» 것**이고, 테스트가 작업 디렉터리에 파일을 만드는 것을 피했다. +⇒ ★**「가드는 들어갔으나 픽스처가 잠그지 않는다」** — `proposals[1]` 이 진다. +※★전 회차가 「reader/writer 단일 개악이 red 였으니 새 케이스가 문다」고 적은 것은 **논증이 안 된다** — +그 red 는 **기존 `InputStreamReader` 케이스만으로도** 났다. ★**한 가드를 두 케이스가 덮으면 단일 개악은 그 둘을 구별하지 못한다.** + +## ★F4 — `String.getChars` 가드의 «위치»를 고쳤다 (선택했다) + +JDK `getChars` 는 **범위를 먼저** 검사하므로 «잘못된 범위 + null dst» 면 **`StringIndexOutOfBoundsException` 이 이긴다**. +초판은 가드를 범위 검사 «앞»에 둬 그 선후를 **NPE 로 뒤집었다** — 티켓 계약 4(「동작 의미를 바꾸지 마라」)의 그 축이다. +⇒ ★**옮겼다**(`issues` 에 적는 쪽이 아니라). 그리고 ★**선후 잠금 케이스를 픽스처에 넣었다** — 옛 위치로 되돌리면 그 케이스가 **red** 다. + +## 재현 — ★**스크립트를 커밋했다**(F5 · 초판 판단을 뒤집는다) + +```sh +python3 scripts/audit-null-guards.py # 현재 트리 +git worktree add /tmp/t && python3 scripts/audit-null-guards.py /tmp/t/rustjava-runtime/src +``` +★★**초판은 「CI 에 배선되지 않은 검사기는 낡는다」며 커밋하지 «않았다». 그 판단을 뒤집는다 — 근거는 이 회차 자신이다**: +★**작성자가 자기 수를 교차검증할 수단이 없어 F1(두 트리 혼입)이 났고**, 검수자는 **술어를 prose 에서 재구현**해야 했다(그래서 N·M 이 갈렸다). +⇒ **실행 가능한 정의 하나가 산문보다 싸다.** +★**단 «잠금이 아니라 감사»다** — CI 에 배선하지 «않았다». K 가 커도 아무것도 red 가 되지 않는다(이 스크립트는 수를 보고할 뿐이다). +★**다시 돌릴 때는 반드시 «정답지 검증»을 먼저 하라**(스크립트 docstring 에도 박았다) — 이 회차가 그것으로 자기 off-by-one 을 잡았다. + +## 제안 +- p0: 잔여 20건의 **규격 삼분**(NPE 의무 / null 합법 / 도달 불가) 후 첫 갈래만 닫기. +- p1: **파일 기반 IO 픽스처** — 가드는 있는데 잠그지 않는 6곳. diff --git a/rustjava-runtime/src/classes/java/io/buffered_output_stream.rs b/rustjava-runtime/src/classes/java/io/buffered_output_stream.rs index 59d0dd89..eff785a0 100644 --- a/rustjava-runtime/src/classes/java/io/buffered_output_stream.rs +++ b/rustjava-runtime/src/classes/java/io/buffered_output_stream.rs @@ -117,6 +117,10 @@ impl BufferedOutputStream { ) -> Result<()> { tracing::debug!("java.io.BufferedOutputStream::write({this:?}, {bytes:?}, {offset}, {length})"); + if bytes.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "bytes is null").await); + } + let source_length = jvm.array_length(&bytes).await? as i32; if offset < 0 || length < 0 || offset > source_length - length { return Err(jvm.exception("java/lang/IndexOutOfBoundsException", "Invalid offset or length").await); diff --git a/rustjava-runtime/src/classes/java/io/data_input_stream.rs b/rustjava-runtime/src/classes/java/io/data_input_stream.rs index 6c7186d0..a1ab2e7b 100644 --- a/rustjava-runtime/src/classes/java/io/data_input_stream.rs +++ b/rustjava-runtime/src/classes/java/io/data_input_stream.rs @@ -248,6 +248,10 @@ impl DataInputStream { async fn read_fully(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef, b: ClassInstanceRef>) -> Result<()> { tracing::debug!("java.io.DataInputStream::readFully({this:?}, {b:?})"); + if b.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "b is null").await); + } + let length = jvm.array_length(&b).await?; let _: () = jvm diff --git a/rustjava-runtime/src/classes/java/io/data_output_stream.rs b/rustjava-runtime/src/classes/java/io/data_output_stream.rs index 9c4a2bfd..5b75dafd 100644 --- a/rustjava-runtime/src/classes/java/io/data_output_stream.rs +++ b/rustjava-runtime/src/classes/java/io/data_output_stream.rs @@ -190,6 +190,10 @@ impl DataOutputStream { async fn write_bytes(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef, s: ClassInstanceRef) -> Result<()> { tracing::debug!("java.io.DataOutputStream::writeBytes({this:?}, {s:?})"); + if s.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "s is null").await); + } + let chars: ClassInstanceRef> = jvm.invoke_virtual(&s, "java/lang/String", "toCharArray", "()[C", ()).await?; let length = jvm.array_length(&chars).await?; let chars: Vec = jvm.load_array(&chars, 0, length).await?; @@ -203,6 +207,10 @@ impl DataOutputStream { async fn write_chars(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef, s: ClassInstanceRef) -> Result<()> { tracing::debug!("java.io.DataOutputStream::writeChars({this:?}, {s:?})"); + if s.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "s is null").await); + } + let chars: ClassInstanceRef> = jvm.invoke_virtual(&s, "java/lang/String", "toCharArray", "()[C", ()).await?; let length = jvm.array_length(&chars).await?; let chars: Vec = jvm.load_array(&chars, 0, length).await?; @@ -222,6 +230,10 @@ impl DataOutputStream { async fn write_utf(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef, s: ClassInstanceRef) -> Result<()> { tracing::debug!("java.io.DataOutputStream::writeUTF({this:?}, {s:?})"); + if s.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "s is null").await); + } + let chars: ClassInstanceRef> = jvm.invoke_virtual(&s, "java/lang/String", "toCharArray", "()[C", ()).await?; let length = jvm.array_length(&chars).await?; let chars: Vec = jvm.load_array(&chars, 0, length).await?; diff --git a/rustjava-runtime/src/classes/java/io/file_input_stream.rs b/rustjava-runtime/src/classes/java/io/file_input_stream.rs index 9ed873df..d24ab3d8 100644 --- a/rustjava-runtime/src/classes/java/io/file_input_stream.rs +++ b/rustjava-runtime/src/classes/java/io/file_input_stream.rs @@ -114,6 +114,10 @@ impl FileInputStream { ) -> Result { tracing::debug!("java.io.FileInputStream::read({this:?}, {buf:?}, {offset:?}, {length:?})"); + if buf.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "buf is null").await); + } + let fd = jvm.get_field(&this, "fd", "Ljava/io/FileDescriptor;").await?; let mut rust_file = FileDescriptor::file(jvm, context, fd).await?; diff --git a/rustjava-runtime/src/classes/java/io/file_output_stream.rs b/rustjava-runtime/src/classes/java/io/file_output_stream.rs index e72d02c0..2f1d7872 100644 --- a/rustjava-runtime/src/classes/java/io/file_output_stream.rs +++ b/rustjava-runtime/src/classes/java/io/file_output_stream.rs @@ -107,6 +107,10 @@ impl FileOutputStream { ) -> Result<()> { tracing::debug!("java.io.FileOutputStream::write({this:?}, {buffer:?}, {offset:?}, {length:?})"); + if buffer.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "buffer is null").await); + } + let fd = jvm.get_field(&this, "fd", "Ljava/io/FileDescriptor;").await?; let mut file = FileDescriptor::file(jvm, context, fd).await?; diff --git a/rustjava-runtime/src/classes/java/io/input_stream_reader.rs b/rustjava-runtime/src/classes/java/io/input_stream_reader.rs index 8c8b93e9..b6876db8 100644 --- a/rustjava-runtime/src/classes/java/io/input_stream_reader.rs +++ b/rustjava-runtime/src/classes/java/io/input_stream_reader.rs @@ -110,6 +110,10 @@ impl InputStreamReader { ) -> Result { tracing::debug!("java.io.InputStreamReader::read({this:?}, {buf:?}, {offset:?}, {length:?})"); + if buf.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "buf is null").await); + } + let destination_length = jvm.array_length(&buf).await? as i32; if offset < 0 || length < 0 || offset > destination_length - length { return Err(jvm.exception("java/lang/IndexOutOfBoundsException", "Invalid offset or length").await); diff --git a/rustjava-runtime/src/classes/java/io/output_stream_writer.rs b/rustjava-runtime/src/classes/java/io/output_stream_writer.rs index d6e2434d..321771d5 100644 --- a/rustjava-runtime/src/classes/java/io/output_stream_writer.rs +++ b/rustjava-runtime/src/classes/java/io/output_stream_writer.rs @@ -104,6 +104,10 @@ impl OutputStreamWriter { ) -> Result<()> { tracing::debug!("java.io.OutputStreamWriter::write({this:?}, {chars:?}, {off}, {len})"); + if chars.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "chars is null").await); + } + let array_length = jvm.array_length(&chars).await? as i32; if off < 0 || len < 0 || off > array_length - len { return Err(jvm.exception("java/lang/IndexOutOfBoundsException", "Invalid offset or length").await); diff --git a/rustjava-runtime/src/classes/java/io/random_access_file.rs b/rustjava-runtime/src/classes/java/io/random_access_file.rs index 59ab137e..068fc02a 100644 --- a/rustjava-runtime/src/classes/java/io/random_access_file.rs +++ b/rustjava-runtime/src/classes/java/io/random_access_file.rs @@ -119,6 +119,10 @@ impl RandomAccessFile { async fn read(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef, buf: ClassInstanceRef>) -> Result { tracing::debug!("java.io.RandomAccessFile::read({this:?}, {buf:?})"); + if buf.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "buf is null").await); + } + let length = jvm.array_length(&buf).await?; let read = jvm .invoke_virtual(&this, "java/io/RandomAccessFile", "read", "([BII)I", (buf, 0, length as i32)) @@ -137,6 +141,10 @@ impl RandomAccessFile { ) -> Result { tracing::debug!("java.io.RandomAccessFile::read({this:?}, {buf:?}, {offset:?}, {length:?})"); + if buf.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "buf is null").await); + } + let fd = jvm.get_field(&this, "fd", "Ljava/io/FileDescriptor;").await?; let mut rust_file = FileDescriptor::file(jvm, context, fd).await?; @@ -153,6 +161,10 @@ impl RandomAccessFile { async fn write(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef, buf: ClassInstanceRef>) -> Result<()> { tracing::debug!("java.io.RandomAccessFile::write({this:?}, {buf:?})"); + if buf.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "buf is null").await); + } + let length = jvm.array_length(&buf).await?; let _: () = jvm .invoke_virtual(&this, "java/io/RandomAccessFile", "write", "([BII)V", (buf, 0, length as i32)) @@ -171,6 +183,10 @@ impl RandomAccessFile { ) -> Result<()> { tracing::debug!("java.io.RandomAccessFile::write({this:?}, {buf:?}, {offset:?}, {length:?})"); + if buf.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "buf is null").await); + } + let fd = jvm.get_field(&this, "fd", "Ljava/io/FileDescriptor;").await?; let mut rust_file = FileDescriptor::file(jvm, context, fd).await?; diff --git a/rustjava-runtime/src/classes/java/io/reader.rs b/rustjava-runtime/src/classes/java/io/reader.rs index 4aff769c..e27e7dd5 100644 --- a/rustjava-runtime/src/classes/java/io/reader.rs +++ b/rustjava-runtime/src/classes/java/io/reader.rs @@ -74,6 +74,10 @@ impl Reader { async fn read(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef, buf: ClassInstanceRef>) -> Result { tracing::debug!("java.io.Reader::read({this:?}, {buf:?})"); + if buf.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "buf is null").await); + } + let len = jvm.array_length(&buf).await? as i32; let result = jvm.invoke_virtual(&this, "java/io/Reader", "read", "([CII)I", (buf, 0, len)).await?; diff --git a/rustjava-runtime/src/classes/java/io/writer.rs b/rustjava-runtime/src/classes/java/io/writer.rs index 8590ff63..cd765273 100644 --- a/rustjava-runtime/src/classes/java/io/writer.rs +++ b/rustjava-runtime/src/classes/java/io/writer.rs @@ -99,6 +99,10 @@ impl Writer { async fn write_chars(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef, chars: ClassInstanceRef>) -> Result<()> { tracing::debug!("java.io.Writer::write({this:?}, {chars:?})"); + if chars.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "chars is null").await); + } + let length = jvm.array_length(&chars).await? as i32; jvm.invoke_virtual(&this, "java/io/Writer", "write", "([CII)V", (chars, 0, length)).await } @@ -106,6 +110,10 @@ impl Writer { async fn write_string(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef, string: ClassInstanceRef) -> Result<()> { tracing::debug!("java.io.Writer::write_string({this:?}, {string:?})"); + if string.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "string is null").await); + } + let chars: ClassInstanceRef> = jvm.invoke_virtual(&string, "java/lang/String", "toCharArray", "()[C", ()).await?; let length = jvm.array_length(&chars).await?; @@ -126,6 +134,10 @@ impl Writer { ) -> Result<()> { tracing::debug!("java.io.Writer::write({this:?}, {string:?}, {off}, {len})"); + if string.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "string is null").await); + } + let chars: ClassInstanceRef> = jvm.invoke_virtual(&string, "java/lang/String", "toCharArray", "()[C", ()).await?; jvm.invoke_virtual(&this, "java/io/Writer", "write", "([CII)V", (chars, off, len)).await } diff --git a/rustjava-runtime/src/classes/java/lang/string.rs b/rustjava-runtime/src/classes/java/lang/string.rs index cbcd6495..0dce204c 100644 --- a/rustjava-runtime/src/classes/java/lang/string.rs +++ b/rustjava-runtime/src/classes/java/lang/string.rs @@ -609,6 +609,11 @@ impl String { .await); } + // the range checks run first, matching JDK getChars: a bad range wins over a null dst + if dst.is_null() { + return Err(jvm.exception("java/lang/NullPointerException", "dst is null").await); + } + let chars: Vec = jvm .load_array(&value, offset + src_begin as usize, (src_end - src_begin) as usize) .await?; diff --git a/scripts/audit-null-guards.py b/scripts/audit-null-guards.py new file mode 100644 index 00000000..1204b604 --- /dev/null +++ b/scripts/audit-null-guards.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Count runtime entry points where a null `ClassInstanceRef` argument would abort the host. + +★This is an AUDIT, not a lock. It is deliberately NOT wired into CI: it reports numbers, + it does not gate. Nothing fails because this script prints a large K. + +Why it is committed at all (2026-09-11, gate2 F5): the round that first produced these +numbers could not cross-check its own arithmetic, and shipped a K measured on the WRONG +TREE. One executable definition of the predicate is cheaper than re-deriving it from prose. + + python3 scripts/audit-null-guards.py [] [--json ] + +To measure another commit, materialise it and point at that tree — the script never +consults git, so the tree you pass is the tree you measure: + + git worktree add /tmp/t + python3 scripts/audit-null-guards.py /tmp/t/rustjava-runtime/src + +Predicate +--------- +N fn parameters typed `ClassInstanceRef<…>`, excluding `this` and `_`-prefixed. +M of N, the parameter reaches a DEREF-FORCING SINK as `&p` / `&mut p` + (or `p.as_class_instance()`) with no `p.is_null()` earlier in the body. +K of M, the fn is registered via `JavaMethodProto::new(…, Self::fn, …)` in the same + file, i.e. guest bytecode can call it, i.e. null can actually arrive. + +SINKS are the `Jvm` methods that take `&Box`, `&mut Box<…>` or +`impl AsClassInstance` — passing a `ClassInstanceRef` there forces Deref/DerefMut/ +AsClassInstance, all three of which are `.unwrap()` on an Option (see +`jvm/src/class_instance.rs`). That unwrap is the host abort. + +Blind spots this scanner HAS HAD (2026-09-12, gate2 R2) — read before trusting a count: + * The first committed version demanded `(` immediately after the function name and only + accepted `pub `/`async ` prefixes. That hid 34 declarations repo-wide — every generic + fn (`fn sort_primitive(`) and every `pub(crate)`/`pub(super)` fn — and undercounted + M by 2. Both are handled now (FN + the balanced `<…>` scan in split_fns). + * ★Why that mattered more than the 2: K survived only by luck. The two hidden functions + happened not to be registered in as_proto. Had ONE generic or `pub(crate)` entry point + been registered, K would have dropped SILENTLY and this audit would have answered + "closed" while the hole stayed open. A miscount here is not a rounding error; it is the + audit lying in the safe direction. + * Parameters are split on depth-0 commas and the type is anchored at the parameter's + start, so a `ClassInstanceRef` nested inside another parameter's type is not counted + as its own parameter. + +Known limits — K is a LOWER bound, and not a clean one: + * intraprocedural only. A fn that derefs inside a helper is invisible (this is why + `String::init_with_string` was missed: it derefs inside `Self::value_range`). + * name-based, so shadowing yields false positives (`java/net/url.rs` flags a `handler` + parameter that the body shadows and ignores). + * `null can arrive` is approximated by `registered in as_proto`; it does not ask + whether the JDK spec actually permits null there (for `URL(context, spec, handler)` + a null handler is LEGAL, so a guard would be wrong). + +★When you re-run this, validate it against a known answer key BEFORE trusting the output: + measure the tree before and after a round that added guards, and check the set difference + equals exactly that round's guards. That check is what caught an off-by-one here once. +""" + +import argparse +import collections +import json +import pathlib +import re +import sys + +SINKS = ( + "array_element_type", "array_length", "array_raw_buffer", "array_raw_buffer_mut", + "get_field", "interrupt_java_thread", "invoke_special", "invoke_virtual", + "is_java_thread_interrupted", "load_array", "monitor_enter", "monitor_exit", + "object_notify", "object_wait_prepare", "put_field", "shallow_clone", "store_array", +) + +# Matches `fn ` under any visibility/qualifier. Generics are NOT matched here: +# `fn sort_primitive(` needs a balanced `<…>` scan, which split_fns does. An earlier +# version demanded `(` immediately after the name and only accepted `pub `/`async ` as +# prefixes; that silently hid 34 declarations repo-wide (generic fns and `pub(crate)`/ +# `pub(super)`), undercounting M by 2. See "blind spots" in the module docstring. +FN = re.compile( + r"(?:^|\n)\s*" + r"(?:pub\s*(?:\([^)]*\)\s*)?)?" # pub, pub(crate), pub(super), pub(in …) + r"(?:default\s+)?(?:const\s+)?(?:async\s+)?(?:unsafe\s+)?" + r"(?:extern\s+\"[^\"]*\"\s+)?" + r"fn\s+([A-Za-z0-9_]+)" +) +# Anchored at the START of a parameter, so a `ClassInstanceRef` nested inside another +# parameter's type (a closure bound, say) is not miscounted as its own parameter. +PARAM = re.compile(r"^(?:mut\s+)?([A-Za-z0-9_]+)\s*:\s*(?:&\s*)?(?:mut\s+)?ClassInstanceRef\s*<") +PROTO = re.compile(r'JavaMethodProto::new\(\s*"[^"]*"\s*,\s*"[^"]*"\s*,\s*Self::([A-Za-z0-9_]+)') +SINK_CALL = r"\.\s*(?:" + "|".join(SINKS) + r")\s*\(\s*&\s*(?:mut\s+)?\**\s*" + + +def _balanced(text, start, open_ch, close_ch): + """Index of the closer matching the opener at `start`, or -1.""" + depth = 0 + for i in range(start, len(text)): + if text[i] == open_ch: + depth += 1 + elif text[i] == close_ch: + depth -= 1 + if depth == 0: + return i + return -1 + + +def split_fns(text): + """Yield (name, params_src, body_src). All indices address `text` itself.""" + for m in FN.finditer(text): + i = m.end() + while i < len(text) and text[i].isspace(): + i += 1 + if i < len(text) and text[i] == "<": # generic parameter list + i = _balanced(text, i, "<", ">") + if i < 0: + continue + i += 1 + while i < len(text) and text[i].isspace(): + i += 1 + if i >= len(text) or text[i] != "(": + continue + paren = i + close = _balanced(text, paren, "(", ")") + if close < 0: + continue + brace = text.find("{", close) + if brace < 0: + continue + end = _balanced(text, brace, "{", "}") + if end < 0: + continue + yield m.group(1), text[paren + 1:close], text[brace:end] + + +def split_params(params): + """Split a parameter list on depth-0 commas, ignoring nested <>, (), [].""" + out, depth, start = [], 0, 0 + for i, ch in enumerate(params): + if ch in "<([": + depth += 1 + elif ch in ">)]": + depth -= 1 + elif ch == "," and depth == 0: + out.append(params[start:i]) + start = i + 1 + out.append(params[start:]) + return out + + +def audit(root): + rows, n = [], 0 + for path in sorted(pathlib.Path(root).rglob("*.rs")): + text = path.read_text(encoding="utf-8", errors="replace") + protos = set(PROTO.findall(text)) + for name, params, body in split_fns(text): + for raw in split_params(params): + pm = PARAM.match(raw.strip()) + if not pm: + continue + p = pm.group(1) + if p == "this" or p.startswith("_"): + continue + n += 1 + hits = [m.start() for m in re.finditer(SINK_CALL + re.escape(p) + r"\b", body)] + hits += [m.start() for m in + re.finditer(re.escape(p) + r"\s*\.\s*as_class_instance\s*\(", body)] + if not hits: + continue + guard = re.search(r"\b" + re.escape(p) + r"\s*\.\s*is_null\s*\(\s*\)", body) + if guard and guard.start() < min(hits): + continue + rows.append({"file": str(path), "fn": name, "param": p, + "guest_reachable": name in protos}) + return n, rows + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", default="rustjava-runtime/src") + ap.add_argument("--json", dest="out") + args = ap.parse_args() + + n, rows = audit(args.root) + k_rows = [r for r in rows if r["guest_reachable"]] + print(f"tree: {args.root}") + print(f"N (ClassInstanceRef params, excl. this/_) = {n}") + print(f"M (unguarded, reaches a deref-forcing sink) = {len(rows)}") + print(f"K (of M, registered in as_proto) = {len(k_rows)}") + print() + by = collections.defaultdict(list) + for r in k_rows: + by[r["file"].split("/classes/")[-1]].append(f'{r["fn"]}({r["param"]})') + for f in sorted(by): + print(f" {f}: " + ", ".join(sorted(by[f]))) + if args.out: + json.dump(rows, open(args.out, "w"), indent=1) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test-data/NullBufferGuards.class b/test-data/NullBufferGuards.class new file mode 100644 index 00000000..fb0ea04d Binary files /dev/null and b/test-data/NullBufferGuards.class differ diff --git a/test-data/NullBufferGuards.txt b/test-data/NullBufferGuards.txt new file mode 100644 index 00000000..c60b679d --- /dev/null +++ b/test-data/NullBufferGuards.txt @@ -0,0 +1,26 @@ +BufferedOutputStream.write(byte[],int,int): +caught NPE +DataInputStream.readFully(byte[]): +caught NPE +DataOutputStream.writeBytes(String): +caught NPE +DataOutputStream.writeChars(String): +caught NPE +DataOutputStream.writeUTF(String): +caught NPE +Reader.read(char[]) via InputStreamReader: +caught NPE +Writer.write(char[]) via OutputStreamWriter: +caught NPE +Writer.write(String): +caught NPE +Writer.write(String,int,int): +caught NPE +InputStreamReader.read(char[],int,int): +caught NPE +OutputStreamWriter.write(char[],int,int): +caught NPE +String.getChars(int,int,char[],int): +caught NPE +String.getChars bad range beats null dst: +caught SIOOBE diff --git a/test-data/src/NullBufferGuards.java b/test-data/src/NullBufferGuards.java new file mode 100644 index 00000000..744a2129 --- /dev/null +++ b/test-data/src/NullBufferGuards.java @@ -0,0 +1,124 @@ +import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; + +class NullBufferGuards { + public static void main(String[] args) throws Exception { + // Each case passes a null buffer to a java.io data-transfer method. The JDK spec + // mandates NullPointerException; the runtime used to dereference and abort the host. + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + ByteArrayInputStream source = new ByteArrayInputStream(new byte[4]); + + System.out.println("BufferedOutputStream.write(byte[],int,int):"); + try { + new BufferedOutputStream(sink).write(null, 0, 0); + System.out.println("should not reach"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + + System.out.println("DataInputStream.readFully(byte[]):"); + try { + new DataInputStream(source).readFully(null); + System.out.println("should not reach"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + + System.out.println("DataOutputStream.writeBytes(String):"); + try { + new DataOutputStream(sink).writeBytes(null); + System.out.println("should not reach"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + + System.out.println("DataOutputStream.writeChars(String):"); + try { + new DataOutputStream(sink).writeChars(null); + System.out.println("should not reach"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + + System.out.println("DataOutputStream.writeUTF(String):"); + try { + new DataOutputStream(sink).writeUTF(null); + System.out.println("should not reach"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + + System.out.println("Reader.read(char[]) via InputStreamReader:"); + try { + new InputStreamReader(source).read((char[]) null); + System.out.println("should not reach"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + + System.out.println("Writer.write(char[]) via OutputStreamWriter:"); + try { + new OutputStreamWriter(sink).write((char[]) null); + System.out.println("should not reach"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + + System.out.println("Writer.write(String):"); + try { + new OutputStreamWriter(sink).write((String) null); + System.out.println("should not reach"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + + System.out.println("Writer.write(String,int,int):"); + try { + new OutputStreamWriter(sink).write((String) null, 0, 0); + System.out.println("should not reach"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + + System.out.println("InputStreamReader.read(char[],int,int):"); + try { + new InputStreamReader(source).read(null, 0, 0); + System.out.println("should not reach"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + + System.out.println("OutputStreamWriter.write(char[],int,int):"); + try { + new OutputStreamWriter(sink).write((char[]) null, 0, 0); + System.out.println("should not reach"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + + System.out.println("String.getChars(int,int,char[],int):"); + try { + "ab".getChars(0, 2, null, 0); + System.out.println("should not reach"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + + // JDK getChars checks the range before it touches dst, so a bad range wins over a + // null dst. Locks the guard's position, not just its presence. + System.out.println("String.getChars bad range beats null dst:"); + try { + "ab".getChars(0, 99, null, 0); + System.out.println("should not reach"); + } catch (StringIndexOutOfBoundsException e) { + System.out.println("caught SIOOBE"); + } catch (NullPointerException e) { + System.out.println("caught NPE"); + } + } +}