Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions REPORT.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# REPORT
## [2026-09-23] 클래스 파일 거부가 «어디서» 걸렸는지 말한다 — 검증 규칙 11개, 오류 변종은 1개 (rustjava-2026-09-18-bootstrap-argument-index-and-tag-adopt-p0)
- 무엇을: `validate_class` 규칙을 전수 세어(14개 · 고정문장 13개) 표를 걸으며 멈춘 위치를 이미 쥔 **11개**가 그 위치를 싣게 했다 — `ClassFileError::InvalidFormatAt { cause, location }` 하나와 표 5종(`Location`)으로.
- 왜: #73 이 부트스트랩 인자 규칙 하나를 구조화한 뒤 나머지가 몇이나 되는지 아무도 세지 않았다. 규칙마다 변종을 늘리면 `&'static str` 설계가 피하던 enum 비대가 오므로, 변종은 «규칙 수»가 아니라 «표 종류 수»로만 늘게 멈춤 기준을 먼저 세웠다.
- 사용자 영향: `ClassFormatError` 문면 끝에 `(constant pool entry #18)`·`(method #2)` 처럼 위치가 붙는다. 종전 문장은 그대로 앞에 남는다. 받아들이는/거부하는 파일은 하나도 바뀌지 않는다.
- 후속 추천: `class.rs` 의 파싱 단계 거부 3종(잘림·꼬리 바이트·45.0 미만)은 이번 범위 밖 — 그중 위치를 쥔 것이 있는지만 세어 볼 것(S). 상세 = `docs/worklog/2026-09-23-validation-rules-name-their-position.{md,json}`.
## [2026-09-23] charset 보류 판단을 `Charset` 의 exhaustive match 로 옮겼다 (rustjava-2026-09-23-stale-next-pointer-and-euc-kr-boundary-adopt-p0)
- 무엇을: `InputStreamReader::read()` 의 charset 이름 문자열 비교 2곳을 `Charset::bytes_to_hold_back` 로 옮겼다(wildcard 없는 match · 동작 불변).
- 왜: 채택 제안 `2026-09-23-stale-next-pointer-and-euc-kr-boundary#p0` — 새 charset 을 더하면 그 if 사슬은 조용히 빠졌다. 이제 컴파일이 막는다.
Expand Down
6 changes: 6 additions & 0 deletions STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
(둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다)

## 완료
- [rustjava-2026-09-18-bootstrap-argument-index-and-tag-adopt-p0] ★**검증 규칙이 멈춘 위치를 말한다 — 11규칙, 변종 1개.** 채택 제안 `2026-09-18-bootstrap-argument-index-and-tag#p0`.
★**전제 반증(작게)**: `validate_class` 규칙은 15/14 가 아니라 **14 · 고정문장 13**(@origin/main `c654ae2e`).
★**전수**: 13 중 **11**이 표를 걸으며 멈춘 위치를 쥐고 있었다(pool 3 · field 3 · method 3 · interface 1 · class attribute 1) · `this_class`/`super_class` 2개는 가리킬 곳 없음 → `InvalidFormat` 유지.
★**멈춤 기준**: enum 은 «규칙»이 아니라 «표 종류»로만 자란다 ⇒ `InvalidFormatAt { cause, location: Location }` **1변종** + `Location` 5종. `ClassFileError` 크기 불변(기존 크기 테스트 무수정 통과).
★**문면** = `<종전 문장> (<표> #<n>)` · pool 은 `javap` 의 1-기반 `#N` · 파일 바이트 0. 경계 무이동(술어 본문 불변 · `all/any` → 첫 위반 위치).
★**양방향**: 인덱스 3종 개악(M1 문면에서 위치 삭제 · M2 field/method 0 고정 · M3 pool 첫 키 보고) **전건 red** · pool 인덱스(#11·#18·#34)는 **독립 바이트 워커로 교차 확인**.
- [rustjava-2026-09-23-stale-next-pointer-and-euc-kr-boundary-adopt-p0] charset 보류 판단을 `Charset::bytes_to_hold_back`(wildcard 없는 match)로 옮김 · `read()` 이름 비교 0 · 동작 불변 · 변이 양방향 확인. 채택 `2026-09-23-stale-next-pointer-and-euc-kr-boundary#p0`.
- [rustjava-2026-09-23-stale-next-pointer-and-euc-kr-boundary-adopt-p1] `## 다음` 정본 결정 = ⒝ 얇은 층(ref + 선행 사슬 + 카드 밖 항목만 · 산문 지목 금지). ⒞ 기각 근거 = tower 술어로 열린 카드 0(32건 전건 injected). 채택 `2026-09-23-stale-next-pointer-and-euc-kr-boundary#p1`. 상세 `docs/worklog/2026-09-23-next-section-canon-decision.md`.
- [rustjava-prune-declined-followup-proposals-2026-09-21] ★**추천 후속작업 2건 기각** — 운영자 지시(2026-09-21 우선순위 정리). ★제품 코드 **0줄** · 새 제안 **0** · 검사기/CI 신설 **0**.
Expand Down
45 changes: 42 additions & 3 deletions classfile/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ pub enum ClassFileError {
/// A bootstrap method argument that is not a loadable constant, carrying the two things the
/// predicate already knows at the moment it refuses: *which* argument, and what it found there.
///
/// ★ Which variant to use: `InvalidFormat` is for a rule that has nothing to point at, and that
/// is still most of them. Use this one only when a number is already in hand — folding it into
/// prose is the thing this variant exists to stop. `UnsupportedVersion` is the same shape and
/// ★ Which variant to use: `InvalidFormat` is for a rule that has nothing to point at;
/// `InvalidFormatAt` for a rule that stopped at one position in one table; this one for the
/// bootstrap-argument rule, which holds two indices and what it found. Folding a number already
/// in hand into prose is the thing these variants exist to stop. `UnsupportedVersion` is the same shape and
/// predates it, so this is the established way here rather than a second scheme.
///
/// It stays `Copy`: two `u16`s and a `&'static str`, no owned data.
Expand All @@ -31,5 +32,43 @@ pub enum ClassFileError {
/// and the message says which.
actual: Option<&'static str>,
},
/// A rule that walks one of the class file's tables and stopped at a known position.
///
/// ★ One variant for all of them, not one per rule. Eleven of `validate_class`'s rules hold a
/// position when they refuse, and a variant each is the enum growth the `&'static str` design
/// was avoiding. What differs between them is only *which table* the number indexes, and there
/// are five tables — so the table is the enum, and the rule stays the sentence it already was.
/// `InvalidBootstrapArgument` stays separate because it carries a second index and what it found.
InvalidFormatAt {
cause: &'static str,
location: Location,
},
UnsupportedVersion(u16),
}

/// Where in the class file an `InvalidFormatAt` rule stopped.
///
/// The constant pool uses its own 1-based index — the `#N` that `javap -v` prints — because that
/// is how every tool names a pool entry. The others are zero-based positions in their table, the
/// same convention `InvalidBootstrapArgument` uses. Only an index: nothing from the file's bytes
/// (a name, a descriptor) is carried, so a hostile file cannot put text into the message.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Location {
ConstantPoolEntry(u16),
Interface(u16),
Field(u16),
Method(u16),
ClassAttribute(u16),
}

impl core::fmt::Display for Location {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::ConstantPoolEntry(index) => write!(f, "constant pool entry #{index}"),
Self::Interface(index) => write!(f, "interface #{index}"),
Self::Field(index) => write!(f, "field #{index}"),
Self::Method(index) => write!(f, "method #{index}"),
Self::ClassAttribute(index) => write!(f, "class attribute #{index}"),
}
}
}
2 changes: 1 addition & 1 deletion classfile/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub use {
attribute::{AttributeInfo, AttributeInfoCode, BootstrapMethod, MethodHandleKind, MethodHandleRef, method_type_descriptor},
class::ClassInfo,
constant_pool::{ConstantPoolReference, FieldMethodref},
error::ClassFileError,
error::{ClassFileError, Location},
field::FieldInfo,
method::MethodInfo,
opcode::{LambdaCallSite, Opcode, StringConcatCallSite},
Expand Down
86 changes: 54 additions & 32 deletions classfile/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use alloc::collections::BTreeMap;

use jvm_types::MethodAccessFlags;

use crate::{AttributeInfo, ClassFileError, ClassInfo, ConstantPoolReference, constant_pool::ConstantPoolItem};
use crate::{AttributeInfo, ClassFileError, ClassInfo, ConstantPoolReference, Location, constant_pool::ConstantPoolItem};

enum MemberKind {
Field,
Expand All @@ -20,38 +20,49 @@ enum MemberKind {
/// The strings are the message, so they are written the way a JVM writes one. They are not
/// identifiers and nothing matches on them; tests assert them to pin *which* rule fired, which is
/// the observability the flat version could not give.
///
/// A rule that walks a table reports *where* it stopped as well (`InvalidFormatAt`); the two that
/// check a single name (`this_class`, `super_class`) have nothing to point at and stay `InvalidFormat`.
pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> {
if !is_internal_class_name(&class.this_class) {
return Err(ClassFileError::InvalidFormat("this_class does not name a class"));
}
if class.super_class.as_ref().is_some_and(|name| !is_internal_class_name(name)) {
return Err(ClassFileError::InvalidFormat("super_class does not name a class"));
}
if class.interfaces.iter().any(|name| !is_internal_class_name(name)) {
return Err(ClassFileError::InvalidFormat("an interface entry does not name a class"));
if let Some(index) = class.interfaces.iter().position(|name| !is_internal_class_name(name)) {
return Err(at("an interface entry does not name a class", Location::Interface(index as u16)));
}
if !validate_constant_pool(&class.constant_pool) {
return Err(ClassFileError::InvalidFormat("a constant pool entry names a missing or wrong-kind entry"));
if let Some(index) = validate_constant_pool(&class.constant_pool) {
return Err(at(
"a constant pool entry names a missing or wrong-kind entry",
Location::ConstantPoolEntry(index),
));
}
if !constant_pool_tags_fit_the_class_file_version(class) {
return Err(ClassFileError::InvalidFormat(
if let Some(index) = constant_pool_tags_fit_the_class_file_version(class) {
return Err(at(
"class file version does not support a constant tag it carries",
Location::ConstantPoolEntry(index),
));
}
// The rule is wider than the function name: the docstring below says the argument must also be a
// loadable constant, and OpenJDK says the same ("bad constant type"). The name stayed behind when
// the rule widened; renaming it is not this round's scope.
bootstrap_method_static_arguments_are_in_the_pool(class)?;
if !bootstrap_method_indices_resolve(class) {
return Err(ClassFileError::InvalidFormat("a dynamic constant names no bootstrap method"));
if let Some(index) = bootstrap_method_indices_resolve(class) {
return Err(at("a dynamic constant names no bootstrap method", Location::ConstantPoolEntry(index)));
}
if !at_most_one_of_each_single_class_attribute(class) {
return Err(ClassFileError::InvalidFormat("a single-valued class attribute appears more than once"));
if let Some(position) = at_most_one_of_each_single_class_attribute(class) {
return Err(at(
"a single-valued class attribute appears more than once",
Location::ClassAttribute(position as u16),
));
}

for field in &class.fields {
for (index, field) in class.fields.iter().enumerate() {
let here = Location::Field(index as u16);
if !is_field_descriptor(&field.descriptor) {
return Err(ClassFileError::InvalidFormat("a field descriptor is malformed"));
return Err(at("a field descriptor is malformed", here));
}

let constant_values = field
Expand All @@ -64,7 +75,7 @@ pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> {
.collect::<alloc::vec::Vec<_>>();
// Two rules, not one: "how many" and "of what type". The flat version could not say which.
if constant_values.len() > 1 {
return Err(ClassFileError::InvalidFormat("multiple ConstantValue attributes on a field"));
return Err(at("multiple ConstantValue attributes on a field", here));
}
if constant_values.first().is_some_and(|value| {
!matches!(
Expand All @@ -76,13 +87,14 @@ pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> {
| ("Ljava/lang/String;", ConstantPoolReference::String(_))
)
}) {
return Err(ClassFileError::InvalidFormat("a ConstantValue does not match its field descriptor"));
return Err(at("a ConstantValue does not match its field descriptor", here));
}
}

for method in &class.methods {
for (index, method) in class.methods.iter().enumerate() {
let here = Location::Method(index as u16);
if !is_method_descriptor(&method.descriptor) {
return Err(ClassFileError::InvalidFormat("a method descriptor is malformed"));
return Err(at("a method descriptor is malformed", here));
}

let code_attributes = method
Expand All @@ -92,16 +104,26 @@ pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> {
.count();
if method.access_flags.intersects(MethodAccessFlags::ABSTRACT | MethodAccessFlags::NATIVE) {
if code_attributes != 0 {
return Err(ClassFileError::InvalidFormat("an abstract or native method carries a Code attribute"));
return Err(at("an abstract or native method carries a Code attribute", here));
}
} else if code_attributes != 1 {
return Err(ClassFileError::InvalidFormat("a method does not have exactly one Code attribute"));
return Err(at("a method does not have exactly one Code attribute", here));
}
}

Ok(())
}

fn at(cause: &'static str, location: Location) -> ClassFileError {
ClassFileError::InvalidFormatAt { cause, location }
}

/// The key of the first pool entry `is_valid` refuses. Pool rules are "every entry satisfies X",
/// and the entry that does not is the one to name — `all` would answer the same question and drop it.
fn first_invalid_entry(constant_pool: &BTreeMap<u16, ConstantPoolItem>, mut is_valid: impl FnMut(&ConstantPoolItem) -> bool) -> Option<u16> {
constant_pool.iter().find(|(_, item)| !is_valid(item)).map(|(index, _)| *index)
}

/// JVMS 4.4: a constant kind is legal only from the class file version that introduced it.
///
/// This lives here rather than in `validate_constant_pool` because it needs `major_version`,
Expand All @@ -115,8 +137,8 @@ pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> {
/// nothing in its place, and the class went from "corrupt" to "this runtime does not support
/// that yet" — a sentence this lineage exists to make true, applied to a file no JVM can read.
/// `test-data/ldc/LdcDynamicOldMajor.class` holds that case.
fn constant_pool_tags_fit_the_class_file_version(class: &ClassInfo) -> bool {
class.constant_pool.values().all(|item| {
fn constant_pool_tags_fit_the_class_file_version(class: &ClassInfo) -> Option<u16> {
first_invalid_entry(&class.constant_pool, |item| {
let minimum_major_version = match item {
// Java 7 (JSR 292) introduced the method handle family.
ConstantPoolItem::MethodHandle { .. } | ConstantPoolItem::MethodType { .. } | ConstantPoolItem::InvokeDynamic { .. } => 51,
Expand Down Expand Up @@ -238,13 +260,13 @@ fn constant_kind_name(item: &ConstantPoolItem) -> &'static str {
/// was `UnsupportedOperationException`, i.e. *this runtime cannot do that yet*, about a file no
/// runtime can read. That sentence is what the lineage exists to make true, so narrowing it here
/// is the point rather than a side effect.
fn bootstrap_method_indices_resolve(class: &ClassInfo) -> bool {
fn bootstrap_method_indices_resolve(class: &ClassInfo) -> Option<u16> {
let bootstrap_method_count = class.attributes.iter().find_map(|attribute| match attribute {
AttributeInfo::BootstrapMethods(methods) => Some(methods.len()),
_ => None,
});

class.constant_pool.values().all(|item| {
first_invalid_entry(&class.constant_pool, |item| {
let index = match item {
ConstantPoolItem::Dynamic {
bootstrap_method_attr_index, ..
Expand Down Expand Up @@ -297,7 +319,7 @@ fn bootstrap_method_indices_resolve(class: &ClassInfo) -> bool {
/// (`ConstantValue`, `Code`, `Exceptions`, `MethodParameters`, `StackMapTable`, `LineNumberTable`,
/// `LocalVariableTable`) are not listed even when they turn up in a class's attribute table, because
/// there they are attributes in a place they are not defined for — ignored, not counted.
fn at_most_one_of_each_single_class_attribute(class: &ClassInfo) -> bool {
fn at_most_one_of_each_single_class_attribute(class: &ClassInfo) -> Option<usize> {
// (discriminant, the class file version that introduced the attribute)
fn single_valued(attribute: &AttributeInfo) -> Option<(u8, u16)> {
Some(match attribute {
Expand All @@ -311,23 +333,23 @@ fn at_most_one_of_each_single_class_attribute(class: &ClassInfo) -> bool {
})
}

class.attributes.iter().enumerate().all(|(position, attribute)| {
let Some((kind, introduced_in)) = single_valued(attribute) else {
return true;
// The position returned is the second occurrence — the one that makes it a duplicate.
(0..class.attributes.len()).find(|&position| {
let Some((kind, introduced_in)) = single_valued(&class.attributes[position]) else {
return false;
};
if class.major_version < introduced_in {
return true;
return false;
}

// Only the first of each kind looks behind it, so one duplicate is reported once.
!class.attributes[..position]
class.attributes[..position]
.iter()
.any(|earlier| single_valued(earlier).is_some_and(|(earlier_kind, _)| earlier_kind == kind))
})
}

fn validate_constant_pool(constant_pool: &BTreeMap<u16, ConstantPoolItem>) -> bool {
constant_pool.values().all(|item| match item {
fn validate_constant_pool(constant_pool: &BTreeMap<u16, ConstantPoolItem>) -> Option<u16> {
first_invalid_entry(constant_pool, |item| match item {
ConstantPoolItem::Class { name_index } => constant_pool
.get(name_index)
.and_then(ConstantPoolItem::utf8)
Expand Down
Loading
Loading