labels: harden class_reader/jar as first-class .class format readers

Treat Java bytecode as a documented format we READ (never execute), a
peer of mpls/clpi. The class reader's bounds checking was already sound
(typed Reader with checked u8/u16/u32/u64 + slice, saturating switch
arithmetic, iterator halt-on-truncation); this adds the untrusted-input
entry points the never-panic harness was missing and the malformed-input
regression tests the format warranted.

- harness: add class_reader (magic CAFEBABE) and jar (zip PK header)
  sweep targets. The class_reader target also drives the accessors a
  label parser exercises (this_class_name, member Code walk, cp_index,
  member_ref, load_constant_display) so their indexing is fuzzed, not
  just the structural parse. The jar target opens the sweep bytes as a
  zip and walks every .class through for_each_class exactly as deluxe/dbp
  do.
- class_reader tests: oversized constant_pool_count and interfaces_count
  reject with UnexpectedEof (u16 counts keep with_capacity bounded);
  out-of-range cp indices return None from every accessor; a Code
  attribute whose declared code_length runs past its body is rejected.
This commit is contained in:
Matthew Jackson
2026-08-25 12:25:19 -07:00
parent f75766b4d5
commit f8e39abe54
2 changed files with 115 additions and 0 deletions
+44
View File
@@ -189,6 +189,50 @@ fn udf_name_parse_never_panics() {
});
}
#[test]
fn class_reader_parse_never_panics() {
// Java `.class` files are a documented format we READ (never execute) to
// recover BD-J stream labels — a peer of mpls/clpi here. Every count,
// length and constant-pool index in the file is attacker-controlled disc
// metadata, so the parser must reject malformed input with `Err`, never
// panic. Magic is the class-file magic `0xCAFEBABE`.
sweep("class_reader", &[0xCA, 0xFE, 0xBA, 0xBE], |b| {
if let Ok(class) = crate::labels::class_reader::ClassFile::parse(b) {
// Exercise the accessors a label parser drives, so their indexing
// is fuzzed too, not just the structural parse.
let _ = class.this_class_name();
let _ = class.super_class_name();
for m in &class.methods {
let _ = class.member_name(m);
if let Some(code) = m.code(&class.constant_pool) {
for insn in code.instructions() {
let _ = insn.cp_index();
if let Some(idx) = insn.cp_index() {
let _ = class.constant_pool.get(idx);
let _ = class.constant_pool.member_ref(idx);
let _ = class.constant_pool.load_constant_display(idx);
}
}
}
}
}
});
}
#[test]
fn jar_class_walk_never_panics() {
// The jar layer opens a `/BDMV/JAR/<x>.jar` (a zip) and walks every
// `.class` inside it, handing each to the class reader. Feed the sweep's
// bytes as a zip archive; when they parse as one, walk the classes exactly
// as `deluxe`/`dbp` do. Neither the zip open nor the class walk may panic.
// Magic is the zip local-file-header signature `PK\x03\x04`.
sweep("jar", &[0x50, 0x4B, 0x03, 0x04], |b| {
if let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(b.to_vec())) {
crate::labels::jar::for_each_class(&mut archive, |_name, _class| {});
}
});
}
#[test]
fn ps_demuxer_feed_never_panics() {
// Stateful, unlike the others: the demuxer carries a buffer across feeds, so
+71
View File
@@ -1723,4 +1723,75 @@ mod tests {
// total = 1 + 3 + 8 (default/npairs) + 24 = 36
assert_eq!(instruction_size(&code, 0), Some(36));
}
// ── Malformed-input hardening: every count/length/index is attacker- ──────
// controlled, so an oversized or lying field must yield Err/None, never a
// panic or an unbounded allocation.
/// A `constant_pool_count` far larger than the data behind it must fail
/// cleanly (running out of bytes), not pre-allocate gigabytes or panic. The
/// count is a u16, so `Vec::with_capacity` is bounded at 65535 regardless.
#[test]
fn parse_rejects_oversized_constant_pool_count() {
let bytes = [
0xCA, 0xFE, 0xBA, 0xBE, // magic
0x00, 0x00, 0x00, 0x00, // minor / major
0xFF, 0xFF, // constant_pool_count = 65535, but no entries follow
];
assert!(matches!(
ClassFile::parse(&bytes),
Err(Error::UnexpectedEof { .. })
));
}
/// An oversized `interfaces_count` / `fields_count` with no data behind it
/// must fail on the first missing element, not panic.
#[test]
fn parse_rejects_oversized_interface_count() {
let bytes = [
0xCA, 0xFE, 0xBA, 0xBE, // magic
0x00, 0x00, 0x00, 0x00, // minor / major
0x00, 0x01, // constant_pool_count = 1 (no entries)
0x00, 0x00, // access_flags
0x00, 0x00, // this_class
0x00, 0x00, // super_class
0xFF, 0xFF, // interfaces_count = 65535, none follow
];
assert!(matches!(
ClassFile::parse(&bytes),
Err(Error::UnexpectedEof { .. })
));
}
/// Out-of-range constant-pool indices resolve to `None` from every
/// accessor a label parser drives — no slice panic.
#[test]
fn constant_pool_out_of_range_index_is_none_everywhere() {
let pool = sample_pool();
let oob = 9999u16;
assert!(pool.get(oob).is_none());
assert!(pool.utf8(oob).is_none());
assert!(pool.class_name(oob).is_none());
assert!(pool.string(oob).is_none());
assert!(pool.integer(oob).is_none());
assert!(pool.member_ref(oob).is_none());
assert!(pool.load_constant_display(oob).is_none());
}
/// A `Code` attribute whose declared `code_length` runs past the attribute
/// body must be rejected, not read out of bounds.
#[test]
fn code_attribute_rejects_length_past_its_body() {
// max_stack(2) + max_locals(2) + code_length(4) then a code_length that
// exceeds the remaining bytes.
let mut info = Vec::new();
info.extend_from_slice(&0u16.to_be_bytes()); // max_stack
info.extend_from_slice(&0u16.to_be_bytes()); // max_locals
info.extend_from_slice(&0xFFFF_FFFFu32.to_be_bytes()); // code_length: absurd
info.extend_from_slice(&[0x00, 0x01]); // only 2 code bytes present
assert!(matches!(
parse_code_attribute(&info),
Err(Error::UnexpectedEof { .. })
));
}
}