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:
commit
a641e69455
140 changed files with 17197 additions and 0 deletions
18
crates/noxis-jar/Cargo.toml
Normal file
18
crates/noxis-jar/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "noxis-jar"
|
||||
description = "Чтение и запись JAR: ZIP-архив, ресурсы, срез подписей"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
noxis-classfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
noxis-structure-check = { workspace = true }
|
||||
3
crates/noxis-jar/build.rs
Normal file
3
crates/noxis-jar/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
noxis_structure_check::check();
|
||||
}
|
||||
157
crates/noxis-jar/src/archive.rs
Normal file
157
crates/noxis-jar/src/archive.rs
Normal 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(_)));
|
||||
}
|
||||
}
|
||||
22
crates/noxis-jar/src/lib.rs
Normal file
22
crates/noxis-jar/src/lib.rs
Normal 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};
|
||||
100
crates/noxis-jar/src/loader.rs
Normal file
100
crates/noxis-jar/src/loader.rs
Normal 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
103
crates/noxis-jar/src/signatures.rs
Normal file
103
crates/noxis-jar/src/signatures.rs
Normal 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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue