Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ jobs:
- secure-random
- text-similarity
- command-arguments
- config-toml
- terminal-style
- terminal-input
- event-stream
Expand Down
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion ci/check_compilation_boundary_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
}


def tree(features: str) -> set[str]:
def tree(features: str, *, normal_only: bool = False) -> set[str]:
command = [
"soldr",
"cargo",
Expand All @@ -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)
Expand All @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions docs/config-toml.md
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 95 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -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<Value>),
Table(BTreeMap<String, Value>),
}

/// 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<Self, ParseError> {
if source.len() > MAX_INPUT_BYTES {
return Err(ParseError::InputTooLarge);
}
let table = toml::from_str::<toml::Table>(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<Value, ParseError> {
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::<Result<_, _>>()?,
),
toml::Value::Table(values) => Value::Table(
values
.into_iter()
.map(|(key, value)| Ok((key, convert(value, depth + 1, remaining)?)))
.collect::<Result<_, ParseError>>()?,
),
})
}
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
60 changes: 60 additions & 0 deletions tests/config_toml.rs
Original file line number Diff line number Diff line change
@@ -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)
));
}
Loading