Skip to content

Commit 37107f4

Browse files
authored
[rustjava-2026-09-23-validation-rules-name-their-position-adopt-p0] fix(classfile): extra-bytes 거부가 클래스가 끝난 바이트 위치를 말한다 (#96)
[rustjava-2026-09-23-validation-rules-name-their-position-adopt-p0] fix(classfile): extra-bytes 거부가 클래스가 끝난 바이트 위치를 말한다
2 parents e0e7868 + 2a4bff9 commit 37107f4

7 files changed

Lines changed: 83 additions & 7 deletions

File tree

‎REPORT.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
# REPORT
2+
## [2026-09-24] «파일 끝에 남는 바이트» 거부가 클래스가 끝난 위치를 말한다 (rustjava-2026-09-23-validation-rules-name-their-position-adopt-p0)
3+
- 무엇을: parse-level 거부 3종을 재어, 정확한 위치를 쥔 «extra bytes» 하나만 `Location::ByteOffset` 으로 오프셋을 싣는다. «truncated or unparsable» 은 nom 오프셋이 손상 지점을 절반 가까이 빗나가 두었고, «version < 45.0» 은 가리킬 자리가 없다.
4+
- 왜: 채택 제안 `2026-09-23-validation-rules-name-their-position#p0`. 제안의 예상(«truncated» 가 후보, 나머지는 없음)은 측정으로 뒤집혔다.
5+
- 사용자 영향: 덧붙은 바이트가 있는 파일의 `ClassFormatError` 가 `(at byte offset N)` 을 붙여 어디서 잘라야 할지 말한다. `ClassFileError` 가 24 → 32 바이트로 커졌다. 후속 추천 0건 — 상세 `docs/worklog/2026-09-24-extra-bytes-name-their-offset.{md,json}`.
26
## [2026-09-24] GC 가 호스트 오류에 패닉하지 않고 `Err` 를 돌려준다 (wie-2026-09-22-lgt-object-reference-gate-adopt-p0)
37
- 무엇을: `Jvm::collect_garbage` 의 도달성 순회가 `get_field`·`get_static_field`·배열 `load` 오류를 `.unwrap()` 대신 호출자에게 돌려준다. 내부 불변식 두 곳은 `JavaError::Unraisable` 로 대상을 이름으로 말한다. 오류가 나면 아무것도 해제하지 않는다.
48
- 왜: wie 제안 `2026-09-22-lgt-object-reference-gate#p0` 의 나머지 절반이다(변종은 #94 에서 끝났다). 변종만 있으면 호스트가 올린 오류가 GC 경로에서 다시 패닉이 된다.

‎STATE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
(둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다)
88

99
## 완료
10+
- [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`.
1011
- [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`.
1112
- [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`.
1213
보정(-fix): `stream_handler.rs` `publish` 의 `if let Err(JavaException)` 2곳 → `match` + `Unraisable` 전파(삼킴 제거) · 회귀 `stream_handler_propagates_unraisable_output_failures`.

‎classfile/src/class.rs‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ use nom::{
1010
use jvm_types::ClassAccessFlags;
1111

1212
use crate::{
13-
ClassFileError, attribute::AttributeInfo, constant_pool::ConstantPoolItem, field::FieldInfo, interface::parse_interface, method::MethodInfo,
14-
validation::validate_class,
13+
ClassFileError, Location, attribute::AttributeInfo, constant_pool::ConstantPoolItem, field::FieldInfo, interface::parse_interface,
14+
method::MethodInfo, validation::validate_class,
1515
};
1616

1717
fn parse_this_class<'a>(data: &'a [u8], constant_pool: &BTreeMap<u16, ConstantPoolItem>) -> IResult<&'a [u8], Arc<String>> {
@@ -102,7 +102,14 @@ impl ClassInfo {
102102
pub fn parse(file: &[u8]) -> Result<Self, ClassFileError> {
103103
let (remaining, result) = Self::parse_info(file).map_err(|_| ClassFileError::InvalidFormat("truncated or unparsable class file"))?;
104104
if !remaining.is_empty() {
105-
return Err(ClassFileError::InvalidFormat("extra bytes after the end of the class file"));
105+
let cause = "extra bytes after the end of the class file";
106+
// A file past 4 GiB cannot name its offset in a `u32`; say the sentence without it rather than a wrong number.
107+
return Err(
108+
u32::try_from(file.len() - remaining.len()).map_or(ClassFileError::InvalidFormat(cause), |offset| ClassFileError::InvalidFormatAt {
109+
cause,
110+
location: Location::ByteOffset(offset),
111+
}),
112+
);
106113
}
107114
if result.major_version < 45 {
108115
return Err(ClassFileError::InvalidFormat("class file version predates 45.0"));

‎classfile/src/error.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ pub enum Location {
5959
Field(u16),
6060
Method(u16),
6161
ClassAttribute(u16),
62+
/// A byte offset into the file itself — not a table index, unlike the five above. Only the
63+
/// "extra bytes" refusal uses it: the offset where the class ends and the extra bytes begin,
64+
/// which is exact. "truncated or unparsable" was measured and deliberately left without one:
65+
/// nom's position is where the parser gave up, which for a damaged byte fell more than 8 bytes
66+
/// away from the damage in roughly half of single-byte mutations — a number that points at the
67+
/// wrong place half the time is worse than no number.
68+
ByteOffset(u32),
6269
}
6370

6471
impl core::fmt::Display for Location {
@@ -69,6 +76,7 @@ impl core::fmt::Display for Location {
6976
Self::Field(index) => write!(f, "field #{index}"),
7077
Self::Method(index) => write!(f, "method #{index}"),
7178
Self::ClassAttribute(index) => write!(f, "class attribute #{index}"),
79+
Self::ByteOffset(offset) => write!(f, "at byte offset {offset}"),
7280
}
7381
}
7482
}

‎classfile/tests/test.rs‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,23 @@ fn test_malformed_class_files_return_structured_errors() {
161161
Some(ClassFileError::InvalidFormat("truncated or unparsable class file"))
162162
);
163163

164+
// The position is where the class ends, i.e. the original length — not the file's end, and
165+
// not zero, so a report of either would fail here.
166+
let mut extra_bytes = hello.to_vec();
167+
extra_bytes.extend_from_slice(&[0, 0, 0]);
168+
let refusal = ClassInfo::parse(&extra_bytes).err();
169+
assert_eq!(
170+
refusal,
171+
Some(ClassFileError::InvalidFormatAt {
172+
cause: "extra bytes after the end of the class file",
173+
location: Location::ByteOffset(hello.len() as u32),
174+
})
175+
);
176+
assert_eq!(
177+
Location::ByteOffset(hello.len() as u32).to_string(),
178+
format!("at byte offset {}", hello.len())
179+
);
180+
164181
let mut unsupported_version = hello.to_vec();
165182
unsupported_version[6..8].copy_from_slice(&71u16.to_be_bytes());
166183
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() {
461478
);
462479
}
463480

464-
/// The cost of the variant, measured rather than asserted in prose: it stays `Copy` and the enum
465-
/// does not grow, because two `u16`s and a `&'static str` fit in the space `InvalidFormat` already
466-
/// needed for its string.
481+
/// The cost of the variant, measured rather than asserted in prose: it stays `Copy`, and the size is
482+
/// pinned so any growth is a decision rather than a side effect.
483+
///
484+
/// ★ It grew once, on purpose: 24 → 32 bytes (64-bit) when `Location::ByteOffset(u32)` arrived — a
485+
/// `u32` is the first payload that does not fit beside the `&'static str` the way two `u16`s did.
486+
/// Accepted because the error path is cold and `Result<ClassInfo, _>` is dominated by `ClassInfo`;
487+
/// splitting the offset into `[u16; 2]` kept 24 but put an awkward type in the public API.
467488
#[test]
468489
fn test_the_structured_variant_does_not_grow_the_error_type() {
469-
assert_eq!(size_of::<ClassFileError>(), size_of::<&'static str>() + size_of::<usize>());
490+
assert_eq!(size_of::<ClassFileError>(), size_of::<&'static str>() + 2 * size_of::<usize>());
470491
fn assert_copy<T: Copy>() {}
471492
assert_copy::<ClassFileError>();
472493
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"date": "2026-09-24",
3+
"taskId": "rustjava-2026-09-23-validation-rules-name-their-position-adopt-p0",
4+
"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.",
5+
"changes": [
6+
"classfile/src/error.rs: Location::ByteOffset(u32), Display 'at byte offset N'",
7+
"classfile/src/class.rs: extra-bytes refusal -> InvalidFormatAt { ByteOffset }",
8+
"classfile/tests/test.rs: extra-bytes assertion; size lock 24 -> 32 bytes with reason"
9+
],
10+
"verification": "cargo test green; mutants (offset=file.len, offset=0, old class.rs) red",
11+
"adoptedProposals": ["2026-09-23-validation-rules-name-their-position#p0"],
12+
"proposals": []
13+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# 2026-09-24 — «extra bytes» 거부가 클래스가 끝난 바이트 위치를 말한다
2+
3+
티켓 `rustjava-2026-09-23-validation-rules-name-their-position-adopt-p0` · 채택 `2026-09-23-validation-rules-name-their-position#p0`(원 worklog 무접촉).
4+
5+
## 판정 — `ClassInfo::parse` 의 parse-level 거부 3종
6+
7+
| 거부 | 위치를 쥐나 | 근거(실측) | 처분 |
8+
|---|---|---|---|
9+
| 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`). | **위치 없음 유지** |
10+
| extra bytes after the end | ★**정확히 쥔다** — 제안의 「가리킬 자리 없음」은 반증 | `file.len() - remaining.len()` = 파서가 클래스를 끝낸 바이트. 뜻이 하나다. | **`Location::ByteOffset(u32)` 로 싣는다** |
11+
| version predates 45.0 | 없다 | 버전은 늘 바이트 6..8 고정 — 위치는 정보가 아니다(값이 정보이고 그건 이미 문장이 말한다). | 유지 |
12+
13+
## 변경
14+
- `classfile/src/error.rs`: `Location::ByteOffset(u32)` — 여섯째 종류이자 «표 인덱스가 아닌» 유일한 종류. Display `at byte offset N`.
15+
- `classfile/src/class.rs`: extra-bytes 거부 → `InvalidFormatAt { cause(불변), ByteOffset }`. 4 GiB 초과 파일은 `u32` 에 못 담아 종전 `InvalidFormat` 로 떨어진다(틀린 수 대신 수 없음).
16+
- 문면: 전 `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` 경유).
17+
- ★대가: `ClassFileError` 24 → **32 바이트**(64-bit). `u32` 는 `&'static str` 옆에 두 `u16` 처럼 들어가지 않는다. 크기 잠금 시험을 새 값으로 갱신하고 이유를 적었다. `[u16; 2]` 로 쪼개면 24 에 맞지만 공개 API 에 어색한 타입을 둔다 — 기각.
18+
- 공개 API: `Location` 에 변종 추가. `Location` 은 2026-09-23(`c40db08e`)에 생겼고 태그·릴리스 전이라 외부 파괴 0.
19+
20+
## 검증
21+
- 새 단언: Hello.class + `[0,0,0]` → `ByteOffset(417)` · Display 문자열.
22+
- 변이: M1 오프셋=`file.len()` · M2 오프셋=0 · M3 종전 `class.rs` — **전건 red** ↔ 변경 green.

0 commit comments

Comments
 (0)