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

View file

@ -0,0 +1,157 @@
//! Чтение и запись ZIP-архива JAR.
//!
//! Единственная задача модуля — переместить записи из/в вектор [`Entry`],
//! сохранив порядок и не потеряв дубликатов молча. Всё, что связано с
//! содержимым (`Program`, маппинги, подпись), живёт в соседних модулях.
use std::fs::File;
use std::io::{Read, Seek, Write};
use std::path::Path;
use thiserror::Error;
use zip::CompressionMethod;
use zip::write::SimpleFileOptions;
/// Ошибки работы с архивом.
#[derive(Debug, Error)]
pub enum JarError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("zip: {0}")]
Zip(#[from] zip::result::ZipError),
#[error("duplicate archive entry: {0}")]
Duplicate(String),
#[error("class file: {0}")]
Classfile(#[from] noxis_classfile::ClassfileError),
}
/// Одна запись архива: имя + байты.
///
/// Порядок в `[`Entry`]` — это порядок в архиве, он сохраняется при
/// чтении и записи. Метод сжатия не хранится: всё пишется deflate'ом,
/// что детерминированно и одинаково для одинаковых данных.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub name: String,
pub data: Vec<u8>,
}
impl Entry {
/// Создаёт запись по имени и байтам.
pub fn new(name: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
Self {
name: name.into(),
data: data.into(),
}
}
}
/// Читает все не-каталожные записи из `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)?;
let mut entries = Vec::with_capacity(archive.len());
for index in 0..archive.len() {
let mut file = archive.by_index(index)?;
if file.is_dir() {
continue;
}
let name = file.name().to_string();
let mut data = Vec::new();
file.read_to_end(&mut data)?;
entries.push(Entry { name, data });
}
let names: Vec<String> = entries.iter().map(|entry| entry.name.clone()).collect();
ensure_unique(&names)?;
Ok(entries)
}
/// Пишет записи в `writer` в переданном порядке.
pub fn write_entries<W: Write + Seek>(writer: W, entries: &[Entry]) -> Result<(), JarError> {
let names: Vec<String> = entries.iter().map(|entry| entry.name.clone()).collect();
ensure_unique(&names)?;
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
let mut out = zip::ZipWriter::new(writer);
for entry in entries {
out.start_file(entry.name.as_str(), options)?;
out.write_all(&entry.data)?;
}
out.finish()?;
Ok(())
}
/// Читает архив с диска.
pub fn read_file(path: &Path) -> Result<Vec<Entry>, JarError> {
read_entries(File::open(path)?)
}
/// Пишет архив на диск.
pub fn write_file(path: &Path, entries: &[Entry]) -> Result<(), JarError> {
let file = File::create(path)?;
write_entries(file, entries)
}
/// Отклоняет повторяющиеся имена записей.
fn ensure_unique(names: &[String]) -> Result<(), JarError> {
let mut seen: Vec<&str> = Vec::with_capacity(names.len());
for name in names {
if seen.contains(&name.as_str()) {
return Err(JarError::Duplicate(name.clone()));
}
seen.push(name);
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use std::io::Cursor;
use super::*;
fn entries() -> Vec<Entry> {
vec![
Entry::new("META-INF/MANIFEST.MF", b"Manifest-Version: 1.0\n".to_vec()),
Entry::new("demo/A.class", vec![0xCA, 0xFE, 0xBA, 0xBE]),
Entry::new("config/app.json", br#"{"k":1}"#.to_vec()),
]
}
#[test]
fn round_trip_preserves_order_and_bytes() {
let source = entries();
let mut buffer = Vec::new();
write_entries(Cursor::new(&mut buffer), &source).expect("write");
let restored = read_entries(Cursor::new(&buffer)).expect("read");
assert_eq!(restored, source);
}
#[test]
fn duplicate_names_are_rejected() {
let dup = vec![
Entry::new("a.txt", b"1".to_vec()),
Entry::new("a.txt", b"2".to_vec()),
];
let mut buffer = Vec::new();
let err = write_entries(Cursor::new(&mut buffer), &dup).expect_err("dup");
assert!(matches!(err, JarError::Duplicate(name) if name == "a.txt"));
}
#[test]
fn missing_file_is_an_io_error() {
let err = read_file(Path::new("/definitely/not/here.jar")).expect_err("open");
assert!(matches!(err, JarError::Io(_)));
}
}

View file

@ -0,0 +1,22 @@
//! # noxis-jar
//!
//! Чтение и запись JAR: ZIP-архив, классы, ресурсы, подписи.
//!
//! Слой разделён на три несвязанные задачи:
//!
//! * [`archive`] — чистая работа с ZIP: порядок записей, сжатие, дубликаты;
//! * [`signatures`] — срез невалидных подписей `META-INF/*.SF|RSA|DSA|EC`;
//! * [`loader`] — перевод архива в [`Program`](noxis_classfile::Program)
//! и обратно (классы через [`format`](noxis_classfile::format), остальное
//! как ресурсы).
//!
//! Обфускация ресурсов — пасс `resources` в `noxis-passes`; сюда она не
//! входит, потому что ей нужен маппинг имён, а не I/O.
pub mod archive;
pub mod loader;
pub mod signatures;
pub use archive::{Entry, JarError, read_entries, read_file, write_entries, write_file};
pub use loader::{load, save};
pub use signatures::{is_signature_entry, strip_signatures};

View file

@ -0,0 +1,100 @@
//! Перевод архива в [`Program`] и обратно.
//!
//! Правило простое: `*.class` уходит в парсер, всё остальное — в ресурсы.
//! Обратно классы сериализуются первыми, затем ресурсы: порядок внутри
//! архива на поведение не влияет, а фиксированный порядок даёт
//! воспроизводимую запись.
use noxis_classfile::Program;
use noxis_classfile::format;
use noxis_classfile::model::Resource;
use crate::archive::{Entry, JarError};
/// Разбирает записи архива: классы — через парсер, остальное — как ресурсы.
pub fn load(entries: Vec<Entry>) -> Result<Program, JarError> {
let mut program = Program::default();
for entry in entries {
if is_class_entry(&entry.name) {
program.classes.push(format::parse(&entry.data)?);
} else {
program.resources.push(Resource {
path: entry.name,
data: entry.data,
});
}
}
Ok(program)
}
/// Собирает записи архива из [`Program`]: сначала классы, потом ресурсы.
pub fn save(program: &Program) -> Result<Vec<Entry>, JarError> {
let mut entries = Vec::with_capacity(program.classes.len() + program.resources.len());
for class in &program.classes {
let name = format!("{}.class", class.this_class);
entries.push(Entry::new(name, format::write(class)?));
}
for resource in &program.resources {
entries.push(Entry::new(resource.path.clone(), resource.data.clone()));
}
Ok(entries)
}
/// Классовый файл внутри архива: путь оканчивается на `.class`.
fn is_class_entry(name: &str) -> bool {
name.len() > ".class".len() && name.ends_with(".class")
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
#[test]
fn class_paths_are_recognised() {
assert!(is_class_entry("demo/App.class"));
assert!(is_class_entry("a/b/C.class"));
assert!(!is_class_entry(".class"));
assert!(!is_class_entry("classes.txt"));
assert!(!is_class_entry("demo/App.class.bak"));
}
#[test]
fn resources_round_trip_without_classes() {
let source = vec![
Entry::new("META-INF/MANIFEST.MF", b"Manifest-Version: 1.0\n".to_vec()),
Entry::new("config/app.json", br#"{"k":1}"#.to_vec()),
];
let program = load(source.clone()).expect("load");
assert!(program.classes.is_empty());
assert_eq!(program.resources.len(), 2);
assert_eq!(save(&program).expect("save"), source);
}
/// До P1 `format::parse` — заглушка; после P1 ожидается успешный разбор.
#[test]
fn class_entries_go_through_the_parser() {
let entries = vec![
Entry::new("demo/App.class", vec![0xCA, 0xFE]),
Entry::new("notes.txt", b"hi".to_vec()),
];
match load(entries) {
Ok(program) => {
assert_eq!(program.classes.len(), 1);
assert_eq!(program.resources.len(), 1);
}
Err(JarError::Classfile(err)) => {
assert!(!err.to_string().is_empty());
}
Err(other) => panic!("unexpected error: {other}"),
}
}
}

View file

@ -0,0 +1,103 @@
//! Срез невалидных подписей JAR.
//!
//! После перепаковки архива `META-INF/*.SF` и `*.RSA|DSA|EC` описывают
//! **старые** байты: JVM либо отказывается запускать jar, либо ругается на
//! подпись. Вырезать их обязательно — и это делается на уровне записей,
//! потому что `MANIFEST.MF` остаётся, а его секции `Name:` тоже нужно
//! выкинуть (ими занимается пасс `resources`).
use crate::archive::Entry;
/// Расширения файлов подписи в `META-INF/` (без учёта регистра).
const SIGNATURE_EXTS: [&str; 4] = [".SF", ".RSA", ".DSA", ".EC"];
/// Префикс блочных файлов подписи старого образца (`META-INF/SIG-*`).
const LEGACY_PREFIX: &str = "SIG-";
/// Путь каталога подписей.
const META_INF: &str = "META-INF/";
/// Проверяет, является ли запись архива файлом подписи.
///
/// Совпадение — только для файлов, лежащих **непосредственно** в
/// `META-INF/`: вложенный `META-INF/certs/x.SF` — обычный ресурс.
///
/// Регистр не важен (`meta-inf/x.sf` — тоже подпись), `MANIFEST.MF`
/// никогда не является подписью.
pub fn is_signature_entry(name: &str) -> bool {
let Some(relative) = name.strip_prefix(META_INF) else {
return false;
};
if relative.is_empty() || relative.contains('/') {
return false;
}
let upper = relative.to_ascii_uppercase();
if upper.starts_with(LEGACY_PREFIX) {
return true;
}
SIGNATURE_EXTS.iter().any(|ext| upper.ends_with(ext))
}
/// Удаляет подписи, возвращая оставшиеся записи и число удалённых.
///
/// Порядок остальных записей не меняется.
pub fn strip_signatures(entries: Vec<Entry>) -> (Vec<Entry>, usize) {
let before = entries.len();
let mut kept = entries;
kept.retain(|entry| !is_signature_entry(&entry.name));
let removed = before - kept.len();
(kept, removed)
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
#[test]
fn signature_files_under_meta_inf_are_detected() {
for name in [
"META-INF/FOO.SF",
"META-INF/foo.rsa",
"META-INF/FOO.DSA",
"META-INF/FOO.EC",
"META-INF/SIG-CUSTOM",
] {
assert!(is_signature_entry(name), "{name} should be a signature");
}
}
#[test]
fn manifest_and_resources_are_kept() {
for name in [
"META-INF/MANIFEST.MF",
"META-INF/services/demo.Loader",
"META-INF/certs/root.SF",
"demo/App.class",
"NOT-META-INF/x.SF",
] {
assert!(!is_signature_entry(name), "{name} must be kept");
}
}
#[test]
fn strip_removes_only_signatures_and_counts_them() {
let entries = vec![
Entry::new("META-INF/MANIFEST.MF", b"m".to_vec()),
Entry::new("META-INF/C.SF", b"s".to_vec()),
Entry::new("demo/A.class", b"c".to_vec()),
Entry::new("META-INF/C.RSA", b"b".to_vec()),
];
let (kept, removed) = strip_signatures(entries);
assert_eq!(removed, 2);
assert_eq!(
kept.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>(),
vec!["META-INF/MANIFEST.MF", "demo/A.class"]
);
}
}