Initial commit: noxis JVM bytecode obfuscator

Workspace of 9 crates (noxis, noxis-core, noxis-classfile, noxis-analysis,
noxis-passes, noxis-runtime, noxis-jar, noxis-verify, noxis-structure-check).
AGPL-3.0-only license, Rust 2024 edition, unsafe_code denied.
This commit is contained in:
Loki 2026-10-08 22:01:03 +02:00
commit a641e69455
140 changed files with 17197 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,134 @@
//! Приёмка `compute_frames` на фикстурах (`tests/data`).
//!
//! Кадры сносятся и считаются заново. Судьи: детерминизм + round-trip IR +
//! настоящий `java -Xverify:all` для классов без внешних зависимостей.
//! (`verify_class` backend'а для приёмки не годится: он ждёт кадры в байтах,
//! а `from_bytes` отдаёт в индексах — см. `todo.md`.)
#![allow(clippy::expect_used, clippy::unwrap_used)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use noxis_classfile::{Hierarchy, compute_frames, format};
/// Карта `класс → суперкласс` для LUB.
#[derive(Default)]
struct MapHierarchy {
supers: BTreeMap<String, String>,
}
impl Hierarchy for MapHierarchy {
fn superclass(&self, class: &str) -> Option<String> {
self.supers.get(class).cloned()
}
}
/// Классы только на JDK — их `java` проверит полностью.
const JDK_ONLY: [&str; 2] = [
"dev/loki/lorep/util/TimeUtil",
"dev/loki/lorep/util/PaginationUtil",
];
/// Фикстуры: снести кадры, посчитать заново, проверить выход.
#[test]
fn fixtures_recompute_and_verify() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/data");
let mut names: Vec<PathBuf> = std::fs::read_dir(&dir)
.expect("tests/data")
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| path.extension().is_some_and(|ext| ext == "class"))
.collect();
names.sort();
assert!(!names.is_empty(), "нет фикстур в tests/data");
let mut supers = BTreeMap::new();
let mut parsed = Vec::new();
for name in &names {
let class = format::parse(&std::fs::read(name).expect("read")).expect("parse");
supers.insert(class.this_class.clone(), class.super_class.clone());
parsed.push(class);
}
// Без суперкласса — только `java/lang/Object`.
supers.entry("java/lang/Object".to_string()).or_insert(None);
let hierarchy = MapHierarchy {
supers: supers
.into_iter()
.filter_map(|(k, v)| v.map(|sup| (k, sup)))
.collect(),
};
let scratch = std::env::temp_dir().join(format!("noxis-frames-{}", std::process::id()));
let java = java_available();
for class in &mut parsed {
if class.version.major < 50 {
continue;
}
for method in &mut class.methods {
if method.code.is_none() {
continue;
}
let first = compute_frames(&class.this_class, method, &hierarchy).expect("recompute");
method.code.as_mut().expect("code").frames = first.clone();
// Детерминизм: повторный прогон даёт те же кадры.
let second = compute_frames(&class.this_class, method, &hierarchy).expect("recompute");
assert_eq!(first, second, "недетерминированные кадры");
}
// Round-trip: записанное читается в тот же IR (включая кадры).
let written = format::write(class).expect("write");
assert_eq!(
format::parse(&written).expect("re-parse"),
*class,
"round-trip"
);
if java && JDK_ONLY.contains(&class.this_class.as_str()) {
java_verify(&scratch, class, &written).expect("java -Xverify:all");
}
}
let _ = std::fs::remove_dir_all(&scratch);
}
/// Есть ли `java` для проверки настоящим верификатором.
fn java_available() -> bool {
Command::new("java")
.arg("-version")
.output()
.is_ok_and(|out| out.status.success())
}
/// Записать класс в дерево пакетов и прогнать `java -Xverify:all`.
/// Успех — загрузка без ошибок верификации (`main` при этом отсутствует).
fn java_verify(
scratch: &Path,
class: &noxis_classfile::ClassFile,
bytes: &[u8],
) -> Result<(), String> {
let mut path = scratch.to_path_buf();
for part in class.this_class.split('/') {
path.push(part);
}
path.set_extension("class");
std::fs::create_dir_all(path.parent().expect("parent")).map_err(|e| format!("mkdir: {e}"))?;
std::fs::write(&path, bytes).map_err(|e| format!("write: {e}"))?;
let dotted = class.this_class.replace('/', ".");
let out = Command::new("java")
.arg("-Xverify:all")
.arg("-cp")
.arg(scratch)
.arg(&dotted)
.output()
.map_err(|e| format!("запуск java: {e}"))?;
let stderr = String::from_utf8_lossy(&out.stderr);
for bad in [
"VerifyError",
"ClassFormatError",
"NoClassDefFoundError",
"LinkageError",
] {
if stderr.contains(bad) {
return Err(format!("{dotted}:\n{stderr}"));
}
}
Ok(())
}

View file

@ -0,0 +1,221 @@
//! Round-trip `parse → write → parse` через backend.
//!
//! Пул при записи строится заново (порядок другой), поэтому сравнивается IR,
//! а не байты. Записанные байты обязаны проходить структурный `verify`
//! backend'а — это ловит битые ссылки пула и кривые атрибуты.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use ristretto_classfile::attributes::{
Attribute as RtAttr, BootstrapMethod as RtBsm, ExceptionTableEntry as RtEntry,
Instruction as Rt, LineNumber as RtLine, StackFrame as RtFrame,
};
use ristretto_classfile::{
ClassAccessFlags, ClassFile as RtClass, ConstantPool, Field as RtField, FieldAccessFlags,
FieldType, JAVA_21, Method as RtMethod, MethodAccessFlags, ReferenceKind,
};
use noxis_classfile::{ClassFile, format};
/// Минимальный класс: версия, флаги, поле с `ConstantValue`, `SourceFile`.
#[test]
fn minimal_class_round_trip() {
let mut pool = ConstantPool::default();
let this = pool.add_class("demo/Round").expect("add_class");
let object = pool.add_class("java/lang/Object").expect("add_object");
let name = pool.add_utf8("K").expect("name");
let desc = pool.add_utf8("I").expect("desc");
let value = pool.add_integer(42).expect("value");
let source_name = pool.add_utf8("SourceFile").expect("attr");
let source_file = pool.add_utf8("Round.java").expect("file");
let constant_name = pool.add_utf8("ConstantValue").expect("attr");
let rt = RtClass {
version: JAVA_21,
constant_pool: pool,
access_flags: ClassAccessFlags::PUBLIC,
this_class: this,
super_class: object,
interfaces: vec![],
fields: vec![RtField {
access_flags: FieldAccessFlags::PUBLIC
| FieldAccessFlags::STATIC
| FieldAccessFlags::FINAL,
name_index: name,
descriptor_index: desc,
field_type: FieldType::parse("I").expect("field type"),
attributes: vec![RtAttr::ConstantValue {
name_index: constant_name,
constant_value_index: value,
}],
}],
methods: vec![],
attributes: vec![RtAttr::SourceFile {
name_index: source_name,
source_file_index: source_file,
}],
..RtClass::default()
};
let ir = round_trip(&rt);
assert_eq!(ir.this_class, "demo/Round");
assert_eq!(ir.super_class.as_deref(), Some("java/lang/Object"));
assert_eq!(ir.methods.len(), 0);
assert_eq!(ir.fields.len(), 1);
}
/// Метод с ветвлениями, handler'ом, номерами строк и кадрами.
#[test]
fn code_with_branches_round_trip() {
let mut pool = ConstantPool::default();
let this = pool.add_class("demo/Flow").expect("add_class");
let object = pool.add_class("java/lang/Object").expect("add_object");
let name = pool.add_utf8("run").expect("name");
let desc = pool.add_utf8("()I").expect("desc");
let code_name = pool.add_utf8("Code").expect("attr");
let lines_name = pool.add_utf8("LineNumberTable").expect("attr");
let frames_name = pool.add_utf8("StackMapTable").expect("attr");
// Логические номера: 0 iconst_0, 1 istore_1, 2 iload_1, 3 ifeq→6,
// 4 iconst_1, 5 goto→7, 6 iconst_2, 7 ireturn.
let code = vec![
Rt::Iconst_0,
Rt::Istore_1,
Rt::Iload_1,
Rt::Ifeq(6),
Rt::Iconst_1,
Rt::Goto(7),
Rt::Iconst_2,
Rt::Ireturn,
];
let code_attr = RtAttr::Code {
name_index: code_name,
max_stack: 1,
max_locals: 2,
code,
exception_table: vec![RtEntry {
range_pc: 0..4,
handler_pc: 6,
catch_type: 0,
}],
attributes: vec![
RtAttr::LineNumberTable {
name_index: lines_name,
line_numbers: vec![
RtLine {
start_pc: 0,
line_number: 10,
},
RtLine {
start_pc: 6,
line_number: 20,
},
],
},
RtAttr::StackMapTable {
name_index: frames_name,
frames: vec![
RtFrame::SameFrame { frame_type: 6 },
RtFrame::SameFrame { frame_type: 0 },
],
},
],
};
let rt = RtClass {
version: JAVA_21,
constant_pool: pool,
access_flags: ClassAccessFlags::PUBLIC,
this_class: this,
super_class: object,
interfaces: vec![],
fields: vec![],
methods: vec![RtMethod {
access_flags: MethodAccessFlags::PUBLIC | MethodAccessFlags::STATIC,
name_index: name,
descriptor_index: desc,
attributes: vec![code_attr],
}],
attributes: vec![],
..RtClass::default()
};
let ir = round_trip(&rt);
let method = &ir.methods[0];
let code = method.code.as_ref().expect("есть Code");
assert_eq!(code.max_stack, 1);
assert_eq!(code.max_locals, 2);
// Номера инструкций: 0,1,2,3(ifeq),4,5(goto),6,7; кадры на 6 и 7.
let frames = &code.frames;
assert_eq!(frames.len(), 2);
assert!(format!("{frames:?}").contains("offset_delta: 6"));
assert_eq!(code.exception_table.len(), 1);
assert_eq!(code.attributes.len(), 1);
}
/// `BootstrapMethods` + `invokedynamic` переживают round-trip.
#[test]
fn bootstrap_methods_round_trip() {
let mut pool = ConstantPool::default();
let this = pool.add_class("demo/Lambda").expect("add_class");
let object = pool.add_class("java/lang/Object").expect("add_object");
let owner = pool.add_class("demo/RT").expect("owner");
let method_ref = pool.add_method_ref(owner, "boot", "()V").expect("ref");
let handle = pool
.add_method_handle(ReferenceKind::InvokeStatic, method_ref)
.expect("handle");
let arg = pool.add_string("x").expect("arg");
let bsm_name = pool.add_utf8("BootstrapMethods").expect("attr");
let name = pool.add_utf8("run").expect("name");
let desc = pool.add_utf8("()V").expect("desc");
let code_name = pool.add_utf8("Code").expect("attr");
let dynamic = pool.add_invoke_dynamic(0, "run", "()V").expect("indy");
let rt = RtClass {
version: JAVA_21,
constant_pool: pool,
access_flags: ClassAccessFlags::PUBLIC,
this_class: this,
super_class: object,
interfaces: vec![],
fields: vec![],
methods: vec![RtMethod {
access_flags: MethodAccessFlags::PUBLIC | MethodAccessFlags::STATIC,
name_index: name,
descriptor_index: desc,
attributes: vec![RtAttr::Code {
name_index: code_name,
max_stack: 1,
max_locals: 0,
code: vec![Rt::Invokedynamic(dynamic), Rt::Return],
exception_table: vec![],
attributes: vec![],
}],
}],
attributes: vec![RtAttr::BootstrapMethods {
name_index: bsm_name,
methods: vec![RtBsm {
bootstrap_method_ref: handle,
arguments: vec![arg],
}],
}],
..RtClass::default()
};
let ir = round_trip(&rt);
assert_eq!(ir.attributes.len(), 1);
assert!(format!("{:?}", ir.attributes[0]).contains("BootstrapMethods"));
}
/// `parse → write → parse` даёт тот же IR; записанное верифицируется.
fn round_trip(rt: &RtClass<'_>) -> ClassFile {
let mut original = Vec::new();
rt.to_bytes(&mut original).expect("to_bytes");
let first = format::parse(&original).expect("parse");
let written = format::write(&first).expect("write");
RtClass::from_bytes(&written)
.expect("written parses")
.verify()
.expect("written verifies");
let second = format::parse(&written).expect("re-parse");
assert_eq!(first, second, "IR обязан пережить round-trip");
second
}

View file

@ -0,0 +1,152 @@
//! Spike, часть 2: доступна ли **генерация** кадров `StackMapTable`.
//!
//! Ключевой вопрос `docs/stack.md` §3: считает ли `ristretto_classfile` кадры
//! сам и отдаёт ли их наружу. Если да — наша работа сводится к записи; если
//! нет — кадры придётся считать (или вендорить их `verifiers::bytecode`).
#![allow(clippy::expect_used, clippy::unwrap_used)]
use ristretto_classfile::attributes::{Attribute, Instruction, StackFrame};
use ristretto_classfile::verifiers::bytecode::{
FallbackStrategy, VerificationPath, VerifierConfig, verify_class,
};
use ristretto_classfile::verifiers::context::VerificationContext;
use ristretto_classfile::verifiers::error::Result as VerifyResult;
use ristretto_classfile::{
ClassAccessFlags, ClassFile, Constant, ConstantPool, MethodAccessFlags, Version,
};
/// Разрешающий контекст: в spike'е нас интересует только сам вывод типов.
#[derive(Debug)]
struct Permissive;
impl VerificationContext for Permissive {
fn is_subclass(&self, _subclass: &str, _superclass: &str) -> VerifyResult<bool> {
Ok(true)
}
fn is_assignable(&self, _target: &str, _source: &str) -> VerifyResult<bool> {
Ok(true)
}
fn common_superclass(&self, _class1: &str, _class2: &str) -> VerifyResult<String> {
Ok("java/lang/Object".to_string())
}
}
/// `void run()` с одной ветвью: на сдвиге 3 (`Ifeq`-target) кадр обязателен.
fn branch_class() -> ClassFile<'static> {
let mut constant_pool = ConstantPool::default();
let utf8_name = constant_pool
.add(Constant::utf8("demo/Branch"))
.expect("utf8");
let this_class = constant_pool
.add(Constant::Class(utf8_name))
.expect("class");
let run = constant_pool.add(Constant::utf8("run")).expect("utf8");
let descriptor = constant_pool.add(Constant::utf8("()V")).expect("utf8");
let code = constant_pool.add(Constant::utf8("Code")).expect("utf8");
ClassFile {
version: Version::Java8 { minor: 0 },
constant_pool,
access_flags: ClassAccessFlags::PUBLIC,
this_class,
super_class: 0,
interfaces: Vec::new(),
fields: Vec::new(),
methods: vec![ristretto_classfile::Method {
access_flags: MethodAccessFlags::PUBLIC | MethodAccessFlags::STATIC,
name_index: run,
descriptor_index: descriptor,
attributes: vec![Attribute::Code {
name_index: code,
max_stack: 1,
max_locals: 0,
code: vec![
Instruction::Iconst_1,
Instruction::Ifeq(3),
Instruction::Return,
Instruction::Return,
],
exception_table: Vec::new(),
attributes: Vec::new(),
}],
}],
attributes: Vec::new(),
code_source_url: None,
}
}
fn inference_config() -> VerifierConfig {
VerifierConfig::default().with_fallback_strategy(FallbackStrategy::AlwaysInference)
}
/// Без `StackMapTable` inference обязан пройти сам — значит, движок кадров у
/// крейта есть, просто его результат не отдаётся наружу.
#[test]
fn inference_path_verifies_without_stack_map_table() {
let class_file = branch_class();
let results = verify_class(&class_file, &Permissive, &inference_config()).expect("verify");
assert_eq!(results.len(), 1);
assert!(results[0].success, "ошибка: {:?}", results[0].error);
assert_eq!(results[0].path_used, VerificationPath::Inference);
}
/// Строгий режим без кадров обязан упасть — иначе требование `StackMapTable`
/// для классов ≥ 50 не проверяется вовсе.
#[test]
fn strict_mode_rejects_missing_stack_map_table() {
let class_file = branch_class();
let config = VerifierConfig::default().with_fallback_strategy(FallbackStrategy::Strict);
let outcome = verify_class(&class_file, &Permissive, &config);
// Строгий режим падает либо `Err`, либо `success == false` — оба варианта
// приемлемы, важен сам отказ.
let rejected = match &outcome {
Err(_) => true,
Ok(results) => results.iter().any(|r| !r.success),
};
assert!(rejected, "строгий режим обязан отвергнуть класс без кадров");
}
/// Негативный результат spike'а: кадр не на том сдвиге не отдаёт диагностику.
///
/// Ждали `Ok(vec![result])` с заполненным `diagnostic.expected` — тогда
/// генерация кадров шла бы итеративным путём через публичный API. Фактически
/// `verify_class` в строгом режиме возвращает `Err(VerifyError)`, и ожидаемый
/// кадр до нас не доходит: из крейта достаётся только место ошибки
/// («merge point at offset N»), но не её содержимое.
///
/// Вывод для `docs/stack.md` §3: проверять кадры крейт умеет, отдавать — нет.
#[test]
fn diagnostic_exposes_expected_frame_on_failure() {
let mut class_file = branch_class();
let name_index = class_file
.constant_pool
.add_utf8("StackMapTable")
.expect("utf8");
// Кадр на сдвиге 0, а настоящий якорь — на сдвиге 3.
class_file.attributes.push(Attribute::StackMapTable {
name_index,
frames: vec![StackFrame::SameFrame { frame_type: 0 }],
});
// Кладём атрибут в метод, а не в класс: `StackMapTable` живёт в `Code`.
let stack_map = class_file.attributes.pop().expect("frame");
match &mut class_file.methods[0].attributes[0] {
Attribute::Code { attributes, .. } => attributes.push(stack_map),
other => panic!("ожидался Code, получено {other:?}"),
}
let strict = VerifierConfig::default().with_fallback_strategy(FallbackStrategy::Strict);
let outcome = verify_class(&class_file, &Permissive, &strict);
// Наблюдаемое поведение: `Err`, а не `Ok` с `diagnostic`.
let err = outcome.expect_err("битые кадры обязаны быть отвергнуты");
let message = err.to_string();
assert!(
message.contains("StackMapTable"),
"ожидалась ошибка про кадры, получено: {message}"
);
}

View file

@ -0,0 +1,110 @@
//! Spike: пригоден ли `ristretto_classfile` как backend для чтения/записи.
//!
//! Критерии — `docs/stack.md` §2. Тест существует, чтобы ответы на вопросы
//! spike'а не устаревали: если крейт сломает хотя бы один пункт, сборка упадёт,
//! и решение о backend'е придётся пересмотреть.
//!
//! Что здесь **не** проверяется (и что выносится в задачу P1): конвертация
//! нашего IR в их типы и обратно. Spike отвечает только на вопрос «можно ли
//! на этом построить», а не «сколько на это уйдёт времени».
#![allow(clippy::expect_used, clippy::unwrap_used)]
use ristretto_classfile::attributes::{StackFrame, VerificationType};
use ristretto_classfile::{ClassFile, ConstantPool, JAVA_21};
/// Крейт компилируется на заявленном MSRV и собирает валидный класс.
#[test]
fn builds_verifies_and_serializes() {
let mut constant_pool = ConstantPool::default();
let this_class = constant_pool.add_class("demo/Hello").expect("add_class");
let class_file = ClassFile {
version: JAVA_21,
constant_pool,
this_class,
..ClassFile::default()
};
class_file.verify().expect("verify");
let mut bytes = Vec::new();
class_file.to_bytes(&mut bytes).expect("to_bytes");
assert!(
bytes.starts_with(&[0xCA, 0xFE, 0xBA, 0xBE]),
"нет магии CAFEBABE"
);
}
/// Round-trip: `to_bytes` → `from_bytes` сохраняет содержимое класса.
#[test]
fn round_trip_preserves_identity() {
let mut constant_pool = ConstantPool::default();
let this_class = constant_pool.add_class("demo/Round").expect("add_class");
let source = ClassFile {
version: JAVA_21,
constant_pool,
this_class,
..ClassFile::default()
};
let mut bytes = Vec::new();
source.to_bytes(&mut bytes).expect("to_bytes");
let restored = ClassFile::from_bytes(&bytes).expect("from_bytes");
assert_eq!(
restored.class_name().expect("class_name").to_string(),
"demo/Round"
);
assert_eq!(restored.version, source.version);
assert_eq!(restored.constant_pool.len(), source.constant_pool.len());
}
/// Кадры `StackMapTable` конструируются и сериализуются публичным API —
/// значит, мы можем **записывать** кадры, а не только читать.
#[test]
fn stack_map_frames_are_constructible() {
let mut constant_pool = ConstantPool::default();
let this_class = constant_pool.add_class("demo/Frames").expect("add_class");
let mut class_file = ClassFile {
version: JAVA_21,
constant_pool,
this_class,
..ClassFile::default()
};
// SameFrame + расширенный кадр с одним элементом на стеке.
let frames = vec![
StackFrame::SameFrame { frame_type: 0 },
StackFrame::SameLocals1StackItemFrameExtended {
frame_type: 247,
offset_delta: 3,
stack: vec![VerificationType::Integer],
},
];
let name_index = class_file
.constant_pool
.add_utf8("StackMapTable")
.expect("add_utf8");
class_file
.attributes
.push(ristretto_classfile::attributes::Attribute::StackMapTable { name_index, frames });
let mut bytes = Vec::new();
class_file.to_bytes(&mut bytes).expect("to_bytes");
let restored = ClassFile::from_bytes(&bytes).expect("from_bytes");
let written = restored
.attributes
.iter()
.find_map(|attribute| match attribute {
ristretto_classfile::attributes::Attribute::StackMapTable { frames, .. } => {
Some(frames.clone())
}
_ => None,
})
.expect("атрибут StackMapTable должен пережить round-trip");
assert_eq!(written.len(), 2);
assert_eq!(written[0], StackFrame::SameFrame { frame_type: 0 });
}