diff --git a/REPORT.md b/REPORT.md index e62c5f40..33621c90 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,8 @@ # REPORT +## [2026-09-24] «파일 끝에 남는 바이트» 거부가 클래스가 끝난 위치를 말한다 (rustjava-2026-09-23-validation-rules-name-their-position-adopt-p0) +- 무엇을: parse-level 거부 3종을 재어, 정확한 위치를 쥔 «extra bytes» 하나만 `Location::ByteOffset` 으로 오프셋을 싣는다. «truncated or unparsable» 은 nom 오프셋이 손상 지점을 절반 가까이 빗나가 두었고, «version < 45.0» 은 가리킬 자리가 없다. +- 왜: 채택 제안 `2026-09-23-validation-rules-name-their-position#p0`. 제안의 예상(«truncated» 가 후보, 나머지는 없음)은 측정으로 뒤집혔다. +- 사용자 영향: 덧붙은 바이트가 있는 파일의 `ClassFormatError` 가 `(at byte offset N)` 을 붙여 어디서 잘라야 할지 말한다. `ClassFileError` 가 24 → 32 바이트로 커졌다. 후속 추천 0건 — 상세 `docs/worklog/2026-09-24-extra-bytes-name-their-offset.{md,json}`. ## [2026-09-24] GC 가 호스트 오류에 패닉하지 않고 `Err` 를 돌려준다 (wie-2026-09-22-lgt-object-reference-gate-adopt-p0) - 무엇을: `Jvm::collect_garbage` 의 도달성 순회가 `get_field`·`get_static_field`·배열 `load` 오류를 `.unwrap()` 대신 호출자에게 돌려준다. 내부 불변식 두 곳은 `JavaError::Unraisable` 로 대상을 이름으로 말한다. 오류가 나면 아무것도 해제하지 않는다. - 왜: wie 제안 `2026-09-22-lgt-object-reference-gate#p0` 의 나머지 절반이다(변종은 #94 에서 끝났다). 변종만 있으면 호스트가 올린 오류가 GC 경로에서 다시 패닉이 된다. diff --git a/STATE.md b/STATE.md index 60218d16..4fe092b7 100644 --- a/STATE.md +++ b/STATE.md @@ -7,6 +7,7 @@ (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 +- [rustjava-2026-09-23-validation-rules-name-their-position-adopt-p0] parse-level 거부 3종 판정: extra bytes 만 정확한 위치 → `Location::ByteOffset(u32)` · truncated/unparsable 은 nom 오프셋이 1바이트 변이의 약 절반에서 손상 지점 ±8B 밖이라 유지 · version<45 는 위치 없음. `ClassFileError` 24→32B(크기 잠금 갱신). 변이 3종 red. 채택 `2026-09-23-validation-rules-name-their-position#p0`. 상세 `docs/worklog/2026-09-24-extra-bytes-name-their-offset.md`. - [wie-2026-09-22-lgt-object-reference-gate-adopt-p0] GC 도달성 순회 `Result` 전파 — `get_field`·`get_static_field`·`load` unwrap 3곳 → `?`(호스트 오류 그대로) · 불변식 unwrap 2곳 → `Unraisable` · 오류 시 중단(해제 0). 새 변종 0 · 공개 서명 변경 0. 양방향: 새 시험 ok ↔ unwrap 복원 시 panic FAILED. 채택 `2026-09-22-lgt-object-reference-gate#p0` · `2026-09-24-unraisable-error-variant#p0`. wie 효력은 crates.io 릴리스 뒤. 상세 `docs/worklog/2026-09-24-gc-walk-propagates-host-errors.md`. - [rustjava-2026-09-20-name-the-missing-bootstrap-class-adopt-p0] ★**공개 API 변경(breaking)**: `JavaError::Unraisable(String)` 신설 — Java 예외로 만들 수 없는 실패. `Jvm::new` 패닉 2곳 → `Err(Unraisable)`(빠진 클래스 이름 포함) · `Jvm::exception` 재귀 바닥(같은 스레드에서 «같은 예외»를 다시 만들거나 깊이 8 → `Unraisable`, 첫 실패 명명) · `[Ljava/lang/String;` 숨김 재현이 stack overflow → loader 질문 2회. ★외부 소비자: `let JavaError::JavaException(..) = ..` irrefutable 구조분해가 컴파일 에러가 된다(이 repo 3곳 수정 · wie 는 crates.io 0.1.1 소비라 판올림 때 발생). 채택 `2026-09-20-name-the-missing-bootstrap-class#p0` · `2026-09-20-string-array-hiding-overflows-stack#p0`. 상세 `docs/worklog/2026-09-24-unraisable-error-variant.md`. 보정(-fix): `stream_handler.rs` `publish` 의 `if let Err(JavaException)` 2곳 → `match` + `Unraisable` 전파(삼킴 제거) · 회귀 `stream_handler_propagates_unraisable_output_failures`. diff --git a/classfile/src/class.rs b/classfile/src/class.rs index e30e36b8..0b58e1ce 100644 --- a/classfile/src/class.rs +++ b/classfile/src/class.rs @@ -10,8 +10,8 @@ use nom::{ use jvm_types::ClassAccessFlags; use crate::{ - ClassFileError, attribute::AttributeInfo, constant_pool::ConstantPoolItem, field::FieldInfo, interface::parse_interface, method::MethodInfo, - validation::validate_class, + ClassFileError, Location, attribute::AttributeInfo, constant_pool::ConstantPoolItem, field::FieldInfo, interface::parse_interface, + method::MethodInfo, validation::validate_class, }; fn parse_this_class<'a>(data: &'a [u8], constant_pool: &BTreeMap) -> IResult<&'a [u8], Arc> { @@ -102,7 +102,14 @@ impl ClassInfo { pub fn parse(file: &[u8]) -> Result { let (remaining, result) = Self::parse_info(file).map_err(|_| ClassFileError::InvalidFormat("truncated or unparsable class file"))?; if !remaining.is_empty() { - return Err(ClassFileError::InvalidFormat("extra bytes after the end of the class file")); + let cause = "extra bytes after the end of the class file"; + // A file past 4 GiB cannot name its offset in a `u32`; say the sentence without it rather than a wrong number. + return Err( + u32::try_from(file.len() - remaining.len()).map_or(ClassFileError::InvalidFormat(cause), |offset| ClassFileError::InvalidFormatAt { + cause, + location: Location::ByteOffset(offset), + }), + ); } if result.major_version < 45 { return Err(ClassFileError::InvalidFormat("class file version predates 45.0")); diff --git a/classfile/src/error.rs b/classfile/src/error.rs index 7f9da389..1120e41a 100644 --- a/classfile/src/error.rs +++ b/classfile/src/error.rs @@ -59,6 +59,13 @@ pub enum Location { Field(u16), Method(u16), ClassAttribute(u16), + /// A byte offset into the file itself — not a table index, unlike the five above. Only the + /// "extra bytes" refusal uses it: the offset where the class ends and the extra bytes begin, + /// which is exact. "truncated or unparsable" was measured and deliberately left without one: + /// nom's position is where the parser gave up, which for a damaged byte fell more than 8 bytes + /// away from the damage in roughly half of single-byte mutations — a number that points at the + /// wrong place half the time is worse than no number. + ByteOffset(u32), } impl core::fmt::Display for Location { @@ -69,6 +76,7 @@ impl core::fmt::Display for Location { Self::Field(index) => write!(f, "field #{index}"), Self::Method(index) => write!(f, "method #{index}"), Self::ClassAttribute(index) => write!(f, "class attribute #{index}"), + Self::ByteOffset(offset) => write!(f, "at byte offset {offset}"), } } } diff --git a/classfile/tests/test.rs b/classfile/tests/test.rs index 3437b9c9..d9ad1493 100644 --- a/classfile/tests/test.rs +++ b/classfile/tests/test.rs @@ -161,6 +161,23 @@ fn test_malformed_class_files_return_structured_errors() { Some(ClassFileError::InvalidFormat("truncated or unparsable class file")) ); + // The position is where the class ends, i.e. the original length — not the file's end, and + // not zero, so a report of either would fail here. + let mut extra_bytes = hello.to_vec(); + extra_bytes.extend_from_slice(&[0, 0, 0]); + let refusal = ClassInfo::parse(&extra_bytes).err(); + assert_eq!( + refusal, + Some(ClassFileError::InvalidFormatAt { + cause: "extra bytes after the end of the class file", + location: Location::ByteOffset(hello.len() as u32), + }) + ); + assert_eq!( + Location::ByteOffset(hello.len() as u32).to_string(), + format!("at byte offset {}", hello.len()) + ); + let mut unsupported_version = hello.to_vec(); unsupported_version[6..8].copy_from_slice(&71u16.to_be_bytes()); assert_eq!(ClassInfo::parse(&unsupported_version).err(), Some(ClassFileError::UnsupportedVersion(71))); @@ -461,12 +478,16 @@ fn test_a_bootstrap_argument_naming_nothing_reports_no_kind() { ); } -/// The cost of the variant, measured rather than asserted in prose: it stays `Copy` and the enum -/// does not grow, because two `u16`s and a `&'static str` fit in the space `InvalidFormat` already -/// needed for its string. +/// The cost of the variant, measured rather than asserted in prose: it stays `Copy`, and the size is +/// pinned so any growth is a decision rather than a side effect. +/// +/// ★ It grew once, on purpose: 24 → 32 bytes (64-bit) when `Location::ByteOffset(u32)` arrived — a +/// `u32` is the first payload that does not fit beside the `&'static str` the way two `u16`s did. +/// Accepted because the error path is cold and `Result` is dominated by `ClassInfo`; +/// splitting the offset into `[u16; 2]` kept 24 but put an awkward type in the public API. #[test] fn test_the_structured_variant_does_not_grow_the_error_type() { - assert_eq!(size_of::(), size_of::<&'static str>() + size_of::()); + assert_eq!(size_of::(), size_of::<&'static str>() + 2 * size_of::()); fn assert_copy() {} assert_copy::(); } diff --git a/docs/worklog/2026-09-24-extra-bytes-name-their-offset.json b/docs/worklog/2026-09-24-extra-bytes-name-their-offset.json new file mode 100644 index 00000000..12347a87 --- /dev/null +++ b/docs/worklog/2026-09-24-extra-bytes-name-their-offset.json @@ -0,0 +1,13 @@ +{ + "date": "2026-09-24", + "taskId": "rustjava-2026-09-23-validation-rules-name-their-position-adopt-p0", + "summary": "Of class.rs's three parse-level refusals, only 'extra bytes' holds an exact position (where the class ended); it now carries Location::ByteOffset. 'truncated or unparsable' was measured and left without one — its nom offset missed the damaged byte by more than 8 bytes in about half of single-byte mutations.", + "changes": [ + "classfile/src/error.rs: Location::ByteOffset(u32), Display 'at byte offset N'", + "classfile/src/class.rs: extra-bytes refusal -> InvalidFormatAt { ByteOffset }", + "classfile/tests/test.rs: extra-bytes assertion; size lock 24 -> 32 bytes with reason" + ], + "verification": "cargo test green; mutants (offset=file.len, offset=0, old class.rs) red", + "adoptedProposals": ["2026-09-23-validation-rules-name-their-position#p0"], + "proposals": [] +} diff --git a/docs/worklog/2026-09-24-extra-bytes-name-their-offset.md b/docs/worklog/2026-09-24-extra-bytes-name-their-offset.md new file mode 100644 index 00000000..fcb1783c --- /dev/null +++ b/docs/worklog/2026-09-24-extra-bytes-name-their-offset.md @@ -0,0 +1,22 @@ +# 2026-09-24 — «extra bytes» 거부가 클래스가 끝난 바이트 위치를 말한다 + +티켓 `rustjava-2026-09-23-validation-rules-name-their-position-adopt-p0` · 채택 `2026-09-23-validation-rules-name-their-position#p0`(원 worklog 무접촉). + +## 판정 — `ClassInfo::parse` 의 parse-level 거부 3종 + +| 거부 | 위치를 쥐나 | 근거(실측) | 처분 | +|---|---|---|---| +| truncated or unparsable | 쥐지만 **믿을 수 없다** | nom 오류의 `input` 은 파일의 부분 슬라이스라 오프셋 복원은 된다. 그러나 1바이트 변이(값 0/0xff/0x01 × 전 위치)에서 오프셋이 손상 바이트 ±8B 안에 든 것은 Hello **358/639**, StringConcat **535/1151** — 절반 가까이가 엉뚱한 곳을 가리킨다(예: 풀 개수 바이트 8 손상 → 302 보고). 오류 지점마다 뜻도 다르다: `MapRes` = 감싼 구조의 시작(속성 본문 오류는 속성 시작으로 접힌다) · `Verify`/`Switch` = 필드를 읽은 «뒤». 절단은 Eof 로 전건(417/417 · 848/848) 오프셋 ≤ 절단점이지만 절단점과 8B 넘게 떨어진 경우가 121/417 · 388/848(최대 151B — 속성 본문 `take`). | **위치 없음 유지** | +| extra bytes after the end | ★**정확히 쥔다** — 제안의 「가리킬 자리 없음」은 반증 | `file.len() - remaining.len()` = 파서가 클래스를 끝낸 바이트. 뜻이 하나다. | **`Location::ByteOffset(u32)` 로 싣는다** | +| version predates 45.0 | 없다 | 버전은 늘 바이트 6..8 고정 — 위치는 정보가 아니다(값이 정보이고 그건 이미 문장이 말한다). | 유지 | + +## 변경 +- `classfile/src/error.rs`: `Location::ByteOffset(u32)` — 여섯째 종류이자 «표 인덱스가 아닌» 유일한 종류. Display `at byte offset N`. +- `classfile/src/class.rs`: extra-bytes 거부 → `InvalidFormatAt { cause(불변), ByteOffset }`. 4 GiB 초과 파일은 `u32` 에 못 담아 종전 `InvalidFormat` 로 떨어진다(틀린 수 대신 수 없음). +- 문면: 전 `extra bytes after the end of the class file` → 후 `extra bytes after the end of the class file (at byte offset 417)`(Hello.class + 3바이트 · `located_message` 경유). +- ★대가: `ClassFileError` 24 → **32 바이트**(64-bit). `u32` 는 `&'static str` 옆에 두 `u16` 처럼 들어가지 않는다. 크기 잠금 시험을 새 값으로 갱신하고 이유를 적었다. `[u16; 2]` 로 쪼개면 24 에 맞지만 공개 API 에 어색한 타입을 둔다 — 기각. +- 공개 API: `Location` 에 변종 추가. `Location` 은 2026-09-23(`c40db08e`)에 생겼고 태그·릴리스 전이라 외부 파괴 0. + +## 검증 +- 새 단언: Hello.class + `[0,0,0]` → `ByteOffset(417)` · Display 문자열. +- 변이: M1 오프셋=`file.len()` · M2 오프셋=0 · M3 종전 `class.rs` — **전건 red** ↔ 변경 green.