diff --git a/crates/noxis-core/src/config/from_toml/sections.rs b/crates/noxis-core/src/config/from_toml/sections.rs index 5dea8fd..0f320a5 100644 --- a/crates/noxis-core/src/config/from_toml/sections.rs +++ b/crates/noxis-core/src/config/from_toml/sections.rs @@ -69,7 +69,14 @@ pub(super) fn build_rename(t: &Table) -> Res { pub(super) fn build_strings(t: &Table) -> Res { check_known_keys( t, - &["schemes", "min_len", "split_above", "skip", "derive_key"], + &[ + "schemes", + "min_len", + "split_above", + "skip", + "derive_key", + "condy", + ], "strings", )?; let mut c = StringsConfig::default(); @@ -88,6 +95,9 @@ pub(super) fn build_strings(t: &Table) -> Res { if let Some(v) = t.get("derive_key") { c.derive_key = expect_bool(v, "strings.derive_key")?; } + if let Some(v) = t.get("condy") { + c.condy = expect_bool(v, "strings.condy")?; + } Ok(c) } diff --git a/crates/noxis-core/src/config/from_toml/tests.rs b/crates/noxis-core/src/config/from_toml/tests.rs index 5342e79..9f496c8 100644 --- a/crates/noxis-core/src/config/from_toml/tests.rs +++ b/crates/noxis-core/src/config/from_toml/tests.rs @@ -62,3 +62,10 @@ fn parses_name_style_in_rename_section() { let cfg = build_from("[rename]\nclass_style = \"il-o0\"\n").unwrap(); assert_eq!(cfg.rename.class_style, NameStyle::IlO0); } + +#[test] +fn parses_condy_flag_in_strings_section() { + let cfg = build_from("[strings]\ncondy = true\n").unwrap(); + assert!(cfg.strings.condy); + assert!(!Config::default().strings.condy); +} diff --git a/crates/noxis-core/src/config/sections.rs b/crates/noxis-core/src/config/sections.rs index d11ec33..a3ad32a 100644 --- a/crates/noxis-core/src/config/sections.rs +++ b/crates/noxis-core/src/config/sections.rs @@ -79,6 +79,12 @@ pub struct StringsConfig { /// Derive the key from KDF(class, method, index) instead of placing it /// next to the ciphertext. pub derive_key: bool, + /// Emit strings as `CONSTANT_Dynamic` (condy) resolved by the decoder's + /// bootstrap method instead of direct `d(cipher, salt)` calls. A + /// concrete-execution deobfuscator cannot follow the condy resolution + /// (todo.md, 2026-10-14: SSVM made 0 changes on a condy-only class). + /// Requires classfile >= 55; older classes keep direct calls. + pub condy: bool, } impl Default for StringsConfig { @@ -89,6 +95,7 @@ impl Default for StringsConfig { split_above: 64, skip: Vec::new(), derive_key: true, + condy: false, } } } diff --git a/crates/noxis-passes/src/strings/condy.rs b/crates/noxis-passes/src/strings/condy.rs new file mode 100644 index 0000000..f0b4647 --- /dev/null +++ b/crates/noxis-passes/src/strings/condy.rs @@ -0,0 +1,76 @@ +//! Condensed emission (`CONSTANT_Dynamic`): the JVM resolves the string +//! itself through the decoder's bootstrap method instead of us calling `d`. +//! +//! Why: a concrete-execution deobfuscator (narumii/SSVM, todo.md 2026-10-14) +//! follows `invokestatic d` and recovers the plaintext, but made **0 +//! changes** on a condy-only class - it does not resolve `CONSTANT_Dynamic` +//! at all. The plaintext then lives only inside JVM constant caching. + +use noxis_classfile::{Attribute, BootstrapMethod, ClassFile, Const, Insn}; +use noxis_runtime::{BOOTSTRAP_DESC, BOOTSTRAP_METHOD}; + +/// `REF_invokeStatic` (JVMS §5.4.3.5). +const INVOKE_STATIC: u8 = 6; + +/// Class-file version introducing `CONSTANT_Dynamic` (Java 11). +pub(super) const CONDY_MIN_MAJOR: u16 = 55; + +/// The class supports condy constants at all. +pub(super) fn supported(class: &ClassFile) -> bool { + class.version.major >= CONDY_MIN_MAJOR +} + +/// Where new bootstrap entries would start: right after the existing ones +/// (Kotlin lambdas etc. already own the front of the attribute). +pub(super) fn base_index(class: &ClassFile) -> u16 { + class + .attributes + .iter() + .find_map(|attribute| match attribute { + Attribute::BootstrapMethods(list) => Some(list.len() as u16), + _ => None, + }) + .unwrap_or(0) +} + +/// One bootstrap entry per chunk: the decoder's `b` with `cipher` + `salt` +/// as static arguments. The BSM prefix (`lookup`, `name`, `type`) is filled +/// in by the JVM (JVMS §5.4.3.5). +pub(super) fn entry(owner: &str, cipher: &str, salt: i32) -> BootstrapMethod { + BootstrapMethod { + kind: INVOKE_STATIC, + owner: owner.to_string(), + name: BOOTSTRAP_METHOD.to_string(), + descriptor: BOOTSTRAP_DESC.to_string(), + itf: false, + args: vec![Const::String(cipher.to_string()), Const::Int(salt)], + } +} + +/// Condensed replacements for one string's chunks: a `ldc` per chunk +/// (bootstrap indices `base..base+n`) joined by `String.concat`. +pub(super) fn sequence(base: u16, chunks: usize) -> Vec { + let mut out = Vec::with_capacity(chunks * 2); + for position in 0..chunks { + out.push(Insn::Const(Const::Dynamic { + name: "_".to_string(), + descriptor: "Ljava/lang/String;".to_string(), + bootstrap: base + position as u16, + })); + if position > 0 { + out.push(concat()); + } + } + out +} + +/// `String.concat` - the same chunk join the direct path uses. +fn concat() -> Insn { + Insn::Invoke { + kind: noxis_classfile::InvokeKind::Virtual, + owner: "java/lang/String".to_string(), + name: "concat".to_string(), + descriptor: "(Ljava/lang/String;)Ljava/lang/String;".to_string(), + itf: false, + } +} diff --git a/crates/noxis-passes/src/strings/mod.rs b/crates/noxis-passes/src/strings/mod.rs index e06cb15..b71adbd 100644 --- a/crates/noxis-passes/src/strings/mod.rs +++ b/crates/noxis-passes/src/strings/mod.rs @@ -4,6 +4,7 @@ //! (KDF or RNG stream - see `derive_key`). The decoder is generated once per //! program and only if at least one string was encrypted. +mod condy; mod crypt; #[cfg(test)] @@ -12,7 +13,9 @@ mod tests; use std::collections::BTreeMap; use noxis_analysis::{ClassHierarchy, JdkHierarchy}; -use noxis_classfile::{Const, Insn, MAX_METHOD_INSNS, Program, compute_frames}; +use noxis_classfile::{ + Attribute, BootstrapMethod, 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}; @@ -69,6 +72,17 @@ impl Pass for StringsPass { let owner = decoder_name(ctx, program); let mut chunk_counter = 0usize; let mut salt_counter = 0u64; + // Condy mode: per-class next bootstrap index + accumulated entries + // (appended to the class attribute after the rewrite below). + let mut bsm_next: BTreeMap = BTreeMap::new(); + let mut bsm_entries: BTreeMap> = BTreeMap::new(); + if cfg.condy { + for (ci, class) in program.classes.iter().enumerate() { + if condy::supported(class) { + bsm_next.insert(ci, condy::base_index(class)); + } + } + } let mut replacements: BTreeMap<(usize, usize, usize), Vec> = BTreeMap::new(); for (ci, mi, ii, text) in &targets { let mut parts = Vec::new(); @@ -80,7 +94,19 @@ impl Pass for StringsPass { parts.push((blob.cipher, salt)); chunk_counter += 1; } - replacements.insert((*ci, *mi, *ii), decode_sequence(&owner, &parts)); + if let Some(next) = bsm_next.get_mut(ci) { + let base = *next; + for (cipher, salt) in &parts { + bsm_entries + .entry(*ci) + .or_default() + .push(condy::entry(&owner, cipher, *salt)); + } + *next = base + parts.len() as u16; + replacements.insert((*ci, *mi, *ii), condy::sequence(base, parts.len())); + } else { + replacements.insert((*ci, *mi, *ii), decode_sequence(&owner, &parts)); + } } let mut mutated: Vec<(usize, usize)> = Vec::new(); let mut applied = 0usize; @@ -126,6 +152,21 @@ impl Pass for StringsPass { 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 3b: condy bootstrap entries land on their classes (appended + // after existing lambdas - the indices computed above assumed that). + for (ci, entries) in bsm_entries { + let class = program + .classes + .get_mut(ci) + .ok_or_else(|| pass_error("class vanished"))?; + match class.attributes.iter_mut().find_map(|a| match a { + Attribute::BootstrapMethods(list) => Some(list), + _ => None, + }) { + Some(list) => list.extend(entries), + None => class.attributes.push(Attribute::BootstrapMethods(entries)), + } + } // Phase 4: frames of the touched methods. let hierarchy = ClassHierarchy::build(program); let frames_hierarchy = JdkHierarchy::wrap(&hierarchy); diff --git a/crates/noxis-passes/src/strings/tests/condy.rs b/crates/noxis-passes/src/strings/tests/condy.rs new file mode 100644 index 0000000..ca65774 --- /dev/null +++ b/crates/noxis-passes/src/strings/tests/condy.rs @@ -0,0 +1,201 @@ +//! Condy-mode tests: `CONSTANT_Dynamic` emission, version gate, attribute +//! merging. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use noxis_classfile::{Attribute, BootstrapMethod, Const, Insn, Version}; +use noxis_core::{Config, Context, Pass, StringsConfig}; +use noxis_runtime::{Scheme, encrypt}; + +use super::super::StringsPass; +use super::sample; + +/// Condensed mode: strings become `CONSTANT_Dynamic` resolved by the +/// decoder's bootstrap method - a concrete-execution deobfuscator sees no +/// decrypt call to follow (todo.md, 2026-10-14: 0 condy changes from SSVM). +#[test] +fn condy_mode_emits_dynamic_constants() { + let mut program = sample(); + let config = Config { + seed: Some(7), + strings: StringsConfig { + condy: true, + ..StringsConfig::default() + }, + ..Config::default() + }; + let mut ctx = Context::new(config); + let report = StringsPass.apply(&mut ctx, &mut program).expect("strings"); + + assert_eq!(report.transformed, 2); + let class = &program.classes[0]; + let code = class.methods[0].code.as_ref().expect("code"); + // Both encrypted strings are condy constants, no direct decoder calls. + let dynamics: Vec<&Const> = code + .insns + .iter() + .filter_map(|insn| match insn { + Insn::Const(c @ Const::Dynamic { .. }) => Some(c), + _ => None, + }) + .collect(); + assert_eq!(dynamics.len(), 3, "1 chunk + 2 chunks: {code:?}"); + for value in &dynamics { + let Const::Dynamic { descriptor, .. } = value else { + unreachable!() + }; + assert_eq!(descriptor, "Ljava/lang/String;"); + } + assert!( + !code + .insns + .iter() + .any(|insn| matches!(insn, Insn::Invoke { name, .. } if name == "d")), + "condy mode must not emit direct decoder calls: {code:?}" + ); + // The bootstrap entries live on the class and point at the decoder's `b`. + let decoder = program + .classes + .iter() + .find(|c| c.methods.iter().any(|m| m.name == "b")) + .expect("decoder with bootstrap method"); + let Attribute::BootstrapMethods(bsm) = class + .attributes + .iter() + .find(|a| matches!(a, Attribute::BootstrapMethods(_))) + .expect("BootstrapMethods attribute") + else { + unreachable!() + }; + assert_eq!(bsm.len(), 3, "one entry per chunk"); + for entry in bsm { + assert_eq!(entry.kind, 6, "REF_invokeStatic"); + assert_eq!(entry.owner, decoder.this_class); + assert_eq!(entry.name, "b"); + assert!( + matches!(entry.args.as_slice(), [Const::String(_), Const::Int(_)]), + "args are cipher + salt: {:?}", + entry.args + ); + } + // Bootstrap indices in the constants match the entry positions. + let indices: Vec = dynamics + .iter() + .map(|c| { + let Const::Dynamic { bootstrap, .. } = c else { + unreachable!() + }; + *bootstrap + }) + .collect(); + assert_eq!(indices, vec![0, 1, 2]); + // Ciphertext is still the direct encryption of the originals. + let schemes = [Scheme::XorSplitmix, Scheme::XteaCtr]; + let originals = ["hello world".to_string(), "y".repeat(50), "y".repeat(50)]; + let ciphers: Vec = bsm + .iter() + .map(|e| match e.args[0] { + Const::String(ref s) => s.clone(), + _ => unreachable!(), + }) + .collect(); + let salts: Vec = bsm + .iter() + .map(|e| match e.args[1] { + Const::Int(s) => s, + _ => unreachable!(), + }) + .collect(); + for ((cipher, salt), original) in ciphers.iter().zip(&salts).zip(originals) { + let scheme = schemes[*salt as usize % schemes.len()]; + assert_eq!(cipher, &encrypt(scheme, 7, *salt, &original).cipher); + } +} + +/// Condy is a Java 11+ feature (classfile 55): older classes keep the direct +/// decoder calls even when the mode is on. +#[test] +fn condy_falls_back_for_old_class_versions() { + let mut program = sample(); + program.classes[0].version = Version::JAVA_8; + let config = Config { + seed: Some(7), + strings: StringsConfig { + condy: true, + ..StringsConfig::default() + }, + ..Config::default() + }; + let mut ctx = Context::new(config); + StringsPass.apply(&mut ctx, &mut program).expect("strings"); + + let code = program.classes[0].methods[0].code.as_ref().expect("code"); + assert!( + code.insns + .iter() + .any(|insn| matches!(insn, Insn::Invoke { name, .. } if name == "d")), + "pre-11 class must keep direct calls: {code:?}" + ); + assert!( + !code + .insns + .iter() + .any(|insn| matches!(insn, Insn::Const(Const::Dynamic { .. }))), + "no condy constants below classfile 55: {code:?}" + ); +} + +/// A class that already has `BootstrapMethods` (Kotlin lambdas) keeps its +/// entries: new condy ones are appended and referenced by the right index. +#[test] +fn condy_appends_to_existing_bootstrap_methods() { + let mut program = sample(); + let existing = BootstrapMethod { + kind: 6, + owner: "java/lang/invoke/StringConcatFactory".to_string(), + name: "makeConcatWithConstants".to_string(), + descriptor: "([Ljava/lang/Object;)Ljava/lang/String;".to_string(), + itf: false, + args: vec![Const::String("\u{1}".to_string())], + }; + program.classes[0] + .attributes + .push(Attribute::BootstrapMethods(vec![existing])); + let config = Config { + seed: Some(7), + strings: StringsConfig { + condy: true, + ..StringsConfig::default() + }, + ..Config::default() + }; + let mut ctx = Context::new(config); + StringsPass.apply(&mut ctx, &mut program).expect("strings"); + + let class = &program.classes[0]; + let Attribute::BootstrapMethods(bsm) = class + .attributes + .iter() + .find(|a| matches!(a, Attribute::BootstrapMethods(_))) + .expect("BootstrapMethods attribute") + else { + unreachable!() + }; + // 1 lambda entry + 3 chunk entries. + assert_eq!(bsm.len(), 4); + assert_eq!(bsm[0].name, "makeConcatWithConstants"); + let code = class.methods[0].code.as_ref().expect("code"); + let indices: Vec = code + .insns + .iter() + .filter_map(|insn| match insn { + Insn::Const(Const::Dynamic { bootstrap, .. }) => Some(*bootstrap), + _ => None, + }) + .collect(); + assert_eq!( + indices, + vec![1, 2, 3], + "condy entries start after the lambda" + ); +} diff --git a/crates/noxis-passes/src/strings/tests.rs b/crates/noxis-passes/src/strings/tests/mod.rs similarity index 99% rename from crates/noxis-passes/src/strings/tests.rs rename to crates/noxis-passes/src/strings/tests/mod.rs index b01ee08..43a4909 100644 --- a/crates/noxis-passes/src/strings/tests.rs +++ b/crates/noxis-passes/src/strings/tests/mod.rs @@ -2,6 +2,8 @@ #![allow(clippy::expect_used, clippy::unwrap_used)] +mod condy; + use noxis_classfile::{ClassBuilder, Code, Const, Insn, MAX_METHOD_INSNS, Program, Version}; use noxis_core::{Config, Context}; use noxis_runtime::{Scheme, encrypt}; @@ -10,7 +12,7 @@ use super::StringsPass; use super::crypt::{decode_sequence, salt_for_scheme, split_chunks}; use noxis_core::Pass; -fn sample() -> Program { +pub(super) fn sample() -> Program { let code = Code { max_stack: 1, max_locals: 0, diff --git a/crates/noxis-runtime/src/decoder/mod.rs b/crates/noxis-runtime/src/decoder/mod.rs index 77220cf..3a7a7de 100644 --- a/crates/noxis-runtime/src/decoder/mod.rs +++ b/crates/noxis-runtime/src/decoder/mod.rs @@ -31,6 +31,11 @@ use kdf::{Gen, c_int, c_long, c_str, call, load, store}; pub const DECODER_METHOD: &str = "d"; /// Дескриптор метода декодера. pub const DECODER_DESC: &str = "(Ljava/lang/String;I)Ljava/lang/String;"; +/// Имя bootstrap-метода для `CONSTANT_Dynamic` (condy-строки). +pub const BOOTSTRAP_METHOD: &str = "b"; +/// Дескриптор bootstrap-метода: стандартный префикс BSM +/// (`Lookup`, `name`, `type`) плюс наши аргументы `cipher` и `salt`. +pub const BOOTSTRAP_DESC: &str = "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;Ljava/lang/String;I)Ljava/lang/String;"; /// Ошибка генерации рантайма. #[derive(Debug, thiserror::Error)] @@ -78,9 +83,38 @@ pub fn decoder_class( major: 49, }) .method_with_code(0x0009, DECODER_METHOD, DECODER_DESC, code) + .method_with_code( + 0x0009, + BOOTSTRAP_METHOD, + BOOTSTRAP_DESC, + bootstrap_code(internal_name), + ) .build()) } +/// Тело bootstrap-метода: `b(lookup, name, type, cipher, salt) = d(cipher, salt)`. +/// +/// JVM вызывает его сама при первом `ldc` condy-константы и кеширует +/// результат - метод должен быть `public static` и возвращать значение +/// дескриптора константы (`Ljava/lang/String;`). +fn bootstrap_code(owner: &str) -> Code { + Code { + max_stack: 2, + // Слоты 0-2 (lookup/name/type) заняты стандартным префиксом BSM, + // cipher и salt живут в 3 и 4. + max_locals: 5, + insns: vec![ + load(3, SlotKind::Ref), + load(4, SlotKind::Int), + call(owner, DECODER_METHOD, DECODER_DESC, InvokeKind::Static), + Insn::AReturn, + ], + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + } +} + /// Имя класса-декодера для прогона: детерминированно от сида, но выглядит /// как обычный обфусцированный класс. pub fn decoder_owner(seed_name: &str) -> String { diff --git a/crates/noxis-runtime/src/lib.rs b/crates/noxis-runtime/src/lib.rs index 8a12d05..cf98eda 100644 --- a/crates/noxis-runtime/src/lib.rs +++ b/crates/noxis-runtime/src/lib.rs @@ -6,5 +6,7 @@ pub mod codec; pub mod decoder; pub mod scheme; -pub use decoder::{DECODER_DESC, DECODER_METHOD, decoder_class, decoder_owner}; +pub use decoder::{ + BOOTSTRAP_DESC, BOOTSTRAP_METHOD, DECODER_DESC, DECODER_METHOD, decoder_class, decoder_owner, +}; pub use scheme::{Blob, Scheme, decrypt, encrypt}; diff --git a/crates/noxis-runtime/tests/exec_condy.rs b/crates/noxis-runtime/tests/exec_condy.rs new file mode 100644 index 0000000..5e20134 --- /dev/null +++ b/crates/noxis-runtime/tests/exec_condy.rs @@ -0,0 +1,132 @@ +//! Condy-приёмка: шифруем в Rust, расшифровывает настоящая JVM через +//! bootstrap-метод декодера (`CONSTANT_Dynamic`). +//! +//! Это приёмка приоритета 1 из todo.md (2026-10-14): sandbox-деобфускатор +//! (narumii/SSVM) не резолвит condy вообще - 0 изменений на condy-классе, +//! тогда как прямой вызов декодера он исполняет и снимает строку. Метод `b` +//! живёт в декодере и вызывается самой JVM в момент `ldc`. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::path::PathBuf; + +use noxis_classfile::{Attribute, BootstrapMethod, ClassBuilder, Code, Const, Insn, format}; +use noxis_runtime::{BOOTSTRAP_DESC, BOOTSTRAP_METHOD, Scheme, decoder_class, encrypt}; + +const MASTER: u64 = 0x1234_5678_9ABC_DEF0; +const DECODER: &str = "test/CondDecoder"; +const CALLER: &str = "test/CondCall"; + +/// Строки обоих схем - тот же набор, что у прямого вызова. +fn samples() -> Vec<(String, Scheme)> { + vec![ + ("hi".to_string(), Scheme::XorSplitmix), + ("Hello, мир!".to_string(), Scheme::XorSplitmix), + ("x".repeat(100), Scheme::XorSplitmix), + ("hi".to_string(), Scheme::XteaCtr), + ("Hello, мир!".to_string(), Scheme::XteaCtr), + ("x".repeat(100), Scheme::XteaCtr), + ] +} + +/// Класс-вызыватель condy-режима: `main` печатает `ldc CONSTANT_Dynamic`. +/// +/// Ровно то, что эмитит пасс `strings` при `condy = true`: по одной +/// bootstrap-записи на чанк (cipher + salt как статические аргументы), +/// константа со стандартным дескриптором `Ljava/lang/String;`. +fn condy_caller_class(parts: &[(String, i32)]) -> noxis_classfile::ClassFile { + let mut insns = Vec::new(); + let mut bootstrap: Vec = Vec::new(); + for (cipher, salt) in parts { + bootstrap.push(BootstrapMethod { + kind: 6, // REF_invokeStatic + owner: DECODER.to_string(), + name: BOOTSTRAP_METHOD.to_string(), + descriptor: BOOTSTRAP_DESC.to_string(), + itf: false, + args: vec![Const::String(cipher.clone()), Const::Int(*salt)], + }); + insns.push(Insn::GetStatic { + owner: "java/lang/System".to_string(), + name: "out".to_string(), + descriptor: "Ljava/io/PrintStream;".to_string(), + }); + insns.push(Insn::Const(Const::Dynamic { + name: "_".to_string(), + descriptor: "Ljava/lang/String;".to_string(), + bootstrap: (bootstrap.len() - 1) as u16, + })); + insns.push(Insn::Invoke { + kind: noxis_classfile::InvokeKind::Virtual, + owner: "java/io/PrintStream".to_string(), + name: "println".to_string(), + descriptor: "(Ljava/lang/String;)V".to_string(), + itf: false, + }); + } + insns.push(Insn::Return(None)); + let code = Code { + max_stack: 2, + max_locals: 1, + insns, + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + }; + ClassBuilder::new(CALLER, None) + .attribute(Attribute::BootstrapMethods(bootstrap)) + .method_with_code(0x0009, "main", "([Ljava/lang/String;)V", code) + .build() +} + +#[test] +fn condy_bootstrap_executes_under_java() { + if std::process::Command::new("java") + .arg("-version") + .output() + .is_err() + { + eprintln!("SKIP: нет java"); + return; + } + let mut parts = Vec::new(); + let mut expected = Vec::new(); + for (index, (text, scheme)) in samples().iter().enumerate() { + let salt = (index as i32) + .saturating_mul(2) + .saturating_add(match scheme { + Scheme::XorSplitmix => 0, + Scheme::XteaCtr => 1, + }); + let blob = encrypt(*scheme, MASTER, salt, text); + parts.push((blob.cipher, salt)); + expected.push(text.to_string()); + } + let decoder = decoder_class(DECODER, MASTER, &["xor-splitmix", "xtea-ctr"]).expect("decoder"); + let caller = condy_caller_class(&parts); + + let dir: PathBuf = std::env::temp_dir().join(format!("noxis-condy-{}", std::process::id())); + for (name, class) in [(DECODER, &decoder), (CALLER, &caller)] { + let mut path = dir.clone(); + for part in name.split('/') { + path.push(part); + } + path.set_extension("class"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, format::write(class).expect("write")).expect("write class"); + } + let out = std::process::Command::new("java") + .arg("-Xverify:all") + .arg("-cp") + .arg(&dir) + .arg(CALLER.replace('/', ".")) + .output() + .expect("запуск java"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let _ = std::fs::remove_dir_all(&dir); + assert!(out.status.success(), "java упал:\n{stderr}\n{stdout}"); + let lines: Vec<&str> = stdout.lines().collect(); + let want: Vec<&str> = expected.iter().map(String::as_str).collect(); + assert_eq!(lines, want, "condy-расшифровка не сошлась"); +}