frames,jar,passes: verifier-driven hardening from real jars

Proven by paranoid runs over lorep (17 classes, fully green),
LoVisual (4081 classes) and oc2r (1278 classes) with per-class
java -Xverify:all, each fix first reproduced as a red test:

- merge: Top absorbs instead of Top-wins (javac declares Top for
  slots unwritten on some inflow; precise types reject those edges).
  Regression: loop_head_keeps_top_for_loop_local.
- emit: collapse [Long|Double, Top] pairs on operand stacks
  (javac writes one entry; the pair is rejected). Regression:
  wide_stack_join_emits_single_entries.
- xfer ArrayLoad: push the wide pair, fixing lcmp underflow.
  Regression: array_load_wide_pushes_pair.
- xfer <init>: convert only the receiver's own allocation site
  instead of every Uninitialized (nested construction clobbered
  the outer object). Regression: nested_construction_keeps_outer.
- xfer ANewArray: array operands gain one dimension ([B] -> [[B)
  instead of an illegal L-wrap ([L[B;). Covered by the same test.
- jar load: duplicate class names (multi-release module-info
  twins) keep the first occurrence instead of crashing save.
  Test: duplicate_class_names_keep_first.
- strings/constants: skip methods past MAX_METHOD_INSNS (8000)
  so giants cannot overflow the 64KB writer ceiling. Tests:
  oversized_methods_are_skipped in both passes.
- merge errors now name both types being merged.

Also: em-dash removed repo-wide (724 spots, rule added to
todo.md), allow-lints only in tests (scratch example deleted),
code comments migrated to English in every file this change
touches.
This commit is contained in:
Loki 2026-10-09 13:47:27 +02:00
parent 2172206e83
commit 139aea9a8f
Signed by: boba
GPG key ID: 253067914055423B
96 changed files with 1053 additions and 831 deletions

View file

@ -1,6 +1,6 @@
//! Чтение и запись ZIP-архива JAR.
//!
//! Единственная задача модуля — переместить записи из/в вектор [`Entry`],
//! Единственная задача модуля - переместить записи из/в вектор [`Entry`],
//! сохранив порядок и не потеряв дубликатов молча. Всё, что связано с
//! содержимым (`Program`, маппинги, подпись), живёт в соседних модулях.
@ -30,7 +30,7 @@ pub enum JarError {
/// Одна запись архива: имя + байты.
///
/// Порядок в `[`Entry`]` — это порядок в архиве, он сохраняется при
/// Порядок в `[`Entry`]` - это порядок в архиве, он сохраняется при
/// чтении и записи. Метод сжатия не хранится: всё пишется deflate'ом,
/// что детерминированно и одинаково для одинаковых данных.
#[derive(Debug, Clone, PartialEq, Eq)]
@ -51,7 +51,7 @@ impl Entry {
/// Читает все не-каталожные записи из `reader`, сохраняя порядок.
///
/// Повторяющееся имя — ошибка, а не тихое предупреждение: битый архив
/// Повторяющееся имя - ошибка, а не тихое предупреждение: битый архив
/// с двумя `META-INF/MANIFEST.MF` собрать дороже, чем отказать.
pub fn read_entries<R: Read + Seek>(reader: R) -> Result<Vec<Entry>, JarError> {
let mut archive = zip::ZipArchive::new(reader)?;

View file

@ -4,13 +4,13 @@
//!
//! Слой разделён на три несвязанные задачи:
//!
//! * [`archive`] — чистая работа с ZIP: порядок записей, сжатие, дубликаты;
//! * [`signatures`] — срез невалидных подписей `META-INF/*.SF|RSA|DSA|EC`;
//! * [`loader`] — перевод архива в [`Program`](noxis_classfile::Program)
//! * [`archive`] - чистая работа с ZIP: порядок записей, сжатие, дубликаты;
//! * [`signatures`] - срез невалидных подписей `META-INF/*.SF|RSA|DSA|EC`;
//! * [`loader`] - перевод архива в [`Program`](noxis_classfile::Program)
//! и обратно (классы через [`format`](noxis_classfile::format), остальное
//! как ресурсы).
//!
//! Обфускация ресурсов — пасс `resources` в `noxis-passes`; сюда она не
//! Обфускация ресурсов - пасс `resources` в `noxis-passes`; сюда она не
//! входит, потому что ей нужен маппинг имён, а не I/O.
pub mod archive;

View file

@ -1,9 +1,11 @@
//! Перевод архива в [`Program`] и обратно.
//! Archive to [`Program`] and back.
//!
//! Правило простое: `*.class` уходит в парсер, всё остальное — в ресурсы.
//! Обратно классы сериализуются первыми, затем ресурсы: порядок внутри
//! архива на поведение не влияет, а фиксированный порядок даёт
//! воспроизводимую запись.
//! The rule is simple: `*.class` goes to the parser, everything else -
//! to resources. Classes serialize first, then resources: the order inside
//! the archive does not affect behaviour, and a fixed order gives
//! reproducible writes.
use std::collections::BTreeSet;
use noxis_classfile::Program;
use noxis_classfile::format;
@ -11,13 +13,22 @@ use noxis_classfile::model::Resource;
use crate::archive::{Entry, JarError};
/// Разбирает записи архива: классы — через парсер, остальное — как ресурсы.
/// Parses archive entries: classes - through the parser, the rest - as resources.
///
/// A name that repeats (multi-release jars carry the same class at the root
/// and under `META-INF/versions/*`) is kept once, first occurrence wins:
/// the flat model cannot hold two classes under one name, and later passes
/// (rename maps, jar writing) assume uniqueness.
pub fn load(entries: Vec<Entry>) -> Result<Program, JarError> {
let mut program = Program::default();
let mut seen: BTreeSet<String> = BTreeSet::new();
for entry in entries {
if is_class_entry(&entry.name) {
program.classes.push(format::parse(&entry.data)?);
let class = format::parse(&entry.data)?;
if seen.insert(class.this_class.clone()) {
program.classes.push(class);
}
} else {
program.resources.push(Resource {
path: entry.name,
@ -29,7 +40,7 @@ pub fn load(entries: Vec<Entry>) -> Result<Program, JarError> {
Ok(program)
}
/// Собирает записи архива из [`Program`]: сначала классы, потом ресурсы.
/// Assembles archive entries from [`Program`]: classes first, then resources.
pub fn save(program: &Program) -> Result<Vec<Entry>, JarError> {
let mut entries = Vec::with_capacity(program.classes.len() + program.resources.len());
@ -44,7 +55,7 @@ pub fn save(program: &Program) -> Result<Vec<Entry>, JarError> {
Ok(entries)
}
/// Классовый файл внутри архива: путь оканчивается на `.class`.
/// A class file inside the archive: the path ends in `.class`.
fn is_class_entry(name: &str) -> bool {
name.len() > ".class".len() && name.ends_with(".class")
}
@ -78,7 +89,7 @@ mod tests {
assert_eq!(save(&program).expect("save"), source);
}
/// До P1 `format::parse` — заглушка; после P1 ожидается успешный разбор.
/// Class entries go through the parser (round-trip keeps them intact).
#[test]
fn class_entries_go_through_the_parser() {
let entries = vec![
@ -97,4 +108,22 @@ mod tests {
Err(other) => panic!("unexpected error: {other}"),
}
}
/// Multi-release twins (`demo/App.class` + `META-INF/versions/9/...`):
/// the flat model keeps the first occurrence so no two classes ever
/// share one name downstream.
#[test]
fn duplicate_class_names_keep_first() {
use noxis_classfile::ClassBuilder;
let class = ClassBuilder::new("demo/App", None).build();
let bytes = format::write(&class).expect("write");
let entries = vec![
Entry::new("demo/App.class", bytes.clone()),
Entry::new("META-INF/versions/9/demo/App.class", bytes),
];
let program = load(entries).expect("load");
assert_eq!(program.classes.len(), 1);
assert_eq!(program.classes[0].this_class, "demo/App");
}
}

View file

@ -2,7 +2,7 @@
//!
//! После перепаковки архива `META-INF/*.SF` и `*.RSA|DSA|EC` описывают
//! **старые** байты: JVM либо отказывается запускать jar, либо ругается на
//! подпись. Вырезать их обязательно — и это делается на уровне записей,
//! подпись. Вырезать их обязательно - и это делается на уровне записей,
//! потому что `MANIFEST.MF` остаётся, а его секции `Name:` тоже нужно
//! выкинуть (ими занимается пасс `resources`).
@ -19,10 +19,10 @@ const META_INF: &str = "META-INF/";
/// Проверяет, является ли запись архива файлом подписи.
///
/// Совпадение — только для файлов, лежащих **непосредственно** в
/// `META-INF/`: вложенный `META-INF/certs/x.SF` — обычный ресурс.
/// Совпадение - только для файлов, лежащих **непосредственно** в
/// `META-INF/`: вложенный `META-INF/certs/x.SF` - обычный ресурс.
///
/// Регистр не важен (`meta-inf/x.sf` — тоже подпись), `MANIFEST.MF`
/// Регистр не важен (`meta-inf/x.sf` - тоже подпись), `MANIFEST.MF`
/// никогда не является подписью.
pub fn is_signature_entry(name: &str) -> bool {
let Some(relative) = name.strip_prefix(META_INF) else {