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 @@
# Стек: почему именно так
Документ фиксирует решения, которые трудно отменить. Для каждого —
Документ фиксирует решения, которые трудно отменить. Для каждого -
альтернативы, критерии, цена и что меняет решение.
## 1. Собственный IR, а не API чужой библиотеки
@ -10,12 +10,12 @@ Code, Insn}`, а не с типами парсера.
**Почему не «просто использовать готовую библиотеку целиком»:**
- библиотека диктует API — каждый пасс привязывается к чужим миграциям
- библиотека диктует API - каждый пасс привязывается к чужим миграциям
мажорной версии (наш лимит «≤4 файлов» легко разрывается «транзитивно»);
- у нас есть конкретное требование, которого нет ни в одном парсере:
`Insn::Mark(Label)` вместо смещений;
- мы не знаём заранее, захочет ли пользователь backend'а ещё одну
библиотеку (например, для `StackMapTable`) — изоляция через IR даёт
библиотеку (например, для `StackMapTable`) - изоляция через IR даёт
смену backend'а без переписывания пассов.
**Что IR даёт на практике:**
@ -25,26 +25,26 @@ Code, Insn}`, а не с типами парсера.
| метки вместо смещений | пассы никогда не пересчитывают jump-офсеты |
| `Attribute::Raw` | round-trip без потерь: неизвестный атрибут возвращается как есть |
| `Program` включает ресурсы архива | переименование и патч ресурсов в одном прогоне |
| одна точка записи | пересчёт смещений, `max_stack` и `StackMapTable` — ровно один раз |
| одна точка записи | пересчёт смещений, `max_stack` и `StackMapTable` - ровно один раз |
**Цена:** конвертация `model ↔ backend` на границах (чтение и запись) —
**Цена:** конвертация `model ↔ backend` на границах (чтение и запись) -
примерно один файл на направление. Дёшево.
## 2. Backend для чтения/записи — `ristretto_classfile`
## 2. Backend для чтения/записи - `ristretto_classfile`
**Решение:** `format::parse` / `format::write` реализуются поверх
`ristretto_classfile 0.34`, наш IR остаётся единим API для пассов.
Реализация — этап P1, до него обе функции возвращают `Unimplemented`.
Реализация - этап P1, до него обе функции возвращают `Unimplemented`.
### Кандидаты
| крейт | чтение | запись | верификация | активность | загрузки | MSRV |
|---|---|---|---|---|---|---|
| **`ristretto_classfile` 0.34** | ✓ | ✓ | ✓ (JVMS §4.10.2) | 2026-09 | 41k | **1.97.1** |
| `jclassfile` | ✓ | — (parser) | — | 2026-08 | 28k | — |
| `classfile-parser` | ✓ | — (parser) | — | 2024-10 (устарел) | 31k | — |
| `java-bytecode` | ✓ | ✓ | ? | 2026-10 | 41 | — |
| `jvm-assembler` | — | — | — | **2015, мёртв** | 3k | — |
| `jclassfile` | ✓ | - (parser) | - | 2026-08 | 28k | - |
| `classfile-parser` | ✓ | - (parser) | - | 2024-10 (устарел) | 31k | - |
| `java-bytecode` | ✓ | ✓ | ? | 2026-10 | 41 | - |
| `jvm-assembler` | - | - | - | **2015, мёртв** | 3k | - |
### Критерии и что подтвердил spike
@ -56,14 +56,14 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` (
### Что отвергнуто
- **Писать парсер с нуля** — спецификация JVMS §4 по формату классов
плюс MUTF-8, constant pool, модули, records, переполнения — это месяцы
- **Писать парсер с нуля** - спецификация JVMS §4 по формату классов
плюс MUTF-8, constant pool, модули, records, переполнения - это месяцы
работы ради того, что уже написано и покрыто тестами. Это нарушает KISS
и не добавляет ни одной защиты.
- **`classfile-parser`, `jclassfile`** — только чтение, значит writer всё
- **`classfile-parser`, `jclassfile`** - только чтение, значит writer всё
равно наш; `classfile-parser` не обновлялся с 2024-10.
- **`java-bytecode`** — 41 загрузка: у проекта нет инерции и нет экосистемы.
- **`jvm-assembler`** — последний коммит в 2015.
- **`java-bytecode`** - 41 загрузка: у проекта нет инерции и нет экосистемы.
- **`jvm-assembler`** - последний коммит в 2015.
## 3. `StackMapTable`: генерируем сами, пользуемся верификатором
@ -80,36 +80,36 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` (
→ StackMapTable → запись
```
`ristretto_classfile` содержит всё нужное **в исходном виде** — но
`ristretto_classfile` содержит всё нужное **в исходном виде** - но
снаружи, к сожалению, отдаёт не всё (см. результат спайка ниже):
- `verifiers/bytecode/inference.rs` — итеративный dataflow, LUB, фикспоинт;
- `verifiers/bytecode/control_flow.rs` — `compute_successors`, worklist;
- `verifiers/bytecode/stackmap.rs` — декодер/кэш `StackMapTable`;
- `attributes/{max_stack,max_locals,instruction/stack_effect}.rs` —
- `verifiers/bytecode/inference.rs` - итеративный dataflow, LUB, фикспоинт;
- `verifiers/bytecode/control_flow.rs` - `compute_successors`, worklist;
- `verifiers/bytecode/stackmap.rs` - декодер/кэш `StackMapTable`;
- `attributes/{max_stack,max_locals,instruction/stack_effect}.rs` -
пересчёт `max_stack`/`max_locals` из инструкций;
- `attributes/{stack_frame,verification_type}.rs` — типы для записи.
- `attributes/{stack_frame,verification_type}.rs` - типы для записи.
**Почему это лучше, чем свои кадры:** это ровно та часть JVMS, в которой
ошибка невидима в исходниках и всплывает только на `VerifyError` в чужом
рантайме. Писать такой верификатор — самая дорогая и самая опасная часть
рантайме. Писать такой верификатор - самая дорогая и самая опасная часть
всего проекта.
**Альтернативы, которые отвергнуты:**
- **Понижать версию класса до 49 и выкинуть `StackMapTable`** — ломает
- **Понижать версию класса до 49 и выкинуть `StackMapTable`** - ломает
`invokedynamic`/лямбды и включает дорогой fallback-верификатор; на Java 9+
это заметно и противоречит цели.
- **Не делать Flow вообще** — лишает технику, которая даёт ★★★★ к
- **Не делать Flow вообще** - лишает технику, которая даёт ★★★★ к
читаемости. Не вариант: вычислим кадры.
**Результат spike'а (`tests/spike_frames.rs`) — главный вывод раздела:**
**Результат spike'а (`tests/spike_frames.rs`) - главный вывод раздела:**
| что проверяли | что получили |
|---|---|
| `verify_class` + `FallbackStrategy::AlwaysInference` на классе с ветвлением и без `StackMapTable` | ✅ `success`, `path_used == Inference` — движок вывода типов работает |
| `FallbackStrategy::Strict` на том же классе | ✅ `Err("StackMapTable required ... with control flow")` — требование v50+ реально проверяется |
| `StackMapTable` с кадром **не на том** сдвиге | ✅ `Err("Merge point at offset N requires StackMapTable frame")` — ошибка локализована точно |
| `verify_class` + `FallbackStrategy::AlwaysInference` на классе с ветвлением и без `StackMapTable` | ✅ `success`, `path_used == Inference` - движок вывода типов работает |
| `FallbackStrategy::Strict` на том же классе | ✅ `Err("StackMapTable required ... with control flow")` - требование v50+ реально проверяется |
| `StackMapTable` с кадром **не на том** сдвиге | ✅ `Err("Merge point at offset N requires StackMapTable frame")` - ошибка локализована точно |
| получить сам кадр из крейта | ❌ **нельзя** |
Причина последней строки: в `ristretto_classfile/src/verifiers/bytecode.rs`
@ -118,7 +118,7 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` (
`verify_method`, `VerificationResult`, `VerifierConfig` и
`VerificationContext`. Диагностика в `VerificationResult.diagnostic`
заполняется лишь на отдельных путях отказа, а в строгом режиме крейт
возвращает `Err(VerifyError)` целиком — то есть **ожидаемый кадр до нас
возвращает `Err(VerifyError)` целиком - то есть **ожидаемый кадр до нас
не доходит**, только его смещение.
**Что это меняет для P1.** Кадры считаем сами, а крейт используем как
@ -132,7 +132,7 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` (
Три способа получить сами кадры, в порядке предпочтения:
1. ~~**Вендор `verifiers::bytecode::inference`.**~~ — **отвергнут после
1. ~~**Вендор `verifiers::bytecode::inference`.**~~ - **отвергнут после
оценки объёма.** Один модуль не оторвать: в `inference.rs` 14 ссылок
`crate::`, включая private-модули `frame`, `type_system`, `handlers`,
`control_flow`, `diagnostics`. Вендорить пришлось бы весь
@ -140,19 +140,19 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` (
`src/`**, 2.3 MB) ради одной патчи `pub(crate)` → `pub`. Чужой код
становится третьей по объёму частью репозитория, отстаёт от апстрима и
не проходит наши правила (250 строк / 4 файла).
2. **Свой вывод на своём IR — принято.** Кадры считаются по `noxis::model`,
2. **Свой вывод на своём IR - принято.** Кадры считаются по `noxis::model`,
а не по ристретто: `format::parse` → IR → пассы → `compute_frames(IR)` →
`Attribute::StackMapTable` → `format::write`. Ристретто остаётся
**парсером, писателем и приёмочным тестом** из crates.io, без форка.
3. ~~Итеративная починка через `diagnostic`~~ — **отвергнута спайком**: поля
3. ~~Итеративная починка через `diagnostic`~~ - **отвергнута спайком**: поля
`expected`/`pre_frame` в этом пути недостижимы.
**Почему 2, а не 1.** Наш dataflow живёт на типах, которые и так нужны
пассам: `Insn`, метки вместо смещений, иерархия классов из
`noxis-analysis::hierarchy` для LUB на merge-точках. Кадры — это ровно
`noxis-analysis::hierarchy` для LUB на merge-точках. Кадры - это ровно
те же инструкции, что уже переписывает `rename::rewrite`, поэтому
промежуточная конвертация `model → Instruction[] → model` (требование
вендора) добавляет ещё один источник расхождений. Цена — своя реализация
вендора) добавляет ещё один источник расхождений. Цена - своя реализация
(~1 000–1 500 строк: границы блоков, worklist, merge, LUB, эмиссия
`StackFrame`), но она покрывается двумя независимыми судьями:
@ -164,7 +164,7 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` (
java -Xverify:all jar
```
Второй судья — ристретто `verify_class` со стратегией `Strict` — ловит
Второй судья - ристретто `verify_class` со стратегией `Strict` - ловит
ровно тот класс ошибок, ради которого вендор и рассматривался: неверный
сдвиг кадра, пропущенная merge-точка, неверный тип.
@ -173,12 +173,12 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` (
## 4. Генерация рантайма, а не вшитая таблица
**Решение:** `noxis-runtime` — это **заготовки Rust-кода**, из которых
**Решение:** `noxis-runtime` - это **заготовки Rust-кода**, из которых
собирается класс-декодер (P2), а не готовый бинарь.
**Почему:** ABI декодера — `d(Ljava/lang/String;I)Ljava/lang/String;`,
**Почему:** ABI декодера - `d(Ljava/lang/String;I)Ljava/lang/String;`,
но схем шифрования несколько (`xor-splitmix`, `xtea-ctr`) и их число
растёт. Константная таблица селекторов рядом с текстом — ровно та ошибка,
растёт. Константная таблица селекторов рядом с текстом - ровно та ошибка,
которую мы разбирали (`info/evo-weaknesses.md` §1): **ключ рядом с
шифротекстом**. Генератор выбирает схемы на этапе сборки артефакта и
кодирует выбор в самих вызовах, а мастер-секрет выводится в рантайме.
@ -193,7 +193,7 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` (
байт-в-байт одинаковый jar. Это свойство, а не удобство: без него
воспроизводимая сборка, откат и сравнение версий невозможны.
- **Доменное разделение:** `ctx.rng("rename/classes")`,
`ctx.rng("noise/junk")` — сид деривается из имени домена, поэтому
`ctx.rng("noise/junk")` - сид деривается из имени домена, поэтому
случайности разных пассов не статистически коррелированы.
- **Зависимость `rand` тянет** OS-энтропию и меняющееся поведение между
версиями. Нам нужен ровно обратный эффект.
@ -201,17 +201,17 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` (
## 6. ZIP: `zip`
Критерии: чтение, запись, **сохранение порядка и сжатия**, контроль
дубликатов. Берём `zip` (стабильный релиз, без `time`/`aes`/`zstd` —
дубликатов. Берём `zip` (стабильный релиз, без `time`/`aes`/`zstd` -
лишние фичи только раздувают бинарь; `default-features = false,
features = ["deflate"]`). Подпись (`*.SF/*.RSA/*.DSA/*.EC`) вырезаем на
уровне записей архива — после перепаковки она всё равно невалидна.
уровне записей архива - после перепаковки она всё равно невалидна.
Альтернатива `zip6` — форк того же кода; при равном API он даёт меньше
Альтернатива `zip6` - форк того же кода; при равном API он даёт меньше
тестов и меньшую инерцию. Нет причины платить за это.
**MSRV воркспейса поднят до 1.97** — это ровно наш тулчейн (rustc 1.97.1)
**MSRV воркспейса поднят до 1.97** - это ровно наш тулчейн (rustc 1.97.1)
и минимум `ristretto_classfile 0.34`. Заявлять 1.85, когда зависимость
требует 1.97, — враньё в `Cargo.toml`.
требует 1.97, - враньё в `Cargo.toml`.
## 7. CLI: `clap` (derive)
@ -221,11 +221,11 @@ features = ["deflate"]`). Подпись (`*.SF/*.RSA/*.DSA/*.EC`) выреза
## 8. Верификация: `java -Xverify:all` первым делом
Единственная честная проверка «я ничего не сломал» — запустить настоящую
Единственная честная проверка «я ничего не сломал» - запустить настоящую
JVM. `noxis-verify` гоняет `java -Xverify:all` на обфусцированном jar
и по red-паттернам ищет дыры в защите.
**Запасной вариант, если JDK не гарантирован:** `ristretto_vm` —
**Запасной вариант, если JDK не гарантирован:** `ristretto_vm` -
встраиваемый JVM на Rust из того же репозитория. Для нас это означает
`noxis-verify` без внешних зависимостей. Стоит проверить в P3, пока
используем системный `java` (в окружении есть OpenJDK 21).