diff --git a/Cargo.lock b/Cargo.lock index 31a6584..ec45f6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,7 +309,6 @@ dependencies = [ "noxis-classfile", "noxis-core", "noxis-structure-check", - "regex", "serde_json", "thiserror", "tracing", @@ -319,7 +318,6 @@ dependencies = [ name = "noxis-classfile" version = "0.1.0" dependencies = [ - "indexmap", "noxis-structure-check", "ristretto_classfile", "thiserror", @@ -333,7 +331,6 @@ dependencies = [ "noxis-structure-check", "serde", "thiserror", - "toml", "tracing", ] @@ -385,7 +382,6 @@ dependencies = [ "noxis-jar", "noxis-passes", "noxis-structure-check", - "regex", "serde", "thiserror", ] @@ -441,18 +437,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - [[package]] name = "regex-automata" version = "0.4.18" @@ -534,15 +518,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" -dependencies = [ - "serde_core", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -621,45 +596,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "toml" -version = "1.1.7+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7874cd34dd99040fc24e30923aa64a60a81ff3ddfe2b8129ccc84ba24949deed" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_parser" -version = "1.1.4+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bed810ee007d8e7f9cf20b4774e7198142fa27186e0f805871da7f3c39eaf333" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" - [[package]] name = "tracing" version = "0.1.44" @@ -820,12 +756,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" - [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index b80072e..7778969 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,12 +20,9 @@ noxis-verify = { path = "crates/noxis-verify" } anyhow = "1" clap = { version = "4", features = ["derive"] } -indexmap = "2" -regex = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" -toml = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } zip = { version = "8", default-features = false, features = ["deflate"] } diff --git a/crates/noxis-analysis/Cargo.toml b/crates/noxis-analysis/Cargo.toml index 4a65dac..fe92797 100644 --- a/crates/noxis-analysis/Cargo.toml +++ b/crates/noxis-analysis/Cargo.toml @@ -7,7 +7,6 @@ rust-version.workspace = true license.workspace = true [dependencies] -regex = { workspace = true } serde_json = { workspace = true } noxis-classfile = { workspace = true } noxis-core = { workspace = true } diff --git a/crates/noxis-analysis/src/keep/mod.rs b/crates/noxis-analysis/src/keep/mod.rs index 498b9b5..8c1cfde 100644 --- a/crates/noxis-analysis/src/keep/mod.rs +++ b/crates/noxis-analysis/src/keep/mod.rs @@ -3,7 +3,8 @@ use std::collections::BTreeSet; use noxis_classfile::Program; -use regex::Regex; + +use crate::minirx::{self, Regex}; use crate::entrypoints::Entrypoints; use noxis_core::Config; @@ -14,7 +15,7 @@ mod scans; pub use dispatch::external_dispatch_families; /// Compiled glob: `*` - anything but `/`, `**` - anything, `?` - one char. -fn compile(pattern: &str) -> Result { +fn compile(pattern: &str) -> Result { let mut out = String::from("^"); let chars: Vec = pattern.chars().collect(); let mut i = 0; @@ -34,7 +35,7 @@ fn compile(pattern: &str) -> Result { i += 1; } c => { - out.push_str(®ex::escape(&c.to_string())); + out.push_str(&minirx::escape(&c.to_string())); i += 1; } } diff --git a/crates/noxis-analysis/src/lib.rs b/crates/noxis-analysis/src/lib.rs index 21d9318..39c53c5 100644 --- a/crates/noxis-analysis/src/lib.rs +++ b/crates/noxis-analysis/src/lib.rs @@ -12,6 +12,7 @@ pub mod entrypoints; pub mod hierarchy; pub mod jdk; pub mod keep; +pub mod minirx; pub use entrypoints::Entrypoints; pub use hierarchy::{ClassHierarchy, RenameSafety, method_rename_safety}; diff --git a/crates/noxis-analysis/src/minirx/matcher.rs b/crates/noxis-analysis/src/minirx/matcher.rs new file mode 100644 index 0000000..97e4591 --- /dev/null +++ b/crates/noxis-analysis/src/minirx/matcher.rs @@ -0,0 +1,82 @@ +//! Backtracking matcher over a flat `Node` sequence. + +use super::parse::Node; + +/// True if `nodes` matches anywhere in `text` (unanchored search), honoring +/// any `Start`/`End` nodes as zero-width anchors at their actual position. +pub(super) fn search(nodes: &[Node], text: &[char]) -> bool { + // `^` only makes sense anchored at position 0: if the pattern starts + // with it, trying every start offset is still correct (it will simply + // fail at every offset except possibly 0) and keeps this function + // simple - no need to special-case it. + for start in 0..=text.len() { + if match_here(nodes, 0, text, start).is_some() { + return true; + } + } + false +} + +/// Tries to match `nodes[idx..]` starting at `text[pos..]`; returns the end +/// position on success. +fn match_here(nodes: &[Node], idx: usize, text: &[char], pos: usize) -> Option { + let Some(node) = nodes.get(idx) else { + return Some(pos); + }; + match node { + Node::Start => { + if pos != 0 { + return None; + } + match_here(nodes, idx + 1, text, pos) + } + Node::End => { + if pos != text.len() { + return None; + } + match_here(nodes, idx + 1, text, pos) + } + Node::WordBoundary => { + if !is_word_boundary(text, pos) { + return None; + } + match_here(nodes, idx + 1, text, pos) + } + Node::Rep { atom, min, max } => { + let run = count_run(atom, text, pos); + let hi = max.map_or(run, |m| m.min(run)); + if hi < *min { + return None; + } + // Greedy: try the longest repetition first, backtrack shorter. + for count in (*min..=hi).rev() { + if let Some(end) = match_here(nodes, idx + 1, text, pos + count) { + return Some(end); + } + } + None + } + } +} + +/// Counts how many consecutive chars from `pos` satisfy `atom`. +fn count_run(atom: &super::parse::Atom, text: &[char], pos: usize) -> usize { + let mut n = 0; + while text.get(pos + n).is_some_and(|&c| atom.matches(c)) { + n += 1; + } + n +} + +/// A JVM/identifier-ish "word" char: alphanumeric or `_`. +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +fn is_word_boundary(text: &[char], pos: usize) -> bool { + let before = pos.checked_sub(1).and_then(|i| text.get(i)).copied(); + let after = text.get(pos).copied(); + let before_word = before.is_some_and(is_word_char); + let after_word = after.is_some_and(is_word_char); + before_word != after_word +} diff --git a/crates/noxis-analysis/src/minirx/mod.rs b/crates/noxis-analysis/src/minirx/mod.rs new file mode 100644 index 0000000..831df7d --- /dev/null +++ b/crates/noxis-analysis/src/minirx/mod.rs @@ -0,0 +1,135 @@ +//! Minimal std-only regex engine, covering exactly the pattern vocabulary +//! used in this codebase (keep-rule globs compiled to regex, plus the +//! fixed `RED_PATTERNS` secret-scan list in `noxis-verify`). +//! +//! Not a general-purpose regex engine: no groups, no alternation, no +//! backreferences, no `{n,m}` quantifiers. What it does support: +//! literal chars, `.`, character classes (`[...]`, `[^...]`, ranges), +//! `\s`, `\b` (word boundary), anchors `^`/`$`, quantifiers `*`/`+`/`?` +//! (greedy, backtracking), a leading `(?i)` case-insensitive flag, and +//! `\`-escaping of any non-alphanumeric char (what `escape` produces). +//! +//! Split into `parse` (pattern string -> `Node` sequence) and `matcher` +//! (backtracking match over a char slice) to stay under the structure +//! gate's per-file line limit. + +mod matcher; +mod parse; + +pub use parse::Error; + +/// A compiled pattern. +#[derive(Debug, Clone)] +pub struct Regex { + nodes: Vec, + case_insensitive: bool, +} + +impl Regex { + /// Compiles `pattern`. See the module docs for the supported vocabulary. + pub fn new(pattern: &str) -> Result { + let (nodes, case_insensitive) = parse::parse(pattern)?; + Ok(Self { + nodes, + case_insensitive, + }) + } + + /// Whether `text` matches anywhere (unanchored, unless the pattern + /// itself carries `^`/`$`) - mirrors `regex::Regex::is_match`. + pub fn is_match(&self, text: &str) -> bool { + let chars: Vec = if self.case_insensitive { + text.chars().flat_map(char::to_lowercase).collect() + } else { + text.chars().collect() + }; + matcher::search(&self.nodes, &chars) + } +} + +/// Escapes every char that could be special in a pattern, so the result +/// matches `s` literally when embedded in a larger pattern. +/// +/// Mirrors the subset of `regex::escape` this codebase actually relies on: +/// everything outside `[A-Za-z0-9_]` is escaped (harmless over-escaping for +/// chars this engine treats as plain literals anyway). +pub fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() * 2); + for c in s.chars() { + if !c.is_alphanumeric() && c != '_' { + out.push('\\'); + } + out.push(c); + } + out +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + #[test] + fn literal_and_anchors() { + let re = Regex::new("^abc$").unwrap(); + assert!(re.is_match("abc")); + assert!(!re.is_match("abcd")); + assert!(!re.is_match("xabc")); + } + + #[test] + fn glob_star_and_doublestar() { + // Mirrors `keep::compile` output for `com.foo.*`. + let re = Regex::new("^com/foo/[^/]*$").unwrap(); + assert!(re.is_match("com/foo/Bar")); + assert!(!re.is_match("com/foo/Bar/Baz")); + + let re2 = Regex::new("^com/.*$").unwrap(); + assert!(re2.is_match("com/foo/Bar/Baz")); + } + + #[test] + fn question_mark_class() { + let re = Regex::new("^a[^/]b$").unwrap(); + assert!(re.is_match("axb")); + assert!(!re.is_match("a/b")); + } + + #[test] + fn escaped_literal_dot() { + let pat = format!("^{}$", escape("a.b")); + let re = Regex::new(&pat).unwrap(); + assert!(re.is_match("a.b")); + assert!(!re.is_match("axb")); + } + + #[test] + fn red_patterns_compile_and_match() { + let re = Regex::new(r"(?i)password\s*=\s*[^\s;]+").unwrap(); + assert!(re.is_match("password=hunter2")); + assert!(re.is_match("PASSWORD = hunter2")); + assert!(!re.is_match("not a secret")); + + let re2 = Regex::new(r"(?i)\bapi[_-]?key\b").unwrap(); + assert!(re2.is_match("api_key=abc")); + assert!(re2.is_match("API-KEY: x")); + assert!(!re2.is_match("recapitalized")); + + let re3 = Regex::new(r#"jdbc:[a-z]+://[^\s"']+"#).unwrap(); + assert!(re3.is_match("jdbc:mysql://host/db")); + + let re4 = Regex::new(r"-----BEGIN [A-Z ]*PRIVATE KEY-----").unwrap(); + assert!(re4.is_match("-----BEGIN RSA PRIVATE KEY-----")); + + let re5 = Regex::new(r#"https?://[^\s"']+"#).unwrap(); + assert!(re5.is_match("https://example.com/hook")); + assert!(re5.is_match("http://example.com")); + } + + #[test] + fn clean_text_passes() { + let re = Regex::new(r"(?i)password\s*=\s*[^\s;]+").unwrap(); + assert!(!re.is_match("hello world")); + } +} diff --git a/crates/noxis-analysis/src/minirx/parse.rs b/crates/noxis-analysis/src/minirx/parse.rs new file mode 100644 index 0000000..004b0c0 --- /dev/null +++ b/crates/noxis-analysis/src/minirx/parse.rs @@ -0,0 +1,202 @@ +//! Pattern string -> flat `Node` sequence (no groups, no alternation). + +use std::fmt; + +/// A single class-member: a literal char or an inclusive range. +#[derive(Debug, Clone, Copy)] +pub(super) enum ClassItem { + Char(char), + Range(char, char), + Space, +} + +impl ClassItem { + fn matches(&self, c: char) -> bool { + match *self { + ClassItem::Char(x) => x == c, + ClassItem::Range(lo, hi) => lo <= c && c <= hi, + ClassItem::Space => c.is_whitespace(), + } + } +} + +/// One zero-width or single-char-consuming atom in the pattern. +#[derive(Debug, Clone)] +pub(super) enum Atom { + Char(char), + Any, + Space, + Class { + negated: bool, + items: Vec, + }, +} + +impl Atom { + pub(super) fn matches(&self, c: char) -> bool { + match self { + Atom::Char(x) => *x == c, + Atom::Any => true, + Atom::Space => c.is_whitespace(), + Atom::Class { negated, items } => { + let hit = items.iter().any(|item| item.matches(c)); + hit != *negated + } + } + } +} + +/// A node in the flat pattern sequence. +#[derive(Debug, Clone)] +pub(super) enum Node { + Start, + End, + WordBoundary, + /// `atom` repeated `min..=max` times (`max = None` means unbounded), + /// matched greedily with backtracking. + Rep { + atom: Atom, + min: usize, + max: Option, + }, +} + +/// A pattern compilation error. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error(pub(super) String); + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for Error {} + +/// Parses `pattern`, returning its node sequence and whether a leading +/// `(?i)` flag was present. +pub(super) fn parse(pattern: &str) -> Result<(Vec, bool), Error> { + let (rest, case_insensitive) = match pattern.strip_prefix("(?i)") { + Some(rest) => (rest, true), + None => (pattern, false), + }; + let chars: Vec = if case_insensitive { + rest.chars().flat_map(char::to_lowercase).collect() + } else { + rest.chars().collect() + }; + let mut nodes = Vec::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let node = match c { + '^' => { + i += 1; + Node::Start + } + '$' => { + i += 1; + Node::End + } + '\\' => { + i += 1; + let Some(&esc) = chars.get(i) else { + return Err(Error("trailing backslash".into())); + }; + i += 1; + if esc == 'b' { + Node::WordBoundary + } else { + let atom = if esc == 's' { + Atom::Space + } else { + Atom::Char(esc) + }; + parse_quantifier(atom, &chars, &mut i) + } + } + '.' => { + i += 1; + parse_quantifier(Atom::Any, &chars, &mut i) + } + '[' => { + i += 1; + let atom = parse_class(&chars, &mut i)?; + parse_quantifier(atom, &chars, &mut i) + } + _ => { + i += 1; + parse_quantifier(Atom::Char(c), &chars, &mut i) + } + }; + nodes.push(node); + } + Ok((nodes, case_insensitive)) +} + +/// Reads an optional trailing `*`/`+`/`?` and wraps `atom` into a `Rep`. +fn parse_quantifier(atom: Atom, chars: &[char], i: &mut usize) -> Node { + let (min, max) = match chars.get(*i) { + Some('*') => { + *i += 1; + (0, None) + } + Some('+') => { + *i += 1; + (1, None) + } + Some('?') => { + *i += 1; + (0, Some(1)) + } + _ => (1, Some(1)), + }; + Node::Rep { atom, min, max } +} + +/// Parses `[...]` starting right after the opening `[`; advances `i` past +/// the closing `]`. +fn parse_class(chars: &[char], i: &mut usize) -> Result { + let negated = if matches!(chars.get(*i), Some('^')) { + *i += 1; + true + } else { + false + }; + let mut items = Vec::new(); + let mut closed = false; + while let Some(&c) = chars.get(*i) { + if c == ']' { + *i += 1; + closed = true; + break; + } + let lo = if c == '\\' { + *i += 1; + let esc = *chars + .get(*i) + .ok_or_else(|| Error("trailing backslash in class".into()))?; + *i += 1; + if esc == 's' { + items.push(ClassItem::Space); + continue; + } + esc + } else { + *i += 1; + c + }; + if chars.get(*i) == Some(&'-') && chars.get(*i + 1).is_some_and(|&c| c != ']') { + *i += 1; + let hi = chars[*i]; + *i += 1; + items.push(ClassItem::Range(lo, hi)); + } else { + items.push(ClassItem::Char(lo)); + } + } + if !closed { + return Err(Error("unterminated character class".into())); + } + Ok(Atom::Class { negated, items }) +} diff --git a/crates/noxis-classfile/Cargo.toml b/crates/noxis-classfile/Cargo.toml index 3a88b3c..338fc92 100644 --- a/crates/noxis-classfile/Cargo.toml +++ b/crates/noxis-classfile/Cargo.toml @@ -7,7 +7,6 @@ rust-version.workspace = true license.workspace = true [dependencies] -indexmap = { workspace = true } thiserror = { workspace = true } ristretto_classfile = "0.34" diff --git a/crates/noxis-classfile/src/format/write/code/insns.rs b/crates/noxis-classfile/src/format/write/code/insns.rs index e7781b1..d520d3b 100644 --- a/crates/noxis-classfile/src/format/write/code/insns.rs +++ b/crates/noxis-classfile/src/format/write/code/insns.rs @@ -4,7 +4,6 @@ //! absolute instruction numbers (`switch` - into deltas from itself), constants //! and references go to the pool via [`Pool`](super::super::pool::Pool). -use indexmap::IndexMap; use ristretto_classfile::attributes::{Instruction as RtInsn, LookupSwitch, TableSwitch}; use super::super::pool::Pool; @@ -62,13 +61,23 @@ pub(crate) fn convert( .map(|target| delta(index, resolve(*target)?)) .collect::>()?, })), - Insn::LookupSwitch { default, pairs } => RtInsn::Lookupswitch(Box::new(LookupSwitch { - default: delta(index, resolve(*default)?)?, - pairs: pairs - .iter() - .map(|(key, target)| Ok((*key, delta(index, resolve(*target)?)?))) - .collect::, ClassfileError>>()?, - })), + Insn::LookupSwitch { default, pairs } => { + // `ristretto_classfile::attributes::LookupSwitch::pairs` is typed + // `indexmap::IndexMap` by the (out-of-scope) backend crate + // itself, so an insertion-ordered map still ends up on the wire here - + // we just build it without naming `indexmap` directly in this crate. + // Match order must be preserved: it's the `lookupswitch` operand + // order, which the JVM spec (and verifiers) require sorted by key, + // and callers already hand us `pairs` pre-sorted. + let mut resolved = Vec::with_capacity(pairs.len()); + for (key, target) in pairs { + resolved.push((*key, delta(index, resolve(*target)?)?)); + } + RtInsn::Lookupswitch(Box::new(LookupSwitch { + default: delta(index, resolve(*default)?)?, + pairs: resolved.into_iter().collect(), + })) + } Insn::Return(prim) => ret_op(*prim), Insn::AReturn => RtInsn::Areturn, Insn::GetStatic { diff --git a/crates/noxis-core/Cargo.toml b/crates/noxis-core/Cargo.toml index 76f11ed..7f4a2be 100644 --- a/crates/noxis-core/Cargo.toml +++ b/crates/noxis-core/Cargo.toml @@ -10,7 +10,6 @@ license.workspace = true noxis-classfile = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } -toml = { workspace = true } tracing = { workspace = true } [lints] diff --git a/crates/noxis-core/src/config/from_toml/mod.rs b/crates/noxis-core/src/config/from_toml/mod.rs new file mode 100644 index 0000000..8a0e16f --- /dev/null +++ b/crates/noxis-core/src/config/from_toml/mod.rs @@ -0,0 +1,172 @@ +//! Builds [`Config`](super::Config) from a parsed [`toml_lite::Table`]. +//! +//! Deliberately *not* going through `serde::Deserialize`: `Config` has +//! only a handful of fields and three small kebab-case enums, so a tiny +//! hand-written visitor is less machinery (and fewer edge cases to worry +//! about) than writing a generic `serde::Deserializer` over our `Value` +//! tree. This keeps the TOML replacement fully decoupled from serde - +//! the `Serialize`/`Deserialize` derives left on `Config` and friends are +//! unrelated to this path and can be dropped independently later. + +mod sections; +#[cfg(test)] +mod tests; + +use super::toml_lite::{Table, Value}; +use super::{Config, PassId, Profile}; + +type Res = Result; + +fn unknown_key(section: &str, key: &str) -> String { + format!("неизвестный ключ `{key}` в секции `{section}`") +} + +fn expect_str<'a>(v: &'a Value, path: &str) -> Res<&'a str> { + match v { + Value::String(s) => Ok(s), + _ => Err(format!("`{path}` должен быть строкой")), + } +} + +fn expect_bool(v: &Value, path: &str) -> Res { + match v { + Value::Bool(b) => Ok(*b), + _ => Err(format!("`{path}` должен быть bool")), + } +} + +fn expect_usize(v: &Value, path: &str) -> Res { + match v { + Value::Integer(i) if *i >= 0 => Ok(*i as usize), + _ => Err(format!("`{path}` должен быть неотрицательным целым")), + } +} + +fn expect_u64(v: &Value, path: &str) -> Res { + match v { + Value::Integer(i) if *i >= 0 => Ok(*i as u64), + _ => Err(format!("`{path}` должен быть неотрицательным целым")), + } +} + +fn expect_string_vec(v: &Value, path: &str) -> Res> { + match v { + Value::Array(items) => items + .iter() + .map(|item| expect_str(item, path).map(str::to_string)) + .collect(), + _ => Err(format!("`{path}` должен быть массивом строк")), + } +} + +fn parse_profile(s: &str) -> Res { + match s { + "light" => Ok(Profile::Light), + "standard" => Ok(Profile::Standard), + "paranoid" => Ok(Profile::Paranoid), + "manual" => Ok(Profile::Manual), + other => Err(format!("неизвестный профиль `{other}`")), + } +} + +fn parse_pass_id(s: &str) -> Res { + [ + PassId::Rename, + PassId::Strings, + PassId::Constants, + PassId::Flow, + PassId::Noise, + PassId::DebugInfo, + PassId::Resources, + ] + .into_iter() + .find(|id| id.as_str() == s) + .ok_or_else(|| format!("неизвестный пасс `{s}`")) +} + +fn parse_pass_id_vec(v: &Value, path: &str) -> Res> { + expect_string_vec(v, path)? + .iter() + .map(|s| parse_pass_id(s)) + .collect() +} + +/// Reject any key in `table` that isn't in `known` (mirrors serde's +/// `deny_unknown_fields`). +fn check_known_keys(table: &Table, known: &[&str], section: &str) -> Res<()> { + for key in table.keys() { + if !known.contains(&key.as_str()) { + return Err(unknown_key(section, key)); + } + } + Ok(()) +} + +fn sub_table<'a>(table: &'a Table, key: &str) -> Res> { + match table.get(key) { + None => Ok(None), + Some(Value::Table(t)) => Ok(Some(t)), + Some(_) => Err(format!("`{key}` должен быть секцией `[{key}]`")), + } +} + +/// Build a full [`Config`] from a parsed root table. +pub(super) fn build(root: &Table) -> Res { + check_known_keys( + root, + &[ + "profile", + "seed", + "enable", + "disable", + "keep", + "keep_resources", + "rename", + "strings", + "constants", + "flow", + "noise", + "debug", + ], + "", + )?; + + let mut cfg = Config::default(); + if let Some(v) = root.get("profile") { + cfg.profile = parse_profile(expect_str(v, "profile")?)?; + } + if let Some(v) = root.get("seed") { + cfg.seed = Some(expect_u64(v, "seed")?); + } + if let Some(v) = root.get("enable") { + cfg.enable = parse_pass_id_vec(v, "enable")?; + } + if let Some(v) = root.get("disable") { + cfg.disable = parse_pass_id_vec(v, "disable")?; + } + if let Some(v) = root.get("keep") { + cfg.keep = expect_string_vec(v, "keep")?; + } + if let Some(v) = root.get("keep_resources") { + cfg.keep_resources = expect_string_vec(v, "keep_resources")?; + } + if let Some(t) = sub_table(root, "rename")? { + cfg.rename = sections::build_rename(t)?; + } + if let Some(t) = sub_table(root, "strings")? { + cfg.strings = sections::build_strings(t)?; + } + if let Some(t) = sub_table(root, "constants")? { + cfg.constants = sections::build_constants(t)?; + } + if let Some(t) = sub_table(root, "flow")? { + cfg.flow = sections::build_flow(t)?; + } + if let Some(t) = sub_table(root, "noise")? { + cfg.noise = sections::build_noise(t)?; + } + if let Some(t) = sub_table(root, "debug")? { + cfg.debug = sections::build_debug(t)?; + } + Ok(cfg) +} diff --git a/crates/noxis-core/src/config/from_toml/sections.rs b/crates/noxis-core/src/config/from_toml/sections.rs new file mode 100644 index 0000000..5dea8fd --- /dev/null +++ b/crates/noxis-core/src/config/from_toml/sections.rs @@ -0,0 +1,184 @@ +//! Per-section struct population, split out of `from_toml::build` purely to +//! stay under the per-file line budget (see `docs/README.md`). + +use super::super::sections::{ + ConstantsConfig, DebugConfig, FlowConfig, NameStyle, NoiseConfig, RenameConfig, StringsConfig, +}; +use super::super::toml_lite::Table; +use super::{Res, check_known_keys, expect_bool, expect_str, expect_string_vec, expect_usize}; + +fn parse_name_style(s: &str) -> Res { + match s { + "short-latin" => Ok(NameStyle::ShortLatin), + "il-o0" => Ok(NameStyle::IlO0), + "unicode" => Ok(NameStyle::Unicode), + "mixed-latin" => Ok(NameStyle::MixedLatin), + "decoy-words" => Ok(NameStyle::DecoyWords), + "plausible" => Ok(NameStyle::Plausible), + other => Err(format!("неизвестный стиль имён `{other}`")), + } +} + +pub(super) fn build_rename(t: &Table) -> Res { + check_known_keys( + t, + &[ + "spread_packages", + "package_pool", + "class_style", + "member_style", + "local_style", + "widen_access", + "rename_enum_constants", + "rename_locals", + "rename_public_members", + ], + "rename", + )?; + let mut c = RenameConfig::default(); + if let Some(v) = t.get("spread_packages") { + c.spread_packages = expect_bool(v, "rename.spread_packages")?; + } + if let Some(v) = t.get("package_pool") { + c.package_pool = expect_usize(v, "rename.package_pool")?; + } + if let Some(v) = t.get("class_style") { + c.class_style = parse_name_style(expect_str(v, "rename.class_style")?)?; + } + if let Some(v) = t.get("member_style") { + c.member_style = parse_name_style(expect_str(v, "rename.member_style")?)?; + } + if let Some(v) = t.get("local_style") { + c.local_style = parse_name_style(expect_str(v, "rename.local_style")?)?; + } + if let Some(v) = t.get("widen_access") { + c.widen_access = expect_bool(v, "rename.widen_access")?; + } + if let Some(v) = t.get("rename_enum_constants") { + c.rename_enum_constants = expect_bool(v, "rename.rename_enum_constants")?; + } + if let Some(v) = t.get("rename_locals") { + c.rename_locals = expect_bool(v, "rename.rename_locals")?; + } + if let Some(v) = t.get("rename_public_members") { + c.rename_public_members = expect_bool(v, "rename.rename_public_members")?; + } + Ok(c) +} + +pub(super) fn build_strings(t: &Table) -> Res { + check_known_keys( + t, + &["schemes", "min_len", "split_above", "skip", "derive_key"], + "strings", + )?; + let mut c = StringsConfig::default(); + if let Some(v) = t.get("schemes") { + c.schemes = expect_string_vec(v, "strings.schemes")?; + } + if let Some(v) = t.get("min_len") { + c.min_len = expect_usize(v, "strings.min_len")?; + } + if let Some(v) = t.get("split_above") { + c.split_above = expect_usize(v, "strings.split_above")?; + } + if let Some(v) = t.get("skip") { + c.skip = expect_string_vec(v, "strings.skip")?; + } + if let Some(v) = t.get("derive_key") { + c.derive_key = expect_bool(v, "strings.derive_key")?; + } + Ok(c) +} + +pub(super) fn build_constants(t: &Table) -> Res { + check_known_keys(t, &["schemes", "max_predicates", "skip"], "constants")?; + let mut c = ConstantsConfig::default(); + if let Some(v) = t.get("schemes") { + c.schemes = expect_string_vec(v, "constants.schemes")?; + } + if let Some(v) = t.get("max_predicates") { + c.max_predicates = expect_usize(v, "constants.max_predicates")?; + } + if let Some(v) = t.get("skip") { + c.skip = expect_string_vec(v, "constants.skip")?; + } + Ok(c) +} + +pub(super) fn build_flow(t: &Table) -> Res { + check_known_keys(t, &["schemes", "max_insns", "encrypt_block_bodies"], "flow")?; + let mut c = FlowConfig::default(); + if let Some(v) = t.get("schemes") { + c.schemes = expect_string_vec(v, "flow.schemes")?; + } + if let Some(v) = t.get("max_insns") { + c.max_insns = expect_usize(v, "flow.max_insns")?; + } + if let Some(v) = t.get("encrypt_block_bodies") { + c.encrypt_block_bodies = expect_bool(v, "flow.encrypt_block_bodies")?; + } + Ok(c) +} + +pub(super) fn build_noise(t: &Table) -> Res { + check_known_keys( + t, + &[ + "junk_ratio", + "junk_methods_per_class", + "junk_annotations", + "weave_reachable", + ], + "noise", + )?; + let mut c = NoiseConfig::default(); + if let Some(v) = t.get("junk_ratio") { + c.junk_ratio = expect_usize(v, "noise.junk_ratio")?; + } + if let Some(v) = t.get("junk_methods_per_class") { + c.junk_methods_per_class = expect_usize(v, "noise.junk_methods_per_class")?; + } + if let Some(v) = t.get("junk_annotations") { + c.junk_annotations = expect_usize(v, "noise.junk_annotations")?; + } + if let Some(v) = t.get("weave_reachable") { + c.weave_reachable = expect_bool(v, "noise.weave_reachable")?; + } + Ok(c) +} + +pub(super) fn build_debug(t: &Table) -> Res { + check_known_keys( + t, + &[ + "strip_line_numbers", + "fake_line_numbers", + "strip_local_variables", + "spoof_source_file", + "strip_source_file", + "strip_signatures", + ], + "debug", + )?; + let mut c = DebugConfig::default(); + if let Some(v) = t.get("strip_line_numbers") { + c.strip_line_numbers = expect_bool(v, "debug.strip_line_numbers")?; + } + if let Some(v) = t.get("fake_line_numbers") { + c.fake_line_numbers = expect_bool(v, "debug.fake_line_numbers")?; + } + if let Some(v) = t.get("strip_local_variables") { + c.strip_local_variables = expect_bool(v, "debug.strip_local_variables")?; + } + if let Some(v) = t.get("spoof_source_file") { + c.spoof_source_file = expect_bool(v, "debug.spoof_source_file")?; + } + if let Some(v) = t.get("strip_source_file") { + c.strip_source_file = expect_bool(v, "debug.strip_source_file")?; + } + if let Some(v) = t.get("strip_signatures") { + c.strip_signatures = expect_bool(v, "debug.strip_signatures")?; + } + Ok(c) +} diff --git a/crates/noxis-core/src/config/from_toml/tests.rs b/crates/noxis-core/src/config/from_toml/tests.rs new file mode 100644 index 0000000..5342e79 --- /dev/null +++ b/crates/noxis-core/src/config/from_toml/tests.rs @@ -0,0 +1,64 @@ +//! Tests for TOML-subset → `Config` struct population. +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use super::*; +use crate::config::sections::NameStyle; +use crate::config::toml_lite; +use crate::config::{PassId, Profile}; + +fn build_from(text: &str) -> Res { + let table = toml_lite::parse(text).map_err(|e| e.to_string())?; + build(&table) +} + +#[test] +fn builds_defaults_from_empty_input() { + let cfg = build_from("").unwrap(); + assert_eq!(cfg, Config::default()); +} + +#[test] +fn overrides_top_level_and_section_fields() { + let cfg = build_from( + 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.keep, vec!["com/example/Main".to_string()]); + assert_eq!(cfg.rename.package_pool, 4); +} + +#[test] +fn rejects_unknown_top_level_key() { + assert!(build_from("totally_unknown = 1").is_err()); +} + +#[test] +fn rejects_unknown_section_key() { + assert!(build_from("[rename]\nbogus = 1\n").is_err()); +} + +#[test] +fn parses_enable_disable_pass_lists() { + let cfg = build_from( + r#"enable = ["flow"] +disable = ["strings"] +"#, + ) + .unwrap(); + assert_eq!(cfg.enable, vec![PassId::Flow]); + assert_eq!(cfg.disable, vec![PassId::Strings]); +} + +#[test] +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); +} diff --git a/crates/noxis-core/src/config/mod.rs b/crates/noxis-core/src/config/mod.rs index 2bfa2b3..92770b8 100644 --- a/crates/noxis-core/src/config/mod.rs +++ b/crates/noxis-core/src/config/mod.rs @@ -1,6 +1,8 @@ //! Конфигурация обфускации: профили, список пассов, keep-правила. +mod from_toml; pub mod sections; +mod toml_lite; use serde::{Deserialize, Serialize}; @@ -146,9 +148,10 @@ impl Config { self.profile.baseline().contains(&id) } - /// Загрузить конфиг из TOML. + /// Загрузить конфиг из TOML (наш подмножественный парсер, см. `toml_lite`). pub fn from_toml(text: &str) -> Result { - toml::from_str(text).map_err(|e| CoreError::Config(e.to_string())) + let table = toml_lite::parse(text).map_err(|e| CoreError::Config(e.to_string()))?; + from_toml::build(&table).map_err(CoreError::Config) } /// Нормализовать значения, которые легко задать нулем/пустым списком. diff --git a/crates/noxis-core/src/config/toml_lite/mod.rs b/crates/noxis-core/src/config/toml_lite/mod.rs new file mode 100644 index 0000000..4b0a26d --- /dev/null +++ b/crates/noxis-core/src/config/toml_lite/mod.rs @@ -0,0 +1,218 @@ +//! Hand-rolled parser for the small TOML subset `Config` actually uses. +//! +//! We don't need full TOML (dates, multi-line strings, inline tables, +//! arrays-of-tables): every fixture and hand-written config in this repo +//! is flat `key = value` pairs grouped by `[section]` / `[section.sub]` +//! headers, with scalars limited to strings, integers, bools and arrays +//! of those. Replacing the `toml` crate with the full spec would be far +//! more code than this project needs; this covers exactly the syntax +//! `Config::from_toml` has ever been asked to parse (see +//! `crates/noxis-core/src/config/from_toml.rs` for the struct-population +//! side), erroring out loudly on anything else instead of guessing. + +use std::collections::BTreeMap; +use std::fmt; + +/// A parsed scalar or compound TOML-subset value. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + String(String), + Integer(i64), + Bool(bool), + Array(Vec), + Table(Table), +} + +/// A `[section]` body: key → value, in the order keys were written. +pub type Table = BTreeMap; + +/// A parse failure, with a human-readable message (no line/col tracking - +/// configs here are short enough that the offending text is enough). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError(pub String); + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +fn err(msg: impl Into) -> ParseError { + ParseError(msg.into()) +} + +/// Parse TOML-subset source text into a root [`Table`]. +pub fn parse(text: &str) -> Result { + let mut root: Table = Table::new(); + let mut path: Vec = Vec::new(); + + for raw_line in text.lines() { + let line = strip_comment(raw_line).trim(); + if line.is_empty() { + continue; + } + if let Some(header) = line.strip_prefix('[') { + let header = header + .strip_suffix(']') + .ok_or_else(|| err(format!("незакрытый заголовок секции: `{raw_line}`")))?; + path = header + .split('.') + .map(|seg| seg.trim().to_string()) + .collect(); + if path.iter().any(|seg| seg.is_empty()) { + return Err(err(format!("пустой сегмент в заголовке: `{raw_line}`"))); + } + ensure_table_path(&mut root, &path)?; + continue; + } + let eq = find_top_level_eq(line) + .ok_or_else(|| err(format!("ожидалось `key = value`: `{raw_line}`")))?; + let key = line[..eq].trim(); + if key.is_empty() { + return Err(err(format!("пустой ключ: `{raw_line}`"))); + } + let value = parse_value(line[eq + 1..].trim())?; + let table = table_at_path(&mut root, &path)?; + table.insert(key.to_string(), value); + } + + Ok(root) +} + +/// Drop a `#`-started comment, unless the `#` is inside a quoted string. +fn strip_comment(line: &str) -> &str { + let mut in_string = false; + for (i, c) in line.char_indices() { + match c { + '"' => in_string = !in_string, + '#' if !in_string => return &line[..i], + _ => {} + } + } + line +} + +/// Find the first `=` that isn't inside a quoted string. +fn find_top_level_eq(line: &str) -> Option { + let mut in_string = false; + for (i, c) in line.char_indices() { + match c { + '"' => in_string = !in_string, + '=' if !in_string => return Some(i), + _ => {} + } + } + None +} + +fn ensure_table_path(root: &mut Table, path: &[String]) -> Result<(), ParseError> { + let mut cur = root; + for seg in path { + let entry = cur + .entry(seg.clone()) + .or_insert_with(|| Value::Table(Table::new())); + match entry { + Value::Table(t) => cur = t, + _ => return Err(err(format!("`{seg}` уже используется как не-секция"))), + } + } + Ok(()) +} + +fn table_at_path<'a>(root: &'a mut Table, path: &[String]) -> Result<&'a mut Table, ParseError> { + let mut cur = root; + for seg in path { + match cur.get_mut(seg.as_str()) { + Some(Value::Table(t)) => cur = t, + _ => return Err(err(format!("секция `{seg}` не найдена"))), + } + } + Ok(cur) +} + +fn parse_value(s: &str) -> Result { + let s = s.trim(); + if let Some(inner) = s.strip_prefix('"') { + let inner = inner + .strip_suffix('"') + .ok_or_else(|| err(format!("незакрытая строка: `{s}`")))?; + return Ok(Value::String(unescape(inner)?)); + } + if let Some(inner) = s.strip_prefix('[') { + let inner = inner + .strip_suffix(']') + .ok_or_else(|| err(format!("незакрытый массив: `{s}`")))?; + return Ok(Value::Array(parse_array_elements(inner)?)); + } + match s { + "true" => return Ok(Value::Bool(true)), + "false" => return Ok(Value::Bool(false)), + _ => {} + } + s.parse::() + .map(Value::Integer) + .map_err(|_| err(format!("неподдерживаемое значение: `{s}`"))) +} + +fn parse_array_elements(inner: &str) -> Result, ParseError> { + let mut out = Vec::new(); + let mut depth = 0i32; + let mut in_string = false; + let mut start = 0usize; + let bytes = inner.as_bytes(); + let push = |out: &mut Vec, chunk: &str| -> Result<(), ParseError> { + let chunk = chunk.trim(); + if chunk.is_empty() { + return Ok(()); + } + out.push(parse_value(chunk)?); + Ok(()) + }; + for (i, c) in inner.char_indices() { + match c { + '"' => in_string = !in_string, + '[' if !in_string => depth += 1, + ']' if !in_string => depth -= 1, + ',' if !in_string && depth == 0 => { + push(&mut out, &inner[start..i])?; + start = i + 1; + } + _ => {} + } + } + if start < bytes.len() { + push(&mut out, &inner[start..])?; + } + Ok(out) +} + +/// Basic escapes actually seen in configs: `\"`, `\\`, `\n`, `\t`. +fn unescape(s: &str) -> Result { + if !s.contains('\\') { + return Ok(s.to_string()); + } + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some('"') => out.push('"'), + Some('\\') => out.push('\\'), + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + other => { + return Err(err(format!( + "неподдерживаемый escape-символ: `\\{}`", + other.unwrap_or(' ') + ))); + } + } + } + Ok(out) +} + +#[cfg(test)] +mod tests; diff --git a/crates/noxis-core/src/config/toml_lite/tests.rs b/crates/noxis-core/src/config/toml_lite/tests.rs new file mode 100644 index 0000000..ad8ec5b --- /dev/null +++ b/crates/noxis-core/src/config/toml_lite/tests.rs @@ -0,0 +1,83 @@ +//! Tests for the TOML-subset parser. +#![allow(clippy::expect_used, clippy::unwrap_used)] +use super::*; + +#[test] +fn parses_flat_pairs() { + let t = parse("name = \"x\"\ncount = 4\nflag = true\n").unwrap(); + assert_eq!(t.get("name"), Some(&Value::String("x".into()))); + assert_eq!(t.get("count"), Some(&Value::Integer(4))); + assert_eq!(t.get("flag"), Some(&Value::Bool(true))); +} + +#[test] +fn parses_negative_integer() { + let t = parse("x = -12").unwrap(); + assert_eq!(t.get("x"), Some(&Value::Integer(-12))); +} + +#[test] +fn parses_section_header() { + let t = parse("[rename]\npackage_pool = 4\n").unwrap(); + let Some(Value::Table(section)) = t.get("rename") else { + panic!("expected table"); + }; + assert_eq!(section.get("package_pool"), Some(&Value::Integer(4))); +} + +#[test] +fn parses_nested_section_header() { + let t = parse("[a.b]\nx = 1\n").unwrap(); + let Some(Value::Table(a)) = t.get("a") else { + panic!("expected table a") + }; + let Some(Value::Table(b)) = a.get("b") else { + panic!("expected table b") + }; + assert_eq!(b.get("x"), Some(&Value::Integer(1))); +} + +#[test] +fn parses_string_array() { + let t = parse(r#"keep = ["com/example/Main", "*Service"]"#).unwrap(); + assert_eq!( + t.get("keep"), + Some(&Value::Array(vec![ + Value::String("com/example/Main".into()), + Value::String("*Service".into()), + ])) + ); +} + +#[test] +fn parses_empty_array() { + let t = parse("keep = []").unwrap(); + assert_eq!(t.get("keep"), Some(&Value::Array(vec![]))); +} + +#[test] +fn skips_comments_and_blank_lines() { + let t = parse("# comment\n\nx = 1 # trailing\n").unwrap(); + assert_eq!(t.get("x"), Some(&Value::Integer(1))); +} + +#[test] +fn hash_inside_string_is_not_a_comment() { + let t = parse(r#"x = "a#b""#).unwrap(); + assert_eq!(t.get("x"), Some(&Value::String("a#b".into()))); +} + +#[test] +fn rejects_float() { + assert!(parse("x = 1.5").is_err()); +} + +#[test] +fn rejects_garbage_line() { + assert!(parse("not a key value line").is_err()); +} + +#[test] +fn rejects_unterminated_string() { + assert!(parse("x = \"unterminated").is_err()); +} diff --git a/crates/noxis-verify/Cargo.toml b/crates/noxis-verify/Cargo.toml index b054bbc..37dafe4 100644 --- a/crates/noxis-verify/Cargo.toml +++ b/crates/noxis-verify/Cargo.toml @@ -7,7 +7,6 @@ rust-version.workspace = true license.workspace = true [dependencies] -regex = { workspace = true } serde = { workspace = true } noxis-analysis = { workspace = true } noxis-classfile = { workspace = true } diff --git a/crates/noxis-verify/src/leaks.rs b/crates/noxis-verify/src/leaks.rs index b006530..1cad5d8 100644 --- a/crates/noxis-verify/src/leaks.rs +++ b/crates/noxis-verify/src/leaks.rs @@ -57,15 +57,13 @@ pub fn find_leaks(before: &BTreeSet, after: &Program, keep: &KeepRules) pub fn check_patterns(text: &str) -> Vec<&'static str> { use std::sync::OnceLock; - static COMPILED: OnceLock> = OnceLock::new(); + use noxis_analysis::minirx::Regex; + + static COMPILED: OnceLock> = OnceLock::new(); let compiled = COMPILED.get_or_init(|| { RED_PATTERNS .iter() - .filter_map(|pattern| { - regex::Regex::new(pattern) - .ok() - .map(|regex| (*pattern, regex)) - }) + .filter_map(|pattern| Regex::new(pattern).ok().map(|regex| (*pattern, regex))) .collect() }); compiled