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
210
crates/noxis-core/src/config/mod.rs
Normal file
210
crates/noxis-core/src/config/mod.rs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
//! Конфигурация обфускации: профили, список пассов, keep-правила.
|
||||
|
||||
pub mod sections;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use self::sections::{
|
||||
ConstantsConfig, DebugConfig, FlowConfig, NoiseConfig, RenameConfig, StringsConfig,
|
||||
};
|
||||
use crate::error::CoreError;
|
||||
|
||||
/// Идентификатор пасса (он же ключ в конфиге, kebab-case).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PassId {
|
||||
/// Переименование классов, членов, локальных переменных, пакетов.
|
||||
Rename,
|
||||
/// Шифрование строк + генерация рантайм-декодера.
|
||||
Strings,
|
||||
/// Обфускация числовых констант и настоящие opaque-предикаты.
|
||||
Constants,
|
||||
/// Обфускация контрольного потока.
|
||||
Flow,
|
||||
/// Junk-классы, junk-аннотации, ложный шум.
|
||||
Noise,
|
||||
/// Отладочная информация: strip / spoof.
|
||||
DebugInfo,
|
||||
/// Ресурсы и метаданные архива (JSON, mixins, refmap, services, AW).
|
||||
Resources,
|
||||
}
|
||||
|
||||
impl PassId {
|
||||
/// Строковое имя для отчётов и логов.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
PassId::Rename => "rename",
|
||||
PassId::Strings => "strings",
|
||||
PassId::Constants => "constants",
|
||||
PassId::Flow => "flow",
|
||||
PassId::Noise => "noise",
|
||||
PassId::DebugInfo => "debug-info",
|
||||
PassId::Resources => "resources",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PassId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Базовый набор пассов профиля.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Profile {
|
||||
/// Только переименование + отладка. Быстро, минимальный риск сломать код.
|
||||
Light,
|
||||
/// Стандарт: имена, строки, константы, ресурсы, отладка, шум.
|
||||
#[default]
|
||||
Standard,
|
||||
/// Всё включено, включая агрессивный control-flow.
|
||||
Paranoid,
|
||||
/// Ничего не включено явно — только `enable` из конфига.
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
/// Пассы, включённые профилем по умолчанию.
|
||||
pub const fn baseline(self) -> &'static [PassId] {
|
||||
match self {
|
||||
Profile::Light => &[PassId::Rename, PassId::DebugInfo, PassId::Resources],
|
||||
Profile::Standard => &[
|
||||
PassId::Rename,
|
||||
PassId::Strings,
|
||||
PassId::Constants,
|
||||
PassId::Noise,
|
||||
PassId::DebugInfo,
|
||||
PassId::Resources,
|
||||
],
|
||||
Profile::Paranoid => &[
|
||||
PassId::Rename,
|
||||
PassId::Strings,
|
||||
PassId::Constants,
|
||||
PassId::Flow,
|
||||
PassId::Noise,
|
||||
PassId::DebugInfo,
|
||||
PassId::Resources,
|
||||
],
|
||||
Profile::Manual => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Полная конфигурация (TOML).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub profile: Profile,
|
||||
/// Мастер-сид; `None` — случайный, но обязательно попадает в отчёт.
|
||||
pub seed: Option<u64>,
|
||||
/// Принудительно включить пассы вне профиля.
|
||||
pub enable: Vec<PassId>,
|
||||
/// Выключить пассы профиля.
|
||||
pub disable: Vec<PassId>,
|
||||
/// Keep-паттерны внутренних имён (`com/example/Main`, `*Service`).
|
||||
pub keep: Vec<String>,
|
||||
/// Keep-паттерны путей ресурсов, которые не переписываются.
|
||||
pub keep_resources: Vec<String>,
|
||||
pub rename: RenameConfig,
|
||||
pub strings: StringsConfig,
|
||||
pub constants: ConstantsConfig,
|
||||
pub flow: FlowConfig,
|
||||
pub noise: NoiseConfig,
|
||||
pub debug: DebugConfig,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
profile: Profile::Standard,
|
||||
seed: None,
|
||||
enable: Vec::new(),
|
||||
disable: Vec::new(),
|
||||
keep: Vec::new(),
|
||||
keep_resources: Vec::new(),
|
||||
rename: RenameConfig::default(),
|
||||
strings: StringsConfig::default(),
|
||||
constants: ConstantsConfig::default(),
|
||||
flow: FlowConfig::default(),
|
||||
noise: NoiseConfig::default(),
|
||||
debug: DebugConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Включён ли пасс с учётом профиля и явных override'ов.
|
||||
pub fn is_enabled(&self, id: PassId) -> bool {
|
||||
if self.disable.contains(&id) {
|
||||
return false;
|
||||
}
|
||||
if self.enable.contains(&id) {
|
||||
return true;
|
||||
}
|
||||
self.profile.baseline().contains(&id)
|
||||
}
|
||||
|
||||
/// Загрузить конфиг из TOML.
|
||||
pub fn from_toml(text: &str) -> Result<Self, CoreError> {
|
||||
toml::from_str(text).map_err(|e| CoreError::Config(e.to_string()))
|
||||
}
|
||||
|
||||
/// Нормализовать значения, которые легко задать нулем/пустым списком.
|
||||
pub fn sanitize(&mut self) {
|
||||
if self.rename.package_pool == 0 {
|
||||
self.rename.package_pool = 1;
|
||||
}
|
||||
if self.strings.min_len == 0 {
|
||||
self.strings.min_len = 1;
|
||||
}
|
||||
self.strings.schemes.retain(|s| !s.trim().is_empty());
|
||||
self.flow.schemes.retain(|s| !s.trim().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn profile_gate() {
|
||||
let mut cfg = Config::default();
|
||||
assert!(cfg.is_enabled(PassId::Rename));
|
||||
assert!(cfg.is_enabled(PassId::Strings));
|
||||
assert!(!cfg.is_enabled(PassId::Flow));
|
||||
|
||||
cfg.profile = Profile::Paranoid;
|
||||
assert!(cfg.is_enabled(PassId::Flow));
|
||||
|
||||
cfg.disable.push(PassId::Strings);
|
||||
assert!(!cfg.is_enabled(PassId::Strings));
|
||||
cfg.enable.push(PassId::Flow);
|
||||
cfg.profile = Profile::Light;
|
||||
assert!(cfg.is_enabled(PassId::Flow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_toml() {
|
||||
let cfg = Config::from_toml(
|
||||
r#"
|
||||
profile = "paranoid"
|
||||
seed = 123
|
||||
keep = ["com/example/Main"]
|
||||
[rename]
|
||||
package_pool = 4
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.profile, Profile::Paranoid);
|
||||
assert_eq!(cfg.seed, Some(123));
|
||||
assert_eq!(cfg.rename.package_pool, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_keys() {
|
||||
assert!(Config::from_toml("totally_unknown = 1").is_err());
|
||||
}
|
||||
}
|
||||
185
crates/noxis-core/src/config/sections.rs
Normal file
185
crates/noxis-core/src/config/sections.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
//! Секции конфигурации: стили имён и настройки отдельных пассов.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Стиль генерации имён.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum NameStyle {
|
||||
/// `a`, `b`, ... `aa` — короткие латинские.
|
||||
ShortLatin,
|
||||
/// `I`, `l`, `O`, `0` — визуально одинаковые.
|
||||
IlO0,
|
||||
/// Случайные unicode-имена (суррогаты допустимы в modified UTF-8).
|
||||
Unicode,
|
||||
/// Смешанная латиница средней длины.
|
||||
#[default]
|
||||
MixedLatin,
|
||||
/// Псевдосмысленные слова из другого проекта (дезориентация).
|
||||
DecoyWords,
|
||||
}
|
||||
|
||||
/// Настройки переименования.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct RenameConfig {
|
||||
/// Переименовывать пакеты в случайные пути.
|
||||
pub spread_packages: bool,
|
||||
/// Размер пула случайных пакетов.
|
||||
pub package_pool: usize,
|
||||
/// Стиль имён классов.
|
||||
pub class_style: NameStyle,
|
||||
/// Стиль имён полей и методов.
|
||||
pub member_style: NameStyle,
|
||||
/// Стиль имён локальных переменных и параметров.
|
||||
pub local_style: NameStyle,
|
||||
/// Делать члены `public` (чтобы не ломать inheritance между пакетами).
|
||||
pub widen_access: bool,
|
||||
/// Переименовывать enum-константы.
|
||||
pub rename_enum_constants: bool,
|
||||
/// Переименовывать локальные переменные и параметры.
|
||||
pub rename_locals: bool,
|
||||
/// Переименовывать и не-private члены (агрессивно: безопасно для приложений,
|
||||
/// опасно для библиотек и кода с рефлексией).
|
||||
pub rename_public_members: bool,
|
||||
}
|
||||
|
||||
impl Default for RenameConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spread_packages: true,
|
||||
package_pool: 12,
|
||||
class_style: NameStyle::ShortLatin,
|
||||
member_style: NameStyle::MixedLatin,
|
||||
local_style: NameStyle::IlO0,
|
||||
widen_access: true,
|
||||
rename_enum_constants: true,
|
||||
rename_locals: true,
|
||||
rename_public_members: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Настройки шифрования строк.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct StringsConfig {
|
||||
/// Схемы шифра, между которыми перемешиваются строки.
|
||||
pub schemes: Vec<String>,
|
||||
/// Строки короче этого не шифруются.
|
||||
pub min_len: usize,
|
||||
/// Дробить длинные строки на чанки (меньше похоже на пул).
|
||||
pub split_above: usize,
|
||||
/// Не шифровать строки, совпадающие с этими паттернами.
|
||||
pub skip: Vec<String>,
|
||||
/// Вычислять ключ из KDF(class, method, index), а не класть рядом с текстом.
|
||||
pub derive_key: bool,
|
||||
}
|
||||
|
||||
impl Default for StringsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schemes: vec!["xor-splitmix".to_string(), "xtea-ctr".to_string()],
|
||||
min_len: 2,
|
||||
split_above: 64,
|
||||
skip: Vec::new(),
|
||||
derive_key: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Настройки обфускации контрольного потока.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct FlowConfig {
|
||||
/// Схемы, между которыми выбирается случайно по методу.
|
||||
pub schemes: Vec<String>,
|
||||
/// Не трогать методы больше этого числа инструкций (бюджет размера).
|
||||
pub max_insns: usize,
|
||||
/// Вплетать opaque-предикаты в тела блоков.
|
||||
pub encrypt_block_bodies: bool,
|
||||
}
|
||||
|
||||
impl Default for FlowConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schemes: vec![
|
||||
"state-spread".to_string(),
|
||||
"computed-switch".to_string(),
|
||||
"butterfly".to_string(),
|
||||
"exception-goto".to_string(),
|
||||
],
|
||||
max_insns: 4000,
|
||||
encrypt_block_bodies: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Настройки шифрования констант.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ConstantsConfig {
|
||||
/// Схемы разбиения (`xor`, `add`, `shift`), выбор по RNG на константу.
|
||||
pub schemes: Vec<String>,
|
||||
/// Вставлять opaque-предикаты (не больше этого на метод).
|
||||
pub max_predicates: usize,
|
||||
/// Не трогать константы в этих классах (префиксы внутренних имён).
|
||||
pub skip: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ConstantsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schemes: vec!["xor".to_string(), "add".to_string(), "shift".to_string()],
|
||||
max_predicates: 1,
|
||||
skip: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Настройки шума.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct NoiseConfig {
|
||||
/// Сколько junk-классов на каждый настоящий (0 = выключено).
|
||||
pub junk_ratio: usize,
|
||||
/// Число junk-аннотаций на класс.
|
||||
pub junk_annotations: usize,
|
||||
/// Вплетать junk-классы в граф достижимости (обязательно для осмысленности).
|
||||
pub weave_reachable: bool,
|
||||
}
|
||||
|
||||
impl Default for NoiseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
junk_ratio: 0,
|
||||
junk_annotations: 0,
|
||||
weave_reachable: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Настройки отладочной информации.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct DebugConfig {
|
||||
/// Удалять `LineNumberTable`.
|
||||
pub strip_line_numbers: bool,
|
||||
/// Удалять `LocalVariableTable`.
|
||||
pub strip_local_variables: bool,
|
||||
/// Подменять `SourceFile` на ложное имя.
|
||||
pub spoof_source_file: bool,
|
||||
/// Удалять `Signature`/`InnerClasses`/`EnclosingMethod`.
|
||||
pub strip_signatures: bool,
|
||||
}
|
||||
|
||||
impl Default for DebugConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
strip_line_numbers: true,
|
||||
strip_local_variables: true,
|
||||
spoof_source_file: true,
|
||||
strip_signatures: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
29
crates/noxis-core/src/error.rs
Normal file
29
crates/noxis-core/src/error.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
//! Ошибки ядра пайплайна.
|
||||
|
||||
use crate::config::PassId;
|
||||
|
||||
/// Ошибка уровня ядра.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CoreError {
|
||||
#[error("ошибка ввода-вывода: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("некорректная конфигурация: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("ошибка классового файла: {0}")]
|
||||
Classfile(#[from] noxis_classfile::ClassfileError),
|
||||
|
||||
#[error("пасс `{pass}` не смог выполниться: {msg}")]
|
||||
Pass { pass: PassId, msg: String },
|
||||
}
|
||||
|
||||
impl CoreError {
|
||||
/// Ошибка конкретного пасса.
|
||||
pub fn pass(pass: PassId, msg: impl Into<String>) -> Self {
|
||||
Self::Pass {
|
||||
pass,
|
||||
msg: msg.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
25
crates/noxis-core/src/lib.rs
Normal file
25
crates/noxis-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! # noxis-core
|
||||
//!
|
||||
//! Ядро обфускатора: конфигурация, детерминированный seed, маппинги,
|
||||
//! контекст прогона и контракт пассов ([`pipeline::Pass`]).
|
||||
//!
|
||||
//! Ядро ничего не знает о бинарном формате `.class` и о ZIP — только о модели
|
||||
//! [`noxis_classfile::Program`]. Сборка конвейера из пассов живёт в
|
||||
//! `noxis-passes`, чтобы не было цикла зависимостей.
|
||||
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod mapping;
|
||||
pub mod pipeline;
|
||||
pub mod rng;
|
||||
|
||||
pub use config::sections::{
|
||||
ConstantsConfig, DebugConfig, FlowConfig, NameStyle, NoiseConfig, RenameConfig, StringsConfig,
|
||||
};
|
||||
pub use config::{Config, PassId, Profile};
|
||||
pub use error::CoreError;
|
||||
pub use mapping::{Mapping, field_key, method_key};
|
||||
pub use pipeline::context::Context;
|
||||
pub use pipeline::report::Report;
|
||||
pub use pipeline::{Pass, PassReport, Pipeline};
|
||||
pub use rng::{Rng, derive_seed};
|
||||
112
crates/noxis-core/src/mapping.rs
Normal file
112
crates/noxis-core/src/mapping.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
//! Обратная таблица маппингов: деобфускация имён и stack-trace translate.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Ключ метода: `owner/name+descriptor`.
|
||||
pub fn method_key(owner: &str, name: &str, descriptor: &str) -> String {
|
||||
format!("{owner}/{name}{descriptor}")
|
||||
}
|
||||
|
||||
/// Ключ поля: `owner/name:descriptor`.
|
||||
pub fn field_key(owner: &str, name: &str, descriptor: &str) -> String {
|
||||
format!("{owner}/{name}:{descriptor}")
|
||||
}
|
||||
|
||||
/// Таблицы «оригинал → обфусцированное».
|
||||
///
|
||||
/// Нужна по двум причинам: (1) перевод stack trace'ей обратно в читаемые имена
|
||||
/// (аналог ZKM Stack Trace Translate), (2) аудит — что именно поменялось.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct Mapping {
|
||||
/// internal name класса → новый internal name
|
||||
pub classes: BTreeMap<String, String>,
|
||||
/// `owner/name+desc` → новое имя метода (владелец остаётся прежним по ключу)
|
||||
pub methods: BTreeMap<String, String>,
|
||||
/// `owner/name:desc` → новое имя поля
|
||||
pub fields: BTreeMap<String, String>,
|
||||
/// имя локальной переменной/параметра в методе → новое (по номеру слота)
|
||||
pub locals: BTreeMap<String, String>,
|
||||
/// оригинальный путь ресурса → новый
|
||||
pub resources: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Mapping {
|
||||
/// Новое имя класса, если он переименован.
|
||||
pub fn class(&self, from: &str) -> Option<&str> {
|
||||
self.classes.get(from).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Новое имя метода.
|
||||
pub fn method(&self, owner: &str, name: &str, descriptor: &str) -> Option<&str> {
|
||||
self.methods
|
||||
.get(&method_key(owner, name, descriptor))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
/// Новое имя поля.
|
||||
pub fn field(&self, owner: &str, name: &str, descriptor: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.get(&field_key(owner, name, descriptor))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
/// Число переименований всех видов.
|
||||
pub fn len(&self) -> usize {
|
||||
self.classes.len() + self.methods.len() + self.fields.len() + self.locals.len()
|
||||
}
|
||||
|
||||
/// Пустая ли таблица.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Человекочитаемая таблица для файла маппингов и deobfuscator'а.
|
||||
pub fn to_text(&self) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("# classes\n");
|
||||
for (from, to) in &self.classes {
|
||||
out.push_str(&format!("{from} -> {to}\n"));
|
||||
}
|
||||
out.push_str("\n# methods\n");
|
||||
for (from, to) in &self.methods {
|
||||
out.push_str(&format!("{from} -> {to}\n"));
|
||||
}
|
||||
out.push_str("\n# fields\n");
|
||||
for (from, to) in &self.fields {
|
||||
out.push_str(&format!("{from} -> {to}\n"));
|
||||
}
|
||||
out.push_str("\n# resources\n");
|
||||
for (from, to) in &self.resources {
|
||||
out.push_str(&format!("{from} -> {to}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_lookup() {
|
||||
let mut m = Mapping::default();
|
||||
m.classes.insert("com/example/Main".into(), "a/B".into());
|
||||
m.methods
|
||||
.insert(method_key("a/B", "run", "()V"), "x".into());
|
||||
assert_eq!(m.class("com/example/Main"), Some("a/B"));
|
||||
assert_eq!(m.method("a/B", "run", "()V"), Some("x"));
|
||||
assert!(m.method("a/B", "other", "()V").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_report_contains_sections() {
|
||||
let mut m = Mapping::default();
|
||||
m.classes.insert("A".into(), "B".into());
|
||||
let text = m.to_text();
|
||||
assert!(text.contains("# classes"));
|
||||
assert!(text.contains("A -> B"));
|
||||
}
|
||||
}
|
||||
94
crates/noxis-core/src/pipeline/context.rs
Normal file
94
crates/noxis-core/src/pipeline/context.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! Контекст прогона: конфиг, мастер-сид, маппинги, заметки.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::mapping::Mapping;
|
||||
use crate::rng::{Rng, derive_seed};
|
||||
|
||||
/// Всё состояние, которое пассы читают и дописывают.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Context {
|
||||
pub config: Config,
|
||||
/// Мастер-сид прогона (всегда известен и попадает в отчёт).
|
||||
pub master_seed: u64,
|
||||
pub mapping: Mapping,
|
||||
/// Заметки пассов: почему что-то пропущено, что не удалось.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
/// Новый контекст. `seed` берётся из конфига или генерируется.
|
||||
pub fn new(mut config: Config) -> Self {
|
||||
config.sanitize();
|
||||
let master_seed = config.seed.unwrap_or_else(fresh_seed);
|
||||
Self {
|
||||
config,
|
||||
master_seed,
|
||||
mapping: Mapping::default(),
|
||||
notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Детерминированный RNG для домена (`"rename"`, `"strings/0"`, ...).
|
||||
///
|
||||
/// Доменное разделение: разные пассы не должны вытягивать одну
|
||||
/// последовательность — иначе их «случайности» статистически коррелируют.
|
||||
pub fn rng(&self, domain: &str) -> Rng {
|
||||
Rng::from_seed(derive_seed(self.master_seed, domain, 0))
|
||||
}
|
||||
|
||||
/// RNG с порядковым номером внутри домена.
|
||||
pub fn rng_nth(&self, domain: &str, index: u64) -> Rng {
|
||||
Rng::from_seed(derive_seed(self.master_seed, domain, index))
|
||||
}
|
||||
|
||||
/// Заметка в отчёт (видна в JSON-отчёте и в логе).
|
||||
pub fn note(&mut self, msg: impl Into<String>) {
|
||||
let msg = msg.into();
|
||||
tracing::debug!(target: "noxis", "{msg}");
|
||||
self.notes.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Нестабильный сид, когда пользователь его не задал.
|
||||
fn fresh_seed() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0x5EED_C0DE);
|
||||
let mut r = Rng::from_seed(nanos ^ 0xA5A5_A5A5_A5A5_A5A5);
|
||||
r.next_u64()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rng_is_reproducible_from_config_seed() {
|
||||
let cfg = Config {
|
||||
seed: Some(777),
|
||||
..Config::default()
|
||||
};
|
||||
let a = Context::new(cfg.clone());
|
||||
let b = Context::new(cfg);
|
||||
assert_eq!(a.master_seed, b.master_seed);
|
||||
let av: Vec<u64> = {
|
||||
let mut r = a.rng("rename");
|
||||
(0..8).map(|_| r.next_u64()).collect()
|
||||
};
|
||||
let bv: Vec<u64> = {
|
||||
let mut r = b.rng("rename");
|
||||
(0..8).map(|_| r.next_u64()).collect()
|
||||
};
|
||||
assert_eq!(av, bv);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_is_recorded() {
|
||||
let mut ctx = Context::new(Config::default());
|
||||
ctx.note("тест");
|
||||
assert_eq!(ctx.notes.len(), 1);
|
||||
}
|
||||
}
|
||||
146
crates/noxis-core/src/pipeline/mod.rs
Normal file
146
crates/noxis-core/src/pipeline/mod.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
//! Конвейер пассов: единый порядок исполнения и отчёт.
|
||||
|
||||
pub mod context;
|
||||
pub mod report;
|
||||
|
||||
use serde::Serialize;
|
||||
use noxis_classfile::Program;
|
||||
|
||||
use self::context::Context;
|
||||
use self::report::Report;
|
||||
use crate::config::PassId;
|
||||
use crate::error::CoreError;
|
||||
|
||||
/// Результат одного пасса.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PassReport {
|
||||
pub pass: PassId,
|
||||
/// Сколько объектов трансформировано.
|
||||
pub transformed: usize,
|
||||
/// Сколько объектов сознательно пропущено.
|
||||
pub skipped: usize,
|
||||
/// Человекочитаемые заметки пасса.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl PassReport {
|
||||
/// Пустой отчёт (пасс ещё не реализован).
|
||||
pub fn not_implemented(pass: PassId, todo: &'static str) -> Self {
|
||||
Self {
|
||||
pass,
|
||||
transformed: 0,
|
||||
skipped: 0,
|
||||
notes: vec![format!("не реализовано: {todo}")],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Единый контракт трансформации программы.
|
||||
pub trait Pass: Send + Sync {
|
||||
/// Идентификатор пасса (совпадает с ключом в конфиге).
|
||||
fn id(&self) -> PassId;
|
||||
|
||||
/// Применить пасс. Порядок пассов задаёт [`Pipeline`].
|
||||
fn apply(&self, ctx: &mut Context, program: &mut Program) -> Result<PassReport, CoreError>;
|
||||
}
|
||||
|
||||
/// Упорядоченная цепочка пассов.
|
||||
///
|
||||
/// Порядок — не деталь: см. `docs/architecture.md` (rename → resources →
|
||||
/// strings → constants → flow → noise → debug), иначе часть техник ломает
|
||||
/// предыдущие или работает по уже мёртвым именам.
|
||||
#[derive(Default)]
|
||||
pub struct Pipeline {
|
||||
passes: Vec<Box<dyn Pass>>,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
/// Пустой конвейер.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Добавить пасс в конец.
|
||||
pub fn push(mut self, pass: Box<dyn Pass>) -> Self {
|
||||
self.passes.push(pass);
|
||||
self
|
||||
}
|
||||
|
||||
/// Число пассов в цепочке.
|
||||
pub fn len(&self) -> usize {
|
||||
self.passes.len()
|
||||
}
|
||||
|
||||
/// Пуст ли конвейер.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.passes.is_empty()
|
||||
}
|
||||
|
||||
/// Прогнать всю программу через все пассы.
|
||||
pub fn run(&self, ctx: &mut Context, program: &mut Program) -> Result<Report, CoreError> {
|
||||
let mut report = Report {
|
||||
seed: ctx.master_seed,
|
||||
..Report::default()
|
||||
};
|
||||
report.bump("classes", program.classes.len());
|
||||
report.bump("resources", program.resources.len());
|
||||
|
||||
for pass in &self.passes {
|
||||
let id = pass.id();
|
||||
tracing::info!(target: "noxis", pass = %id, "пасс запущен");
|
||||
let result = pass.apply(ctx, program)?;
|
||||
tracing::info!(
|
||||
target: "noxis",
|
||||
pass = %id,
|
||||
transformed = result.transformed,
|
||||
skipped = result.skipped,
|
||||
"пасс завершён"
|
||||
);
|
||||
report.passes.push(result);
|
||||
}
|
||||
|
||||
report.notes = std::mem::take(&mut ctx.notes);
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
struct Stub(PassId);
|
||||
|
||||
impl Pass for Stub {
|
||||
fn id(&self) -> PassId {
|
||||
self.0
|
||||
}
|
||||
|
||||
fn apply(
|
||||
&self,
|
||||
_ctx: &mut Context,
|
||||
_program: &mut Program,
|
||||
) -> Result<PassReport, CoreError> {
|
||||
Ok(PassReport {
|
||||
pass: self.0,
|
||||
transformed: 1,
|
||||
skipped: 0,
|
||||
notes: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_in_order_and_collects_reports() {
|
||||
let pipeline = Pipeline::new()
|
||||
.push(Box::new(Stub(PassId::Rename)))
|
||||
.push(Box::new(Stub(PassId::Strings)));
|
||||
let mut ctx = Context::new(crate::config::Config::default());
|
||||
let mut program = Program::default();
|
||||
let report = pipeline.run(&mut ctx, &mut program).unwrap();
|
||||
assert_eq!(report.passes.len(), 2);
|
||||
assert_eq!(report.passes[0].pass, PassId::Rename);
|
||||
assert_eq!(report.passes[1].pass, PassId::Strings);
|
||||
assert_eq!(report.counters.get("classes"), Some(&0));
|
||||
}
|
||||
}
|
||||
20
crates/noxis-core/src/pipeline/report.rs
Normal file
20
crates/noxis-core/src/pipeline/report.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! Итог прогона: пассы + счётчики — сериализуется в JSON.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Итог прогона: пассы + счётчики — сериализуется в JSON.
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct Report {
|
||||
pub seed: u64,
|
||||
pub passes: Vec<crate::pipeline::PassReport>,
|
||||
pub notes: Vec<String>,
|
||||
/// Произвольные счётчики (`classes`, `strings_encrypted`, ...).
|
||||
pub counters: std::collections::BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// Увеличить счётчик.
|
||||
pub fn bump(&mut self, key: &str, by: usize) {
|
||||
*self.counters.entry(key.to_string()).or_insert(0) += by;
|
||||
}
|
||||
}
|
||||
168
crates/noxis-core/src/rng.rs
Normal file
168
crates/noxis-core/src/rng.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
//! Детерминированный PRNG и вывод производных seed'ов.
|
||||
//!
|
||||
//! Свой генератор, а не `rand`: обфускация должна быть **воспроизводимой между
|
||||
//! версиями инструмента** — алгоритм замирает в формате, а не в изменении API
|
||||
//! чужого крейта. Схема: SplitMix64 для инициализации + xoshiro256++ для генерации.
|
||||
|
||||
/// Генератор xoshiro256++.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rng {
|
||||
s: [u64; 4],
|
||||
}
|
||||
|
||||
const PHI: u64 = 0x9E37_79B9_7F4A_7C15;
|
||||
|
||||
fn splitmix64(state: &mut u64) -> u64 {
|
||||
*state = state.wrapping_add(PHI);
|
||||
let mut z = *state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
fn rotl(x: u64, k: u32) -> u64 {
|
||||
x.rotate_left(k)
|
||||
}
|
||||
|
||||
impl Rng {
|
||||
/// Генератор из мастер-сиди.
|
||||
pub fn from_seed(seed: u64) -> Self {
|
||||
let mut sm = seed;
|
||||
let s = [
|
||||
splitmix64(&mut sm),
|
||||
splitmix64(&mut sm),
|
||||
splitmix64(&mut sm),
|
||||
splitmix64(&mut sm),
|
||||
];
|
||||
Self { s }
|
||||
}
|
||||
|
||||
/// Следующее 64-битное значение.
|
||||
pub fn next_u64(&mut self) -> u64 {
|
||||
let result = rotl(self.s[0].wrapping_add(self.s[3]), 23).wrapping_add(self.s[0]);
|
||||
let t = self.s[1] << 17;
|
||||
self.s[2] ^= self.s[0];
|
||||
self.s[3] ^= self.s[1];
|
||||
self.s[1] ^= self.s[2];
|
||||
self.s[0] ^= self.s[3];
|
||||
self.s[2] ^= t;
|
||||
self.s[3] = rotl(self.s[3], 45);
|
||||
result
|
||||
}
|
||||
|
||||
/// Значение из `[0, bound)` без смещения (отбраковка перекоса).
|
||||
pub fn below(&mut self, bound: u64) -> u64 {
|
||||
assert!(bound > 0, "bound должен быть больше нуля");
|
||||
// Пространство [0, 2^64) режем на куски размером bound; хвост отбрасываем.
|
||||
let limit = u64::MAX - (u64::MAX % bound);
|
||||
loop {
|
||||
let v = self.next_u64();
|
||||
if v < limit {
|
||||
return v % bound;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Случайный элемент среза.
|
||||
pub fn choose<'a, T>(&mut self, items: &'a [T]) -> Option<&'a T> {
|
||||
if items.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&items[self.below(items.len() as u64) as usize])
|
||||
}
|
||||
}
|
||||
|
||||
/// Перемешать срез на месте (Fisher–Yates).
|
||||
pub fn shuffle<T>(&mut self, items: &mut [T]) {
|
||||
for i in (1..items.len()).rev() {
|
||||
let j = self.below((i + 1) as u64) as usize;
|
||||
items.swap(i, j);
|
||||
}
|
||||
}
|
||||
|
||||
/// Случайные байты.
|
||||
pub fn fill_bytes(&mut self, out: &mut [u8]) {
|
||||
for chunk in out.chunks_mut(8) {
|
||||
let v = self.next_u64().to_le_bytes();
|
||||
let n = chunk.len();
|
||||
chunk.copy_from_slice(&v[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Вектор случайных байтов.
|
||||
pub fn bytes(&mut self, len: usize) -> Vec<u8> {
|
||||
let mut v = vec![0u8; len];
|
||||
self.fill_bytes(&mut v);
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a 64 — детерминированный вывод производного сида.
|
||||
///
|
||||
/// Разделение доменов обязательно: пассы не должны делить одну и ту же
|
||||
/// последовательность (иначе переименование и шифрование «случайно»
|
||||
/// коррелируют, и это видно статистикой).
|
||||
pub fn derive_seed(master: u64, domain: &str, index: u64) -> u64 {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
let mut mix = |byte: u8| {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01B3);
|
||||
};
|
||||
for b in master.to_le_bytes() {
|
||||
mix(b);
|
||||
}
|
||||
for b in domain.as_bytes() {
|
||||
mix(*b);
|
||||
}
|
||||
for b in index.to_le_bytes() {
|
||||
mix(b);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deterministic_across_instances() {
|
||||
let mut a = Rng::from_seed(42);
|
||||
let mut b = Rng::from_seed(42);
|
||||
let av: Vec<u64> = (0..32).map(|_| a.next_u64()).collect();
|
||||
let bv: Vec<u64> = (0..32).map(|_| b.next_u64()).collect();
|
||||
assert_eq!(av, bv);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_diverge() {
|
||||
let mut a = Rng::from_seed(1);
|
||||
let mut b = Rng::from_seed(2);
|
||||
assert_ne!(a.next_u64(), b.next_u64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_respects_bound() {
|
||||
let mut r = Rng::from_seed(7);
|
||||
for _ in 0..1000 {
|
||||
assert!(r.below(10) < 10);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_seed_is_domain_separated() {
|
||||
assert_ne!(derive_seed(1, "rename", 0), derive_seed(1, "strings", 0));
|
||||
assert_ne!(derive_seed(1, "rename", 0), derive_seed(1, "rename", 1));
|
||||
assert_eq!(derive_seed(1, "rename", 3), derive_seed(1, "rename", 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shuffle_is_permutation() {
|
||||
let mut r = Rng::from_seed(9);
|
||||
let mut v: Vec<u32> = (0..50).collect();
|
||||
r.shuffle(&mut v);
|
||||
let mut sorted = v.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(sorted, (0..50).collect::<Vec<u32>>());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue