deps: drop regex, toml, indexmap - std-only minirx, toml_lite, inferred IndexMap

This commit is contained in:
Loki 2026-10-15 12:53:22 +02:00
parent 59637e50dc
commit d19e4f784d
Signed by: boba
GPG key ID: 253067914055423B
19 changed files with 1171 additions and 96 deletions

70
Cargo.lock generated
View file

@ -309,7 +309,6 @@ dependencies = [
"noxis-classfile", "noxis-classfile",
"noxis-core", "noxis-core",
"noxis-structure-check", "noxis-structure-check",
"regex",
"serde_json", "serde_json",
"thiserror", "thiserror",
"tracing", "tracing",
@ -319,7 +318,6 @@ dependencies = [
name = "noxis-classfile" name = "noxis-classfile"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"indexmap",
"noxis-structure-check", "noxis-structure-check",
"ristretto_classfile", "ristretto_classfile",
"thiserror", "thiserror",
@ -333,7 +331,6 @@ dependencies = [
"noxis-structure-check", "noxis-structure-check",
"serde", "serde",
"thiserror", "thiserror",
"toml",
"tracing", "tracing",
] ]
@ -385,7 +382,6 @@ dependencies = [
"noxis-jar", "noxis-jar",
"noxis-passes", "noxis-passes",
"noxis-structure-check", "noxis-structure-check",
"regex",
"serde", "serde",
"thiserror", "thiserror",
] ]
@ -441,18 +437,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 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]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.18" version = "0.4.18"
@ -534,15 +518,6 @@ dependencies = [
"zmij", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@ -621,45 +596,6 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@ -820,12 +756,6 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"

View file

@ -20,12 +20,9 @@ noxis-verify = { path = "crates/noxis-verify" }
anyhow = "1" anyhow = "1"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
indexmap = "2"
regex = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
thiserror = "2" thiserror = "2"
toml = "1"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zip = { version = "8", default-features = false, features = ["deflate"] } zip = { version = "8", default-features = false, features = ["deflate"] }

View file

@ -7,7 +7,6 @@ rust-version.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
regex = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
noxis-classfile = { workspace = true } noxis-classfile = { workspace = true }
noxis-core = { workspace = true } noxis-core = { workspace = true }

View file

@ -3,7 +3,8 @@
use std::collections::BTreeSet; use std::collections::BTreeSet;
use noxis_classfile::Program; use noxis_classfile::Program;
use regex::Regex;
use crate::minirx::{self, Regex};
use crate::entrypoints::Entrypoints; use crate::entrypoints::Entrypoints;
use noxis_core::Config; use noxis_core::Config;
@ -14,7 +15,7 @@ mod scans;
pub use dispatch::external_dispatch_families; pub use dispatch::external_dispatch_families;
/// Compiled glob: `*` - anything but `/`, `**` - anything, `?` - one char. /// Compiled glob: `*` - anything but `/`, `**` - anything, `?` - one char.
fn compile(pattern: &str) -> Result<Regex, regex::Error> { fn compile(pattern: &str) -> Result<Regex, minirx::Error> {
let mut out = String::from("^"); let mut out = String::from("^");
let chars: Vec<char> = pattern.chars().collect(); let chars: Vec<char> = pattern.chars().collect();
let mut i = 0; let mut i = 0;
@ -34,7 +35,7 @@ fn compile(pattern: &str) -> Result<Regex, regex::Error> {
i += 1; i += 1;
} }
c => { c => {
out.push_str(&regex::escape(&c.to_string())); out.push_str(&minirx::escape(&c.to_string()));
i += 1; i += 1;
} }
} }

View file

@ -12,6 +12,7 @@ pub mod entrypoints;
pub mod hierarchy; pub mod hierarchy;
pub mod jdk; pub mod jdk;
pub mod keep; pub mod keep;
pub mod minirx;
pub use entrypoints::Entrypoints; pub use entrypoints::Entrypoints;
pub use hierarchy::{ClassHierarchy, RenameSafety, method_rename_safety}; pub use hierarchy::{ClassHierarchy, RenameSafety, method_rename_safety};

View file

@ -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<usize> {
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
}

View file

@ -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<parse::Node>,
case_insensitive: bool,
}
impl Regex {
/// Compiles `pattern`. See the module docs for the supported vocabulary.
pub fn new(pattern: &str) -> Result<Self, Error> {
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<char> = 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"));
}
}

View file

@ -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<ClassItem>,
},
}
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<usize>,
},
}
/// 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<Node>, bool), Error> {
let (rest, case_insensitive) = match pattern.strip_prefix("(?i)") {
Some(rest) => (rest, true),
None => (pattern, false),
};
let chars: Vec<char> = 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<Atom, Error> {
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 })
}

View file

@ -7,7 +7,6 @@ rust-version.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
indexmap = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
ristretto_classfile = "0.34" ristretto_classfile = "0.34"

View file

@ -4,7 +4,6 @@
//! absolute instruction numbers (`switch` - into deltas from itself), constants //! absolute instruction numbers (`switch` - into deltas from itself), constants
//! and references go to the pool via [`Pool`](super::super::pool::Pool). //! 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 ristretto_classfile::attributes::{Instruction as RtInsn, LookupSwitch, TableSwitch};
use super::super::pool::Pool; use super::super::pool::Pool;
@ -62,13 +61,23 @@ pub(crate) fn convert(
.map(|target| delta(index, resolve(*target)?)) .map(|target| delta(index, resolve(*target)?))
.collect::<Result<_, _>>()?, .collect::<Result<_, _>>()?,
})), })),
Insn::LookupSwitch { default, pairs } => RtInsn::Lookupswitch(Box::new(LookupSwitch { Insn::LookupSwitch { default, pairs } => {
default: delta(index, resolve(*default)?)?, // `ristretto_classfile::attributes::LookupSwitch::pairs` is typed
pairs: pairs // `indexmap::IndexMap<i32, i32>` by the (out-of-scope) backend crate
.iter() // itself, so an insertion-ordered map still ends up on the wire here -
.map(|(key, target)| Ok((*key, delta(index, resolve(*target)?)?))) // we just build it without naming `indexmap` directly in this crate.
.collect::<Result<IndexMap<_, _>, ClassfileError>>()?, // 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::Return(prim) => ret_op(*prim),
Insn::AReturn => RtInsn::Areturn, Insn::AReturn => RtInsn::Areturn,
Insn::GetStatic { Insn::GetStatic {

View file

@ -10,7 +10,6 @@ license.workspace = true
noxis-classfile = { workspace = true } noxis-classfile = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
[lints] [lints]

View file

@ -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<T> = Result<T, String>;
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<bool> {
match v {
Value::Bool(b) => Ok(*b),
_ => Err(format!("`{path}` должен быть bool")),
}
}
fn expect_usize(v: &Value, path: &str) -> Res<usize> {
match v {
Value::Integer(i) if *i >= 0 => Ok(*i as usize),
_ => Err(format!("`{path}` должен быть неотрицательным целым")),
}
}
fn expect_u64(v: &Value, path: &str) -> Res<u64> {
match v {
Value::Integer(i) if *i >= 0 => Ok(*i as u64),
_ => Err(format!("`{path}` должен быть неотрицательным целым")),
}
}
fn expect_string_vec(v: &Value, path: &str) -> Res<Vec<String>> {
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<Profile> {
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> {
[
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<Vec<PassId>> {
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<Option<&'a Table>> {
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<Config> {
check_known_keys(
root,
&[
"profile",
"seed",
"enable",
"disable",
"keep",
"keep_resources",
"rename",
"strings",
"constants",
"flow",
"noise",
"debug",
],
"<root>",
)?;
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)
}

View file

@ -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<NameStyle> {
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<RenameConfig> {
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<StringsConfig> {
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<ConstantsConfig> {
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<FlowConfig> {
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<NoiseConfig> {
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<DebugConfig> {
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)
}

View file

@ -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<Config> {
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);
}

View file

@ -1,6 +1,8 @@
//! Конфигурация обфускации: профили, список пассов, keep-правила. //! Конфигурация обфускации: профили, список пассов, keep-правила.
mod from_toml;
pub mod sections; pub mod sections;
mod toml_lite;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -146,9 +148,10 @@ impl Config {
self.profile.baseline().contains(&id) self.profile.baseline().contains(&id)
} }
/// Загрузить конфиг из TOML. /// Загрузить конфиг из TOML (наш подмножественный парсер, см. `toml_lite`).
pub fn from_toml(text: &str) -> Result<Self, CoreError> { pub fn from_toml(text: &str) -> Result<Self, CoreError> {
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)
} }
/// Нормализовать значения, которые легко задать нулем/пустым списком. /// Нормализовать значения, которые легко задать нулем/пустым списком.

View file

@ -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<Value>),
Table(Table),
}
/// A `[section]` body: key → value, in the order keys were written.
pub type Table = BTreeMap<String, Value>;
/// 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<String>) -> ParseError {
ParseError(msg.into())
}
/// Parse TOML-subset source text into a root [`Table`].
pub fn parse(text: &str) -> Result<Table, ParseError> {
let mut root: Table = Table::new();
let mut path: Vec<String> = 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<usize> {
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<Value, ParseError> {
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::<i64>()
.map(Value::Integer)
.map_err(|_| err(format!("неподдерживаемое значение: `{s}`")))
}
fn parse_array_elements(inner: &str) -> Result<Vec<Value>, 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<Value>, 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<String, ParseError> {
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;

View file

@ -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());
}

View file

@ -7,7 +7,6 @@ rust-version.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
regex = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
noxis-analysis = { workspace = true } noxis-analysis = { workspace = true }
noxis-classfile = { workspace = true } noxis-classfile = { workspace = true }

View file

@ -57,15 +57,13 @@ pub fn find_leaks(before: &BTreeSet<String>, after: &Program, keep: &KeepRules)
pub fn check_patterns(text: &str) -> Vec<&'static str> { pub fn check_patterns(text: &str) -> Vec<&'static str> {
use std::sync::OnceLock; use std::sync::OnceLock;
static COMPILED: OnceLock<Vec<(&'static str, regex::Regex)>> = OnceLock::new(); use noxis_analysis::minirx::Regex;
static COMPILED: OnceLock<Vec<(&'static str, Regex)>> = OnceLock::new();
let compiled = COMPILED.get_or_init(|| { let compiled = COMPILED.get_or_init(|| {
RED_PATTERNS RED_PATTERNS
.iter() .iter()
.filter_map(|pattern| { .filter_map(|pattern| Regex::new(pattern).ok().map(|regex| (*pattern, regex)))
regex::Regex::new(pattern)
.ok()
.map(|regex| (*pattern, regex))
})
.collect() .collect()
}); });
compiled compiled