diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e97ab6b..b4639acc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,6 +177,7 @@ jobs: - secure-random - text-similarity - command-arguments + - config-toml - terminal-style - terminal-input - event-stream diff --git a/Cargo.toml b/Cargo.toml index d8326968..fe8931fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ default = [] secure-random = ["dep:getrandom"] text-similarity = ["dep:strsim"] command-arguments = ["dep:shell-words"] +config-toml = ["dep:toml"] terminal-style = [] # Native terminal capture and key decoding without PTY process spawning. terminal-input = [] @@ -223,6 +224,7 @@ framehop = { version = "=0.13.3", optional = true } futures-core = { version = "=0.3.34", optional = true } getrandom = { version = "=0.4.3", optional = true } strsim = { version = "=0.11.1", optional = true } +toml = { version = "=0.8.23", optional = true } shell-words = { version = "=1.1.1", optional = true } globset = { version = "=0.4.18", optional = true } interprocess = { version = "=2.4.3", optional = true } @@ -384,6 +386,7 @@ toml = "=0.8.23" # here explicitly; this is a documentation set, not a supported bundle. features = [ "full", + "config-toml", "daemon-identity", "daemon-frame-v1", "daemon-registration", diff --git a/ci/check_compilation_boundary_dependencies.py b/ci/check_compilation_boundary_dependencies.py index 110fd1fb..2e833c17 100644 --- a/ci/check_compilation_boundary_dependencies.py +++ b/ci/check_compilation_boundary_dependencies.py @@ -49,7 +49,7 @@ } -def tree(features: str) -> set[str]: +def tree(features: str, *, normal_only: bool = False) -> set[str]: command = [ "soldr", "cargo", @@ -59,6 +59,8 @@ def tree(features: str) -> set[str]: "--prefix", "none", ] + if normal_only: + command.extend(("--edges", "normal")) if features: command.extend(("--features", features)) completed = subprocess.run(command, check=True, text=True, capture_output=True) @@ -72,6 +74,11 @@ def tree(features: str) -> set[str]: def main() -> int: default_graph = tree("") failures: list[str] = [] + # TOML already builds the kernel's catalog. Only its runtime edge is opt-in. + if "toml" in tree("", normal_only=True): + failures.append("default runtime graph unexpectedly contains toml") + if "toml" not in tree("config-toml", normal_only=True): + failures.append("config-toml runtime graph omits toml") for label, graph in (("default", default_graph), ("full", tree("full"))): unexpected = sorted(graph & SKETCH_AND_WEBVIEW_PACKAGES) if unexpected: diff --git a/docs/config-toml.md b/docs/config-toml.md new file mode 100644 index 00000000..7fe480f2 --- /dev/null +++ b/docs/config-toml.md @@ -0,0 +1,18 @@ +# Bounded TOML configuration + +Enable `config-toml` for `config::Document::parse_toml(&str)`. The document's +root is a kernel-owned `Value::Table`; children preserve strings, signed +integers, floating-point values, booleans, arrays, tables, and canonical TOML +date/time text. No parser types, Serde traits, filesystem reads, interpolation, +or application defaults are exposed. Comments and original formatting are not +retained. Applications match values and enforce their own field schemas. + +Source is limited to 1 MiB before parsing. The returned document is limited to +16,384 values (including containers and the root) and depth 32 (root depth 0). +Those two decoded limits are checked after backend parsing, not as independent +CPU or parser-allocation quotas. The private parser retains its default +recursion limit. Errors return no partial document and do not echo source text. + +The exact private `toml` 0.8.23 dependency is enabled on runtime edges only by +this feature. It already occurs as a build dependency, so whole dependency-graph +absence and build-speed gains are not claimed. CI checks runtime edges separately. diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 00000000..8472866f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,95 @@ +//! Configuration mechanics only; applications own field validation and defaults. + +use std::collections::BTreeMap; + +/// Maximum UTF-8 source bytes, checked before invoking the parser. +pub const MAX_INPUT_BYTES: usize = 1024 * 1024; +/// Maximum decoded values, counting the root table and every container. +pub const MAX_NODES: usize = 16_384; +/// Maximum value depth, with the root table at depth zero. +pub const MAX_DEPTH: usize = 32; + +/// Semantic configuration values independent of the private TOML parser. +/// Table keys are ordered; source formatting and comments are not retained. +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + String(String), + Integer(i64), + Float(f64), + Boolean(bool), + /// Canonical TOML date/time spelling; no timezone conversion is performed. + DateTime(String), + Array(Vec), + Table(BTreeMap), +} + +/// A parsed configuration. Construction enforces bounds before it is returned. +#[derive(Clone, Debug, PartialEq)] +pub struct Document { + root: Value, +} + +/// Bounded diagnostics that never echo configuration contents or secrets. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ParseError { + #[error("configuration exceeds 1048576 UTF-8 source bytes")] + InputTooLarge, + #[error("invalid TOML document")] + InvalidSyntax, + #[error("configuration exceeds 16384 decoded values")] + TooManyNodes, + #[error("configuration exceeds value depth 32")] + TooDeep, +} + +impl Document { + /// Decode a TOML document without filesystem access or interpolation. + /// + /// The source byte bound applies before parsing. Node/depth bounds apply + /// after the private parser constructs its tree, during conversion to owned + /// semantic values. They are not independent parser CPU or allocation quotas; + /// source size and the parser's own recursion limit bound that earlier stage. + /// No partial document is returned on error. Unknown fields remain values + /// for application policy; empty input is a valid empty table. + pub fn parse_toml(source: &str) -> Result { + if source.len() > MAX_INPUT_BYTES { + return Err(ParseError::InputTooLarge); + } + let table = toml::from_str::(source).map_err(|_| ParseError::InvalidSyntax)?; + let mut remaining = MAX_NODES; + Ok(Self { + root: convert(toml::Value::Table(table), 0, &mut remaining)?, + }) + } + + /// The root of a TOML document is always a table. + pub fn root(&self) -> &Value { + &self.root + } +} + +fn convert(value: toml::Value, depth: usize, remaining: &mut usize) -> Result { + if depth > MAX_DEPTH { + return Err(ParseError::TooDeep); + } + *remaining = remaining.checked_sub(1).ok_or(ParseError::TooManyNodes)?; + Ok(match value { + toml::Value::String(value) => Value::String(value), + toml::Value::Integer(value) => Value::Integer(value), + toml::Value::Float(value) => Value::Float(value), + toml::Value::Boolean(value) => Value::Boolean(value), + toml::Value::Datetime(value) => Value::DateTime(value.to_string()), + toml::Value::Array(values) => Value::Array( + values + .into_iter() + .map(|value| convert(value, depth + 1, remaining)) + .collect::>()?, + ), + toml::Value::Table(values) => Value::Table( + values + .into_iter() + .map(|(key, value)| Ok((key, convert(value, depth + 1, remaining)?))) + .collect::>()?, + ), + }) +} diff --git a/src/lib.rs b/src/lib.rs index 176e1109..a4d1dd96 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,10 @@ mod process_adapter; #[cfg(feature = "command-arguments")] pub mod arguments; +/// Bounded configuration decoding with caller-owned schemas and defaults. +#[cfg(feature = "config-toml")] +pub mod config; + /// Kernel-owned BLAKE3 content hashing for bytes, readers, and files, plus /// an incremental hasher and key-derivation domain separation. pub mod hash; diff --git a/tests/config_toml.rs b/tests/config_toml.rs new file mode 100644 index 00000000..37bf2055 --- /dev/null +++ b/tests/config_toml.rs @@ -0,0 +1,60 @@ +#![cfg(feature = "config-toml")] + +use kernal_api::config::{Document, ParseError, Value, MAX_INPUT_BYTES, MAX_NODES}; + +#[test] +fn preserves_toml_values_without_exposing_parser_types() { + let document = Document::parse_toml("title = '日本語'\nn = 42\nok = true\nf = 1.5\nwhen = 1979-05-27\n[flags]\nitems = ['-O0', '-pthread']\n").unwrap(); + let Value::Table(root) = document.root() else { + panic!("root table") + }; + assert_eq!(root["title"], Value::String("日本語".into())); + assert_eq!(root["n"], Value::Integer(42)); + assert_eq!(root["ok"], Value::Boolean(true)); + assert_eq!(root["f"], Value::Float(1.5)); + assert_eq!(root["when"], Value::DateTime("1979-05-27".into())); + let Value::Table(flags) = &root["flags"] else { + panic!("flags table") + }; + assert_eq!( + flags["items"], + Value::Array(vec![ + Value::String("-O0".into()), + Value::String("-pthread".into()) + ]) + ); +} + +#[test] +fn rejects_invalid_documents_and_honors_input_bound() { + for source in ["x =", "x=1\nx=2", "x='unterminated"] { + assert!(matches!( + Document::parse_toml(source), + Err(ParseError::InvalidSyntax) + )); + } + let source = format!("#{}", " ".repeat(MAX_INPUT_BYTES - 1)); + assert!(Document::parse_toml(&source).is_ok()); + assert!(matches!( + Document::parse_toml(&(source + " ")), + Err(ParseError::InputTooLarge) + )); +} + +#[test] +fn decoded_node_and_depth_limits_are_exact() { + let source = format!("x=[{}]", vec!["0"; MAX_NODES - 2].join(",")); + assert!(Document::parse_toml(&source).is_ok()); + let source = format!("x=[{}]", vec!["0"; MAX_NODES - 1].join(",")); + assert!(matches!( + Document::parse_toml(&source), + Err(ParseError::TooManyNodes) + )); + let source = format!("x={}0{}", "[".repeat(31), "]".repeat(31)); + assert!(Document::parse_toml(&source).is_ok()); + let source = format!("x={}0{}", "[".repeat(32), "]".repeat(32)); + assert!(matches!( + Document::parse_toml(&source), + Err(ParseError::TooDeep) + )); +}