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.
205 lines
7.8 KiB
Rust
205 lines
7.8 KiB
Rust
//! The strings pass: `Const::String` becomes runtime-decoder calls.
|
|
//!
|
|
//! The scheme rotates per string (`salt % n`), the salt is deterministic
|
|
//! (KDF or RNG stream - see `derive_key`). The decoder is generated once per
|
|
//! program and only if at least one string was encrypted.
|
|
|
|
mod crypt;
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use noxis_analysis::ClassHierarchy;
|
|
use noxis_classfile::{Const, Insn, MAX_METHOD_INSNS, Program, compute_frames};
|
|
use noxis_core::{Context, CoreError, NameStyle, Pass, PassId, PassReport, derive_seed};
|
|
use noxis_runtime::{Scheme, decoder_class, decoder_owner, encrypt};
|
|
|
|
use crate::namegen::NameGenerator;
|
|
use crypt::{decode_sequence, salt_for_scheme, split_chunks};
|
|
|
|
/// The string encryption pass.
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct StringsPass;
|
|
|
|
impl Pass for StringsPass {
|
|
fn id(&self) -> PassId {
|
|
PassId::Strings
|
|
}
|
|
|
|
fn apply(&self, ctx: &mut Context, program: &mut Program) -> Result<PassReport, CoreError> {
|
|
let cfg = ctx.config.strings.clone();
|
|
let mut schemes = Vec::with_capacity(cfg.schemes.len());
|
|
for name in &cfg.schemes {
|
|
schemes.push(Scheme::parse(name).map_err(|e| pass_error(e.to_string()))?);
|
|
}
|
|
if schemes.is_empty() {
|
|
return Err(pass_error("no encryption schemes"));
|
|
}
|
|
// Phase 1: collect every encryptable string (position + text).
|
|
let mut targets: Vec<(usize, usize, usize, String)> = Vec::new();
|
|
let mut skipped = 0usize;
|
|
for (ci, class) in program.classes.iter().enumerate() {
|
|
for (mi, method) in class.methods.iter().enumerate() {
|
|
let Some(code) = method.code.as_ref() else {
|
|
continue;
|
|
};
|
|
for (ii, insn) in code.insns.iter().enumerate() {
|
|
if let Insn::Const(Const::String(text)) = insn {
|
|
if text.chars().count() >= cfg.min_len && !is_skipped(text, &cfg.skip) {
|
|
targets.push((ci, mi, ii, text.clone()));
|
|
} else {
|
|
skipped += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if targets.is_empty() {
|
|
return Ok(report(
|
|
0,
|
|
skipped,
|
|
vec!["no strings to encrypt".to_string()],
|
|
));
|
|
}
|
|
// Phase 2: encrypt and substitute call sequences.
|
|
let master = ctx.master_seed;
|
|
let mut rng = ctx.rng("strings");
|
|
let owner = decoder_name(ctx, program);
|
|
let mut chunk_counter = 0usize;
|
|
let mut salt_counter = 0u64;
|
|
let mut replacements: BTreeMap<(usize, usize, usize), Vec<Insn>> = BTreeMap::new();
|
|
for (ci, mi, ii, text) in &targets {
|
|
let mut parts = Vec::new();
|
|
for chunk in split_chunks(text, cfg.split_above) {
|
|
let scheme = chunk_counter % schemes.len();
|
|
let raw = next_salt(&mut rng, master, &mut salt_counter, cfg.derive_key);
|
|
let salt = salt_for_scheme(raw, scheme, schemes.len());
|
|
let blob = encrypt(schemes[scheme], master, salt, &chunk);
|
|
parts.push((blob.cipher, salt));
|
|
chunk_counter += 1;
|
|
}
|
|
replacements.insert((*ci, *mi, *ii), decode_sequence(&owner, &parts));
|
|
}
|
|
let mut mutated: Vec<(usize, usize)> = Vec::new();
|
|
let mut applied = 0usize;
|
|
let mut per_method: BTreeMap<(usize, usize), usize> = BTreeMap::new();
|
|
for (ci, mi, _, _) in &targets {
|
|
*per_method.entry((*ci, *mi)).or_default() += 1;
|
|
}
|
|
for (ci, class) in program.classes.iter_mut().enumerate() {
|
|
for (mi, method) in class.methods.iter_mut().enumerate() {
|
|
let Some(code) = method.code.as_mut() else {
|
|
continue;
|
|
};
|
|
let original = std::mem::take(&mut code.insns);
|
|
let method_targets = per_method.get(&(ci, mi)).copied().unwrap_or(0);
|
|
let mut fresh = Vec::with_capacity(original.len());
|
|
let mut touched = false;
|
|
for (ii, insn) in original.iter().enumerate() {
|
|
if let Some(sequence) = replacements.remove(&(ci, mi, ii)) {
|
|
fresh.extend(sequence);
|
|
touched = true;
|
|
} else {
|
|
fresh.push(insn.clone());
|
|
}
|
|
}
|
|
if fresh.len() > MAX_METHOD_INSNS {
|
|
// Would risk the 64KB writer ceiling: keep the original
|
|
// code (and its strings) untouched.
|
|
code.insns = original;
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
code.insns = fresh;
|
|
if touched {
|
|
applied += method_targets;
|
|
code.max_stack = code.max_stack.saturating_add(2);
|
|
mutated.push((ci, mi));
|
|
}
|
|
}
|
|
}
|
|
// Phase 3: one decoder class per program.
|
|
let scheme_names: Vec<&str> = cfg.schemes.iter().map(String::as_str).collect();
|
|
let decoder =
|
|
decoder_class(&owner, master, &scheme_names).map_err(|e| pass_error(e.to_string()))?;
|
|
program.classes.push(decoder);
|
|
ctx.note(format!("string decoder: {owner}"));
|
|
// Phase 4: frames of the touched methods.
|
|
let hierarchy = ClassHierarchy::build(program);
|
|
for (ci, mi) in &mutated {
|
|
let (name, snapshot) = {
|
|
let class = program
|
|
.classes
|
|
.get(*ci)
|
|
.ok_or_else(|| pass_error("class vanished"))?;
|
|
(
|
|
class.this_class.clone(),
|
|
class
|
|
.methods
|
|
.get(*mi)
|
|
.cloned()
|
|
.ok_or_else(|| pass_error("method vanished"))?,
|
|
)
|
|
};
|
|
let frames = compute_frames(&name, &snapshot, &hierarchy)
|
|
.map_err(|e| pass_error(e.to_string()))?;
|
|
program
|
|
.classes
|
|
.get_mut(*ci)
|
|
.and_then(|c| c.methods.get_mut(*mi))
|
|
.and_then(|m| m.code.as_mut())
|
|
.ok_or_else(|| pass_error("method vanished"))?
|
|
.frames = frames;
|
|
}
|
|
Ok(report(
|
|
applied,
|
|
skipped,
|
|
vec![format!("strings encrypted: {applied}, decoder: {owner}")],
|
|
))
|
|
}
|
|
}
|
|
|
|
fn report(transformed: usize, skipped: usize, notes: Vec<String>) -> PassReport {
|
|
PassReport {
|
|
pass: PassId::Strings,
|
|
transformed,
|
|
skipped,
|
|
notes,
|
|
}
|
|
}
|
|
|
|
fn pass_error(msg: impl Into<String>) -> CoreError {
|
|
CoreError::pass(PassId::Strings, msg)
|
|
}
|
|
|
|
/// A string matches the `skip` patterns of the config.
|
|
fn is_skipped(text: &str, skip: &[String]) -> bool {
|
|
skip.iter().any(|pattern| text.contains(pattern))
|
|
}
|
|
|
|
/// A chunk salt: KDF from the position or an RNG stream (see `derive_key`).
|
|
fn next_salt(rng: &mut noxis_core::Rng, master: u64, counter: &mut u64, derive: bool) -> u64 {
|
|
let value = if derive {
|
|
derive_seed(master, "strings/salt", *counter)
|
|
} else {
|
|
rng.next_u64()
|
|
};
|
|
*counter += 1;
|
|
value
|
|
}
|
|
|
|
/// A collision-free deterministic decoder name.
|
|
fn decoder_name(ctx: &mut Context, program: &Program) -> String {
|
|
let mut names = NameGenerator::new(ctx.rng("strings/decoder"), NameStyle::ShortLatin);
|
|
for class in &program.classes {
|
|
names.reserve(class.this_class.clone());
|
|
}
|
|
loop {
|
|
let owner = decoder_owner(&format!("{}/{}", names.next_name(), names.next_name()));
|
|
if program.class(&owner).is_none() {
|
|
return owner;
|
|
}
|
|
}
|
|
}
|