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

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-правила.
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)
}
/// Нормализовать значения, которые легко задать нулем/пустым списком.

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