Skip to content

Commit 976acae

Browse files
author
jun0
committed
[rustjava-adopt-classfile-error-cause-decision-p0] fix(classfile): 거부 사유를 세 층에 꿴다 — validate_class 를 규칙마다 쪼갠다
1 parent d505230 commit 976acae

12 files changed

Lines changed: 285 additions & 56 deletions

File tree

‎REPORT.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
# REPORT
2+
## [2026-09-18] 거부된 클래스 파일이 «왜»를 말한다 — 세 층을 관통하는 사유 (rustjava-adopt-classfile-error-cause-decision-p0)
3+
- 무엇을: 채택 제안 `2026-09-17-classfile-error-cause-decision#p0`. ★**제품 동작 변경 있음** — `ClassFormatError` 메시지가 **모든 거부에 같던 「Invalid class file」** 에서 **사유별 문장**으로 바뀐다.
4+
- ★**제안이 스스로 all-or-nothing 이라 못박았다** — 타입만 고치면 **관측되는 것이 없고**, `||` 사슬을 안 쪼개면 **평평함이 사라지는 게 아니라 옮겨갈 뿐**이다. 넷 다 했다:
5+
`ClassFileError::InvalidFormat(&'static str)` → `ClassDefinitionError::InvalidClassFile(&'static str)`(★`From` 이 **버리던** 자리) → 두 경계 자리 모두 사유를 그대로 던진다 · `validate_class` 의 **8항 `||` 사슬 → 규칙마다 `if` 하나**(사유 **13개** · 필드 `ConstantValue` 는 「몇 개냐」와 「타입이 맞냐」가 **한 조건에 묶여** 있어 갈랐다).
6+
- ★**왜 «변형»이 아니라 «문자열»인가**: 집합이 **열려 있고**(규칙마다 하나) **아무도 분기하지 않는다**. 선례도 있다 — `ClassDefinitionError::UnsupportedFeature(&'static str)`.
7+
- ★★**사유를 꿰자마자 «평평한 오류가 가리고 있던 것 둘»이 나왔다**:
8+
⑴**테스트가 «어느 층이 거부하는지»를 틀리게 믿고 있었다** — 「인덱스가 엉뚱한 종류를 가리킨다」는 **검증**이 아니라 ★**파서**가 거부한다(`truncated or unparsable class file`). ★**코드를 추측에 맞추지 않고 단언을 실측에 맞췄다**(주석에 「measured, not assumed」).
9+
⑵**술어 이름이 낡아 있었다** — `bootstrap_method_static_arguments_are_in_the_pool` 은 이름과 달리 **「적재 가능 상수인가」까지** 요구한다(직전 회차가 넓혔고 자기 docstring 이 그렇게 적는다). 사유는 **규칙 그대로** 적고 ★**함수 이름은 바꾸지 않았다**(리팩터 = 범위 밖).
10+
- ★★**양방향 — 세 층 «전부»에 개악**: **M1** `src/runtime.rs` 가 다시 문자열을 박는다 → red · **M2** `From` 이 다시 사유를 버린다(제안이 지목한 그 버그) → red · **M3** 두 사유를 한 문자열로 접는다 → ★**dedup 단언이 잡는다**(이게 없으면 「전부 같은 문자열로 되돌려도 통과」가 된다) · 복원 **17/0**.
11+
- ★★**대가 — 실측한 구멍 하나를 포함해 적는다**: ⒜★**마지막 홉이 «두 번» 쓰여 있고 한 쪽만 테스트가 본다** — `test-utils/src/lib.rs` 사본만 개악하면 `cargo test --all` 이 **579 passed / 0 failed**(아무것도 안 운다). ★**합치는 것은 리팩터라 하지 않았고 구멍을 보고한다.** ⒝사유가 문자열이라 **두 규칙에 같은 문구**를 주는 것을 막는 것이 없다(dedup 단언은 세 픽스처만 덮는다) ⒞★**픽스처 규율을 대체하지 않는다**(제안이 이미 적었다) ⒟★**PR #66 과 같은 함수를 만진다** — 뒤에 착지하는 쪽이 base 를 당겨 그 항을 다시 쪼갠다(충돌은 실재하나 **기계적**).
12+
- 검증: `cargo test --all` **578 → 579 / 0 failed / 1 ignored** · `classfile` **15+13/0** · DoD 7명령 rc=0.
13+
214
## [2026-09-17] ldc 픽스처를 ASM 으로 재생성하자 — ★**기각**(rustjava-adopt-ldc-tags-real-world-generator-survey-p0)
315
- 무엇을: 운영자가 tower 에서 채택한 제안 `2026-09-16-ldc-tags-real-world-generator-survey#p0` 의 처분. ★**제품 코드 0줄** — 산출물은 «판정과 그 근거»다.
416
- ★**기각 사유 ⑴ 제안이 사려는 것이 이미 있다 — 그것도 더 센 오라클로**: ★**OpenJDK 26.0.1 이 양성 픽스처 4건을 «실행»한다**(`LdcMethodHandle`·`LdcMethodType`·`LdcDynamic`·`Ldc2WDynamic` **전건 rc=0**) ⇒ 진짜 JVM 의 **검증기**가 우리 손조립 바이트를 받아들인다. 대조군(위법 5건)은 **전건 rc=1**. 그리고 「ASM 이 이 태그를 내는가」는 **선행 조사 회차가 이미 동적으로 실증**했다.

‎STATE.md‎

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

99
## 완료
10+
- [rustjava-adopt-classfile-error-cause-decision-p0] ★★**거부 사유를 세 층에 꿴다 — 「Invalid class file」 하나가 13개 문장이 된다.** 채택 제안 `2026-09-17-classfile-error-cause-decision#p0`. ★**제품 동작 변경 있음**(사용자가 보는 `ClassFormatError` 메시지).
11+
★제안이 **all-or-nothing** 이라 못박은 넷을 다 했다: `InvalidFormat(&'static str)` · `InvalidClassFile(&'static str)`(★`From` 이 **버리던** 자리) · 경계 2자리 · ★**`validate_class` 8항 `||` → 규칙마다 `if`**.
12+
★★**사유를 꿰자 «평평한 오류가 가리던 것 둘»이 나왔다**: ⑴테스트가 **어느 층이 거부하는지를 틀리게 믿었다**(검증 아닌 **파서**) ⇒ ★단언을 실측에 맞췄다 ⑵술어 **이름이 낡아 있었다**(「in_the_pool」인데 **적재 가능성까지** 본다) ⇒ 사유는 규칙대로, ★**이름은 안 바꿨다**(리팩터 금지).
13+
★**양방향 — 세 층 전부 개악**: M1 경계 · M2 `From` 이 사유 버림 · M3 두 사유를 한 문자열로 접음(★dedup 단언이 잡는다) · 복원 17/0.
14+
★★**대가**: ★**마지막 홉이 두 번 쓰여 있고 `test-utils` 사본은 «무검증»**(개악해도 579/0 · **합치지 않고 보고**) · 사유가 문자열이라 같은 문구 중복을 막는 것이 없다 · ★**PR #66 과 같은 함수**(충돌은 기계적).
15+
★`--all` **578 → 579/0/1** · DoD 7명령 rc=0.
1016
- [rustjava-adopt-ldc-tags-real-world-generator-survey-p0] ★★**「ldc 픽스처를 ASM 으로 재생성」 제안 — 기각.** ★**제품 코드 0줄**(`declinedProposals` 기록).
1117
★**사유 ⑴ 더 센 오라클이 이미 있다** — ★**OpenJDK 26.0.1 이 양성 4건을 실행한다(전건 rc=0)** · 위법 5건은 전건 rc=1 · 「ASM 이 낸다」는 선행 회차가 이미 동적 실증.
1218
★**⑵ 손의 흔적은 «옮겨갈» 뿐이다**(ASM 도 드라이버 15줄이 모양을 고른다) ★**⑶ 생성기가 «두 기구»가 된다**(음성 픽스처는 ASM 불가) ·

‎classfile/src/class.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,12 @@ impl ClassInfo {
100100
}
101101

102102
pub fn parse(file: &[u8]) -> Result<Self, ClassFileError> {
103-
let (remaining, result) = Self::parse_info(file).map_err(|_| ClassFileError::InvalidFormat)?;
103+
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);
105+
return Err(ClassFileError::InvalidFormat("extra bytes after the end of the class file"));
106106
}
107107
if result.major_version < 45 {
108-
return Err(ClassFileError::InvalidFormat);
108+
return Err(ClassFileError::InvalidFormat("class file version predates 45.0"));
109109
}
110110
if result.major_version > 70 {
111111
return Err(ClassFileError::UnsupportedVersion(result.major_version));

‎classfile/src/error.rs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1+
/// Why a class file was refused.
2+
///
3+
/// `InvalidFormat` carries the cause so the rejection can say what is wrong instead of repeating
4+
/// one sentence for every reason — the shape `ClassDefinitionError::UnsupportedFeature` already
5+
/// used. A `&'static str` rather than a variant per rule: the set is open (every new rule adds
6+
/// one) and nothing branches on it, so a string is what a caller actually needs.
7+
///
8+
/// The words are the message a user sees, so they read as a JVM does — "multiple BootstrapMethods
9+
/// attributes", not "AtMostOneBootstrapMethods".
110
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211
pub enum ClassFileError {
3-
InvalidFormat,
12+
InvalidFormat(&'static str),
413
UnsupportedVersion(u16),
514
}

‎classfile/src/validation.rs‎

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,54 @@ enum MemberKind {
99
Method,
1010
}
1111

12+
/// Every rule the class file has to satisfy, each one naming itself.
13+
///
14+
/// This used to be an eight-term `||` chain feeding one `InvalidFormat`, which made the cause
15+
/// unrecoverable by construction: the caller could not tell "unknown constant pool tag" from
16+
/// "a bootstrap argument names nothing", and neither could the `ClassFormatError` a user reads.
17+
/// One `if` per rule is the cheapest thing that lets the cause differ — no dispatch, no table, and
18+
/// the reason lives next to the check it belongs to.
19+
///
20+
/// The strings are the message, so they are written the way a JVM writes one. They are not
21+
/// identifiers and nothing matches on them; tests assert them to pin *which* rule fired, which is
22+
/// the observability the flat version could not give.
1223
pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> {
13-
if !is_internal_class_name(&class.this_class)
14-
|| class.super_class.as_ref().is_some_and(|name| !is_internal_class_name(name))
15-
|| class.interfaces.iter().any(|name| !is_internal_class_name(name))
16-
|| !validate_constant_pool(&class.constant_pool)
17-
|| !constant_pool_tags_fit_the_class_file_version(class)
18-
|| !bootstrap_method_static_arguments_are_in_the_pool(class)
19-
|| !bootstrap_method_indices_resolve(class)
20-
|| !at_most_one_bootstrap_methods_attribute(class)
21-
{
22-
return Err(ClassFileError::InvalidFormat);
24+
if !is_internal_class_name(&class.this_class) {
25+
return Err(ClassFileError::InvalidFormat("this_class does not name a class"));
26+
}
27+
if class.super_class.as_ref().is_some_and(|name| !is_internal_class_name(name)) {
28+
return Err(ClassFileError::InvalidFormat("super_class does not name a class"));
29+
}
30+
if class.interfaces.iter().any(|name| !is_internal_class_name(name)) {
31+
return Err(ClassFileError::InvalidFormat("an interface entry does not name a class"));
32+
}
33+
if !validate_constant_pool(&class.constant_pool) {
34+
return Err(ClassFileError::InvalidFormat("a constant pool entry names a missing or wrong-kind entry"));
35+
}
36+
if !constant_pool_tags_fit_the_class_file_version(class) {
37+
return Err(ClassFileError::InvalidFormat(
38+
"class file version does not support a constant tag it carries",
39+
));
40+
}
41+
if !bootstrap_method_static_arguments_are_in_the_pool(class) {
42+
return Err(ClassFileError::InvalidFormat(
43+
// The rule is wider than the function name: the docstring above says the argument must also
44+
// be a loadable constant, and OpenJDK says the same ("bad constant type"). The name stayed
45+
// behind when the rule widened; renaming it is not this round's scope, so the cause is what
46+
// gets the wording right.
47+
"a bootstrap method argument names nothing or is not a loadable constant",
48+
));
49+
}
50+
if !bootstrap_method_indices_resolve(class) {
51+
return Err(ClassFileError::InvalidFormat("a dynamic constant names no bootstrap method"));
52+
}
53+
if !at_most_one_bootstrap_methods_attribute(class) {
54+
return Err(ClassFileError::InvalidFormat("multiple BootstrapMethods attributes"));
2355
}
2456

2557
for field in &class.fields {
2658
if !is_field_descriptor(&field.descriptor) {
27-
return Err(ClassFileError::InvalidFormat);
59+
return Err(ClassFileError::InvalidFormat("a field descriptor is malformed"));
2860
}
2961

3062
let constant_values = field
@@ -35,25 +67,27 @@ pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> {
3567
_ => None,
3668
})
3769
.collect::<alloc::vec::Vec<_>>();
38-
if constant_values.len() > 1
39-
|| constant_values.first().is_some_and(|value| {
40-
!matches!(
41-
(field.descriptor.as_str(), *value),
42-
("Z" | "B" | "C" | "S" | "I", ConstantPoolReference::Integer(_))
43-
| ("J", ConstantPoolReference::Long(_))
44-
| ("F", ConstantPoolReference::Float(_))
45-
| ("D", ConstantPoolReference::Double(_))
46-
| ("Ljava/lang/String;", ConstantPoolReference::String(_))
47-
)
48-
})
49-
{
50-
return Err(ClassFileError::InvalidFormat);
70+
// Two rules, not one: "how many" and "of what type". The flat version could not say which.
71+
if constant_values.len() > 1 {
72+
return Err(ClassFileError::InvalidFormat("multiple ConstantValue attributes on a field"));
73+
}
74+
if constant_values.first().is_some_and(|value| {
75+
!matches!(
76+
(field.descriptor.as_str(), *value),
77+
("Z" | "B" | "C" | "S" | "I", ConstantPoolReference::Integer(_))
78+
| ("J", ConstantPoolReference::Long(_))
79+
| ("F", ConstantPoolReference::Float(_))
80+
| ("D", ConstantPoolReference::Double(_))
81+
| ("Ljava/lang/String;", ConstantPoolReference::String(_))
82+
)
83+
}) {
84+
return Err(ClassFileError::InvalidFormat("a ConstantValue does not match its field descriptor"));
5185
}
5286
}
5387

5488
for method in &class.methods {
5589
if !is_method_descriptor(&method.descriptor) {
56-
return Err(ClassFileError::InvalidFormat);
90+
return Err(ClassFileError::InvalidFormat("a method descriptor is malformed"));
5791
}
5892

5993
let code_attributes = method
@@ -63,10 +97,10 @@ pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> {
6397
.count();
6498
if method.access_flags.intersects(MethodAccessFlags::ABSTRACT | MethodAccessFlags::NATIVE) {
6599
if code_attributes != 0 {
66-
return Err(ClassFileError::InvalidFormat);
100+
return Err(ClassFileError::InvalidFormat("an abstract or native method carries a Code attribute"));
67101
}
68102
} else if code_attributes != 1 {
69-
return Err(ClassFileError::InvalidFormat);
103+
return Err(ClassFileError::InvalidFormat("a method does not have exactly one Code attribute"));
70104
}
71105
}
72106

‎classfile/tests/test.rs‎

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -149,17 +149,26 @@ fn test_invokeinterface() {
149149
fn test_malformed_class_files_return_structured_errors() {
150150
let hello = include_bytes!("../../test-data/Hello.class");
151151

152-
assert_eq!(ClassInfo::parse(&[]).err(), Some(ClassFileError::InvalidFormat));
152+
assert_eq!(
153+
ClassInfo::parse(&[]).err(),
154+
Some(ClassFileError::InvalidFormat("truncated or unparsable class file"))
155+
);
153156

154157
let mut invalid_magic = hello.to_vec();
155158
invalid_magic[0] = 0;
156-
assert_eq!(ClassInfo::parse(&invalid_magic).err(), Some(ClassFileError::InvalidFormat));
159+
assert_eq!(
160+
ClassInfo::parse(&invalid_magic).err(),
161+
Some(ClassFileError::InvalidFormat("truncated or unparsable class file"))
162+
);
157163

158164
let mut unsupported_version = hello.to_vec();
159165
unsupported_version[6..8].copy_from_slice(&71u16.to_be_bytes());
160166
assert_eq!(ClassInfo::parse(&unsupported_version).err(), Some(ClassFileError::UnsupportedVersion(71)));
161167

162-
assert_eq!(ClassInfo::parse(&hello[..hello.len() / 2]).err(), Some(ClassFileError::InvalidFormat));
168+
assert_eq!(
169+
ClassInfo::parse(&hello[..hello.len() / 2]).err(),
170+
Some(ClassFileError::InvalidFormat("truncated or unparsable class file"))
171+
);
163172

164173
let minimal_class = vec![
165174
0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00, 0x00, 0x2d, 0x00, 0x05, 0x01, 0x00, 0x04, b'T', b'e', b's', b't', 0x07, 0x00, 0x01, 0x01, 0x00, 0x10,
@@ -170,11 +179,19 @@ fn test_malformed_class_files_return_structured_errors() {
170179

171180
let mut invalid_constant_pool_index = minimal_class.clone();
172181
invalid_constant_pool_index[44..46].copy_from_slice(&99u16.to_be_bytes());
173-
assert_eq!(ClassInfo::parse(&invalid_constant_pool_index).err(), Some(ClassFileError::InvalidFormat));
182+
assert_eq!(
183+
ClassInfo::parse(&invalid_constant_pool_index).err(),
184+
Some(ClassFileError::InvalidFormat("truncated or unparsable class file")),
185+
"an index past the end of the pool stops the parse, before validation sees it"
186+
);
174187

175188
let mut invalid_constant_pool_type = minimal_class;
176189
invalid_constant_pool_type[44..46].copy_from_slice(&1u16.to_be_bytes());
177-
assert_eq!(ClassInfo::parse(&invalid_constant_pool_type).err(), Some(ClassFileError::InvalidFormat));
190+
assert_eq!(
191+
ClassInfo::parse(&invalid_constant_pool_type).err(),
192+
Some(ClassFileError::InvalidFormat("truncated or unparsable class file")),
193+
"measured, not assumed: this one is refused by the parser, not by validation"
194+
);
178195
}
179196

180197
#[test]
@@ -183,15 +200,24 @@ fn test_class_info_validation_rejects_invalid_names_descriptors_and_code_layout(
183200

184201
let mut invalid_name = ClassInfo::parse(hello).unwrap();
185202
invalid_name.this_class = "[I".to_string().into();
186-
assert_eq!(invalid_name.validate(), Err(ClassFileError::InvalidFormat));
203+
assert_eq!(
204+
invalid_name.validate(),
205+
Err(ClassFileError::InvalidFormat("this_class does not name a class"))
206+
);
187207

188208
let mut invalid_descriptor = ClassInfo::parse(hello).unwrap();
189209
invalid_descriptor.methods[0].descriptor = "(V)V".to_string().into();
190-
assert_eq!(invalid_descriptor.validate(), Err(ClassFileError::InvalidFormat));
210+
assert_eq!(
211+
invalid_descriptor.validate(),
212+
Err(ClassFileError::InvalidFormat("a method descriptor is malformed"))
213+
);
191214

192215
let mut missing_code = ClassInfo::parse(hello).unwrap();
193216
missing_code.methods[0].attributes.clear();
194-
assert_eq!(missing_code.validate(), Err(ClassFileError::InvalidFormat));
217+
assert_eq!(
218+
missing_code.validate(),
219+
Err(ClassFileError::InvalidFormat("a method does not have exactly one Code attribute"))
220+
);
195221
}
196222

197223
#[test]
@@ -285,7 +311,7 @@ fn test_bootstrap_method_reference_kinds_outside_the_set_and_mispaired_kinds_are
285311
for reference_kind in [0u8, 10, 255] {
286312
assert_eq!(
287313
parse_with_kind(reference_kind),
288-
Some(ClassFileError::InvalidFormat),
314+
Some(ClassFileError::InvalidFormat("truncated or unparsable class file")),
289315
"reference kind {reference_kind} must not parse"
290316
);
291317
}
@@ -294,8 +320,9 @@ fn test_bootstrap_method_reference_kinds_outside_the_set_and_mispaired_kinds_are
294320
for reference_kind in [1u8, 4, 9] {
295321
assert_eq!(
296322
parse_with_kind(reference_kind),
297-
Some(ClassFileError::InvalidFormat),
298-
"reference kind {reference_kind} does not pair with a Methodref"
323+
Some(ClassFileError::InvalidFormat("a constant pool entry names a missing or wrong-kind entry")),
324+
"reference kind {reference_kind} does not pair with a Methodref — and the cause says it was validation, \
325+
not the parser, that refused it (the loop above is the parser's)"
299326
);
300327
}
301328

@@ -340,7 +367,9 @@ fn test_a_bootstrap_argument_that_is_not_a_loadable_constant_is_rejected() {
340367

341368
assert_eq!(
342369
ClassInfo::parse(&mutated).err(),
343-
Some(ClassFileError::InvalidFormat),
370+
Some(ClassFileError::InvalidFormat(
371+
"a bootstrap method argument names nothing or is not a loadable constant"
372+
)),
344373
"a bootstrap argument naming a Utf8 is not a loadable constant"
345374
);
346375
}

0 commit comments

Comments
 (0)