deps: drop regex, toml, indexmap - std-only minirx, toml_lite, inferred IndexMap
This commit is contained in:
parent
59637e50dc
commit
d19e4f784d
19 changed files with 1171 additions and 96 deletions
70
Cargo.lock
generated
70
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<Regex, regex::Error> {
|
||||
fn compile(pattern: &str) -> Result<Regex, minirx::Error> {
|
||||
let mut out = String::from("^");
|
||||
let chars: Vec<char> = pattern.chars().collect();
|
||||
let mut i = 0;
|
||||
|
|
@ -34,7 +35,7 @@ fn compile(pattern: &str) -> Result<Regex, regex::Error> {
|
|||
i += 1;
|
||||
}
|
||||
c => {
|
||||
out.push_str(®ex::escape(&c.to_string()));
|
||||
out.push_str(&minirx::escape(&c.to_string()));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
82
crates/noxis-analysis/src/minirx/matcher.rs
Normal file
82
crates/noxis-analysis/src/minirx/matcher.rs
Normal 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
|
||||
}
|
||||
135
crates/noxis-analysis/src/minirx/mod.rs
Normal file
135
crates/noxis-analysis/src/minirx/mod.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
202
crates/noxis-analysis/src/minirx/parse.rs
Normal file
202
crates/noxis-analysis/src/minirx/parse.rs
Normal 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 })
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ rust-version.workspace = true
|
|||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
indexmap = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ristretto_classfile = "0.34"
|
||||
|
||||
|
|
|
|||
|
|
@ -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::<Result<_, _>>()?,
|
||||
})),
|
||||
Insn::LookupSwitch { default, pairs } => RtInsn::Lookupswitch(Box::new(LookupSwitch {
|
||||
Insn::LookupSwitch { default, pairs } => {
|
||||
// `ristretto_classfile::attributes::LookupSwitch::pairs` is typed
|
||||
// `indexmap::IndexMap<i32, i32>` 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: pairs
|
||||
.iter()
|
||||
.map(|(key, target)| Ok((*key, delta(index, resolve(*target)?)?)))
|
||||
.collect::<Result<IndexMap<_, _>, ClassfileError>>()?,
|
||||
})),
|
||||
pairs: resolved.into_iter().collect(),
|
||||
}))
|
||||
}
|
||||
Insn::Return(prim) => ret_op(*prim),
|
||||
Insn::AReturn => RtInsn::Areturn,
|
||||
Insn::GetStatic {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ license.workspace = true
|
|||
noxis-classfile = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[lints]
|
||||
|
|
|
|||
172
crates/noxis-core/src/config/from_toml/mod.rs
Normal file
172
crates/noxis-core/src/config/from_toml/mod.rs
Normal 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)
|
||||
}
|
||||
184
crates/noxis-core/src/config/from_toml/sections.rs
Normal file
184
crates/noxis-core/src/config/from_toml/sections.rs
Normal 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)
|
||||
}
|
||||
64
crates/noxis-core/src/config/from_toml/tests.rs
Normal file
64
crates/noxis-core/src/config/from_toml/tests.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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<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)
|
||||
}
|
||||
|
||||
/// Нормализовать значения, которые легко задать нулем/пустым списком.
|
||||
|
|
|
|||
218
crates/noxis-core/src/config/toml_lite/mod.rs
Normal file
218
crates/noxis-core/src/config/toml_lite/mod.rs
Normal 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;
|
||||
83
crates/noxis-core/src/config/toml_lite/tests.rs
Normal file
83
crates/noxis-core/src/config/toml_lite/tests.rs
Normal 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());
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -57,15 +57,13 @@ pub fn find_leaks(before: &BTreeSet<String>, after: &Program, keep: &KeepRules)
|
|||
pub fn check_patterns(text: &str) -> Vec<&'static str> {
|
||||
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(|| {
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue