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.
16 KiB
Стек: почему именно так
Документ фиксирует решения, которые трудно отменить. Для каждого - альтернативы, критерии, цена и что меняет решение.
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) из готового байткода:
наш 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. Кадры считаем сами, а крейт используем как приёмочный тест:
наш IR → Instruction[] → наши кадры → StackMapTable → запись ристретто
↓
verify_class(Strict) = Ok → артефакт валиден
Три способа получить сами кадры, в порядке предпочтения:
Вендор- отвергнут после оценки объёма. Один модуль не оторвать: вverifiers::bytecode::inference.inference.rs14 ссылокcrate::, включая private-модулиframe,type_system,handlers,control_flow,diagnostics. Вендорить пришлось бы весьverifiers/bytecode/(~9 700 строк) или весь крейт (61 193 строкиsrc/, 2.3 MB) ради одной патчиpub(crate)→pub. Чужой код становится третьей по объёму частью репозитория, отстаёт от апстрима и не проходит наши правила (250 строк / 4 файла).- Свой вывод на своём IR - принято. Кадры считаются по
noxis::model, а не по ристретто:format::parse→ IR → пассы →compute_frames(IR)→Attribute::StackMapTable→format::write. Ристретто остаётся парсером, писателем и приёмочным тестом из crates.io, без форка. Итеративная починка через- отвергнута спайком: поляdiagnosticexpected/pre_frameв этом пути недостижимы.
Почему 2, а не 1. Наш dataflow живёт на типах, которые и так нужны
пассам: Insn, метки вместо смещений, иерархия классов из
noxis-analysis::hierarchy для LUB на merge-точках. Кадры - это ровно
те же инструкции, что уже переписывает rename::rewrite, поэтому
промежуточная конвертация model → Instruction[] → model (требование
вендора) добавляет ещё один источник расхождений. Цена - своя реализация
(~1 000–1 500 строк: границы блоков, worklist, merge, LUB, эмиссия
StackFrame), но она покрывается двумя независимыми судьями:
наш 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 |