obfuskator/docs/stack.md
loki5512344 139aea9a8f
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.
2026-10-09 13:47:27 +02:00

242 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Стек: почему именно так
Документ фиксирует решения, которые трудно отменить. Для каждого -
альтернативы, критерии, цена и что меняет решение.
## 1. Собственный IR, а не API чужой библиотеки
**Решение:** пассы работают с `noxis-classfile::model::{Program, ClassFile,
Code, Insn}`, а не с типами парсера.
**Почему не «просто использовать готовую библиотеку целиком»:**
- библиотека диктует API - каждый пасс привязывается к чужим миграциям
мажорной версии (наш лимит «≤4 файлов» легко разрывается «транзитивно»);
- у нас есть конкретное требование, которого нет ни в одном парсере:
`Insn::Mark(Label)` вместо смещений;
- мы не знаём заранее, захочет ли пользователь backend'а ещё одну
библиотеку (например, для `StackMapTable`) - изоляция через IR даёт
смену backend'а без переписывания пассов.
**Что IR даёт на практике:**
| свойство | эффект |
|---|---|
| метки вместо смещений | пассы никогда не пересчитывают jump-офсеты |
| `Attribute::Raw` | round-trip без потерь: неизвестный атрибут возвращается как есть |
| `Program` включает ресурсы архива | переименование и патч ресурсов в одном прогоне |
| одна точка записи | пересчёт смещений, `max_stack` и `StackMapTable` - ровно один раз |
**Цена:** конвертация `model ↔ backend` на границах (чтение и запись) -
примерно один файл на направление. Дёшево.
## 2. Backend для чтения/записи - `ristretto_classfile`
**Решение:** `format::parse` / `format::write` реализуются поверх
`ristretto_classfile 0.34`, наш IR остаётся единим API для пассов.
Реализация - этап 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 | - |
### Критерии и что подтвердил spike
MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` (совместима с
нашим `deny(unsafe_code)`), запись, а не только чтение, разбор
`StackMapTable`, живой апстрим (CI, бенчи, релиз 2026-09). Спайк
`tests/spike_ristretto.rs` подтверждает round-trip, `verify()` и
конструирование кадров.
### Что отвергнуто
- **Писать парсер с нуля** - спецификация JVMS §4 по формату классов
плюс MUTF-8, constant pool, модули, records, переполнения - это месяцы
работы ради того, что уже написано и покрыто тестами. Это нарушает KISS
и не добавляет ни одной защиты.
- **`classfile-parser`, `jclassfile`** - только чтение, значит writer всё
равно наш; `classfile-parser` не обновлялся с 2024-10.
- **`java-bytecode`** - 41 загрузка: у проекта нет инерции и нет экосистемы.
- **`jvm-assembler`** - последний коммит в 2015.
## 3. `StackMapTable`: генерируем сами, пользуемся верификатором
**Проблема.** Классы версии ≥ 50 требуют `StackMapTable`. Любая техника,
меняющая CFG (Flow), и любая, добавляющая ветки (Strings, Constants, Noise),
делает сохранённые кадры неверными → `VerifyError`.
**Решение:** не считать кадры самим, а **вывести их типовым выводом
(JVMS §4.10.2)** из готового байткода:
```text
наш IR → Instruction[] → inference (worklist, LUB на merge-точках)
→ кадры на каждом target'е ветвления
→ StackMapTable → запись
```
`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` -
пересчёт `max_stack`/`max_locals` из инструкций;
- `attributes/{stack_frame,verification_type}.rs` - типы для записи.
**Почему это лучше, чем свои кадры:** это ровно та часть JVMS, в которой
ошибка невидима в исходниках и всплывает только на `VerifyError` в чужом
рантайме. Писать такой верификатор - самая дорогая и самая опасная часть
всего проекта.
**Альтернативы, которые отвергнуты:**
- **Понижать версию класса до 49 и выкинуть `StackMapTable`** - ломает
`invokedynamic`/лямбды и включает дорогой fallback-верификатор; на Java 9+
это заметно и противоречит цели.
- **Не делать Flow вообще** - лишает технику, которая даёт ★★★★ к
читаемости. Не вариант: вычислим кадры.
**Результат 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")` - ошибка локализована точно |
| получить сам кадр из крейта | ❌ **нельзя** |
Причина последней строки: в `ristretto_classfile/src/verifiers/bytecode.rs`
модули `inference`, `diagnostics`, `frame`, `stackmap`, `control_flow`
объявлены как `pub(crate)`. Публично отданы только `verify`, `verify_class`,
`verify_method`, `VerificationResult`, `VerifierConfig` и
`VerificationContext`. Диагностика в `VerificationResult.diagnostic`
заполняется лишь на отдельных путях отказа, а в строгом режиме крейт
возвращает `Err(VerifyError)` целиком - то есть **ожидаемый кадр до нас
не доходит**, только его смещение.
**Что это меняет для P1.** Кадры считаем сами, а крейт используем как
**приёмочный тест**:
```text
наш IR → Instruction[] → наши кадры → StackMapTable → запись ристретто
↓
verify_class(Strict) = Ok → артефакт валиден
```
Три способа получить сами кадры, в порядке предпочтения:
1. ~~**Вендор `verifiers::bytecode::inference`.**~~ - **отвергнут после
оценки объёма.** Один модуль не оторвать: в `inference.rs` 14 ссылок
`crate::`, включая private-модули `frame`, `type_system`, `handlers`,
`control_flow`, `diagnostics`. Вендорить пришлось бы весь
`verifiers/bytecode/` (~9 700 строк) или весь крейт (**61 193 строки
`src/`**, 2.3 MB) ради одной патчи `pub(crate)` → `pub`. Чужой код
становится третьей по объёму частью репозитория, отстаёт от апстрима и
не проходит наши правила (250 строк / 4 файла).
2. **Свой вывод на своём IR - принято.** Кадры считаются по `noxis::model`,
а не по ристретто: `format::parse` → IR → пассы → `compute_frames(IR)` →
`Attribute::StackMapTable` → `format::write`. Ристретто остаётся
**парсером, писателем и приёмочным тестом** из crates.io, без форка.
3. ~~Итеративная починка через `diagnostic`~~ - **отвергнута спайком**: поля
`expected`/`pre_frame` в этом пути недостижимы.
**Почему 2, а не 1.** Наш dataflow живёт на типах, которые и так нужны
пассам: `Insn`, метки вместо смещений, иерархия классов из
`noxis-analysis::hierarchy` для LUB на merge-точках. Кадры - это ровно
те же инструкции, что уже переписывает `rename::rewrite`, поэтому
промежуточная конвертация `model → Instruction[] → model` (требование
вендора) добавляет ещё один источник расхождений. Цена - своя реализация
(~1 000–1 500 строк: границы блоков, worklist, merge, LUB, эмиссия
`StackFrame`), но она покрывается двумя независимыми судьями:
```text
наш IR → compute_frames → StackMapTable → запись ристретто
↓ ↓
noxis-verify (red-паттерны) verify_class(Strict) = Ok
↓
java -Xverify:all jar
```
Второй судья - ристретто `verify_class` со стратегией `Strict` - ловит
ровно тот класс ошибок, ради которого вендор и рассматривался: неверный
сдвиг кадра, пропущенная merge-точка, неверный тип.
**Критерий готовности этапа:** на всех jar из `fixtures` (см. `todo.md`)
`verify_class(Strict) == Ok` и `java -Xverify:all` без `VerifyError`.
## 4. Генерация рантайма, а не вшитая таблица
**Решение:** `noxis-runtime` - это **заготовки Rust-кода**, из которых
собирается класс-декодер (P2), а не готовый бинарь.
**Почему:** ABI декодера - `d(Ljava/lang/String;I)Ljava/lang/String;`,
но схем шифрования несколько (`xor-splitmix`, `xtea-ctr`) и их число
растёт. Константная таблица селекторов рядом с текстом - ровно та ошибка,
которую мы разбирали (`info/evo-weaknesses.md` §1): **ключ рядом с
шифротекстом**. Генератор выбирает схемы на этапе сборки артефакта и
кодирует выбор в самих вызовах, а мастер-секрет выводится в рантайме.
**Что нельзя делать:** вшивать мастер-секрет как константу в `LDC`.
## 5. PRNG: свой, не `rand`
**Решение:** `xoshiro256++` + `splitmix64` + доменное разделение по FNV-1a.
- **Детерминированность обязана:** один seed на одинаковой версии →
байт-в-байт одинаковый jar. Это свойство, а не удобство: без него
воспроизводимая сборка, откат и сравнение версий невозможны.
- **Доменное разделение:** `ctx.rng("rename/classes")`,
`ctx.rng("noise/junk")` - сид деривается из имени домена, поэтому
случайности разных пассов не статистически коррелированы.
- **Зависимость `rand` тянет** OS-энтропию и меняющееся поведение между
версиями. Нам нужен ровно обратный эффект.
## 6. ZIP: `zip`
Критерии: чтение, запись, **сохранение порядка и сжатия**, контроль
дубликатов. Берём `zip` (стабильный релиз, без `time`/`aes`/`zstd` -
лишние фичи только раздувают бинарь; `default-features = false,
features = ["deflate"]`). Подпись (`*.SF/*.RSA/*.DSA/*.EC`) вырезаем на
уровне записей архива - после перепаковки она всё равно невалидна.
Альтернатива `zip6` - форк того же кода; при равном API он даёт меньше
тестов и меньшую инерцию. Нет причины платить за это.
**MSRV воркспейса поднят до 1.97** - это ровно наш тулчейн (rustc 1.97.1)
и минимум `ristretto_classfile 0.34`. Заявлять 1.85, когда зависимость
требует 1.97, - враньё в `Cargo.toml`.
## 7. CLI: `clap` (derive)
Стандарт, стабильный API, поддержка цветов и автодополнения. Альтернативы
(`bpaf`, `gumdrop`, ручной парсер) экономят размер сборки, но не решают
ни одной нашей задачи.
## 8. Верификация: `java -Xverify:all` первым делом
Единственная честная проверка «я ничего не сломал» - запустить настоящую
JVM. `noxis-verify` гоняет `java -Xverify:all` на обфусцированном jar
и по red-паттернам ищет дыры в защите.
**Запасной вариант, если JDK не гарантирован:** `ristretto_vm` -
встраиваемый JVM на Rust из того же репозитория. Для нас это означает
`noxis-verify` без внешних зависимостей. Стоит проверить в P3, пока
используем системный `java` (в окружении есть OpenJDK 21).
## Сводка
| решение | альтернатива | цена |
|---|---|---|
| свой IR + метки | писать пассы поверх чужого API | конвертер на границе |
| `ristretto_classfile` | свой парсер / parse-only крейты | зависимость 1 уровня |
| inference-кадры | свой верификатор / downgrade версии | spike на публичность API |
| генерация декодера | вшитая таблица селекторов | сложнее собирать |
| свой PRNG | `rand` | ~1 файл |
| `java -Xverify:all` | только красные паттерны | нужен JDK |