From b3896d2e2253c2de65c59ad3d1b2f687c74b8813 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 06:00:50 -0700 Subject: [PATCH 1/3] feat(json): add bounded owned JSON documents (refs #211) --- .github/workflows/ci.yml | 1 + Cargo.toml | 2 + ci/check_compilation_boundary_dependencies.py | 1 + docs/json.md | 30 +++ src/json.rs | 201 ++++++++++++++++++ src/lib.rs | 3 + tests/facade_policy.rs | 6 +- tests/json_documents.rs | 110 ++++++++++ 8 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 docs/json.md create mode 100644 src/json.rs create mode 100644 tests/json_documents.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6f86cd1..e4efe486 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,6 +178,7 @@ jobs: - text-similarity - command-arguments - config-toml + - json - source-cpp - terminal-style - terminal-input diff --git a/Cargo.toml b/Cargo.toml index df488f78..6299ad52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ secure-random = ["dep:getrandom"] text-similarity = ["dep:strsim"] command-arguments = ["dep:shell-words"] config-toml = ["dep:toml"] +json = ["dep:serde", "dep:serde_json"] source-cpp = ["dep:tree-sitter", "dep:tree-sitter-cpp"] terminal-style = [] # Native terminal capture and key decoding without PTY process spawning. @@ -390,6 +391,7 @@ toml = "=0.8.23" features = [ "full", "config-toml", + "json", "source-cpp", "daemon-identity", "daemon-frame-v1", diff --git a/ci/check_compilation_boundary_dependencies.py b/ci/check_compilation_boundary_dependencies.py index 545eb381..5b43e47b 100644 --- a/ci/check_compilation_boundary_dependencies.py +++ b/ci/check_compilation_boundary_dependencies.py @@ -15,6 +15,7 @@ ("pty", "portable-pty"), ("text-similarity", "strsim"), ("command-arguments", "shell-words"), + ("json", "serde_json"), ("source-cpp", "tree-sitter"), ("source-cpp", "tree-sitter-cpp"), ("wasm-sketch-host", "wasmtime"), diff --git a/docs/json.md b/docs/json.md new file mode 100644 index 00000000..535894e3 --- /dev/null +++ b/docs/json.md @@ -0,0 +1,30 @@ +# JSON documents + +Enable `json` for `kernal_api::json::{parse, encode, Value, Layout}`. The API +owns its values and errors; it exposes no Serde bounds, derives or backend +types. Applications retain schema validation, defaults, field names, unknown +field handling, HTTP status codes and trailing-newline policy. + +Parsing accepts one UTF-8 JSON value with optional surrounding whitespace. +Duplicate object keys retain the last value. Objects encode in sorted key +order, arrays retain order, and pretty output uses two-space indentation with +no trailing newline. Signed and unsigned 64-bit extrema are exact. Positive +integers use the signed variant when representable; larger integers outside +both integer ranges can round to finite f64. This is not arbitrary precision. +Nonfinite caller-provided floats are rejected instead of silently becoming null. + +Input is limited to 8 MiB before parsing. Decoded trees are limited to 262144 +values (including containers, excluding object keys) and depth 64, root zero. +Those traversal bounds apply after the private parser constructs its tree; +they are not independent parser memory or CPU quotas. The private parser's +own recursion limit remains enabled. + +Encoding validates count, depth and finite numbers before serialization, +borrows caller-owned values without constructing a second tree, and limits +output to 8 MiB including whitespace and escaping. Failures return no partial +document and diagnostics never echo source contents. Limits do not constrain +allocations performed by callers while constructing their values. + +JSON is distinct from TOML configuration: null, unsigned integers and JSON +output semantics should not alter the configuration contract. The private +JSON backend is also used by the existing Firefox profile exporter. diff --git a/src/json.rs b/src/json.rs new file mode 100644 index 00000000..1abc8dde --- /dev/null +++ b/src/json.rs @@ -0,0 +1,201 @@ +//! Bounded JSON mechanics. Applications own schemas, defaults and field policy. + +use std::collections::BTreeMap; +use std::io::Write; + +/// Source byte limit, enforced before parsing. +pub const MAX_INPUT_BYTES: usize = 8 * 1024 * 1024; +/// Encoded byte limit, including escaping and whitespace. +pub const MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024; +/// Maximum decoded values, counting containers and the root (not object keys). +pub const MAX_NODES: usize = 262_144; +/// Maximum value depth; the root has depth zero. +pub const MAX_DEPTH: usize = 64; + +/// Owned JSON values, independent of the private serialization backend. +/// Objects encode in key order. Positive parsed integers use `Signed` when +/// representable, otherwise `Unsigned`; numeric variant identity is not a +/// wire-format guarantee. Floating-point values must be finite when encoded. +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Null, + Bool(bool), + Signed(i64), + Unsigned(u64), + Float(f64), + String(String), + Array(Vec), + Object(BTreeMap), +} + +/// Output style; neither style appends a trailing newline. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Layout { + Compact, + /// Two-space indentation. + Pretty, +} + +/// Bounded diagnostics that do not echo document contents. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum Error { + #[error("JSON source exceeds byte limit")] + InputTooLarge, + #[error("invalid JSON document")] + InvalidSyntax, + #[error("JSON exceeds value count limit")] + TooManyNodes, + #[error("JSON exceeds depth limit")] + TooDeep, + #[error("JSON numbers must be finite")] + NonFiniteNumber, + #[error("JSON output exceeds byte limit")] + OutputTooLarge, + #[error("JSON encoding failed")] + EncodingFailed, +} + +/// Parse one UTF-8 JSON value; trailing whitespace is allowed, trailing values +/// are not. Duplicate object keys retain their last value. +/// +/// The input byte limit is checked before parsing. Value count and depth limits +/// apply to the decoded tree after private parsing, not as independent parser +/// allocation or CPU quotas. The parser also retains its own recursion limit. +/// No partial result is returned. Integers outside i64/u64 may decode as finite +/// f64 values with rounding; this is not an arbitrary-precision number API. +pub fn parse(source: &[u8]) -> Result { + if source.len() > MAX_INPUT_BYTES { + return Err(Error::InputTooLarge); + } + let value = serde_json::from_slice(source).map_err(|_| Error::InvalidSyntax)?; + let mut remaining = MAX_NODES; + convert(value, 0, &mut remaining) +} + +fn visit(depth: usize, remaining: &mut usize) -> Result<(), Error> { + if depth > MAX_DEPTH { + return Err(Error::TooDeep); + } + *remaining = remaining.checked_sub(1).ok_or(Error::TooManyNodes)?; + Ok(()) +} + +fn convert(value: serde_json::Value, depth: usize, remaining: &mut usize) -> Result { + visit(depth, remaining)?; + Ok(match value { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(value) => Value::Bool(value), + serde_json::Value::Number(value) => { + if let Some(value) = value.as_i64() { + Value::Signed(value) + } else if let Some(value) = value.as_u64() { + Value::Unsigned(value) + } else { + Value::Float(value.as_f64().ok_or(Error::InvalidSyntax)?) + } + } + serde_json::Value::String(value) => Value::String(value), + serde_json::Value::Array(values) => Value::Array( + values + .into_iter() + .map(|value| convert(value, depth + 1, remaining)) + .collect::>()?, + ), + serde_json::Value::Object(values) => Value::Object( + values + .into_iter() + .map(|(key, value)| Ok((key, convert(value, depth + 1, remaining)?))) + .collect::>()?, + ), + }) +} + +fn validate(value: &Value, depth: usize, remaining: &mut usize) -> Result<(), Error> { + visit(depth, remaining)?; + match value { + Value::Float(value) if !value.is_finite() => return Err(Error::NonFiniteNumber), + Value::Array(values) => { + for value in values { + validate(value, depth + 1, remaining)?; + } + } + Value::Object(values) => { + for value in values.values() { + validate(value, depth + 1, remaining)?; + } + } + _ => {} + } + Ok(()) +} + +// Only this private borrowed adapter implements the backend trait. Encoding +// does not clone caller strings or construct a second tree. +struct Borrowed<'a>(&'a Value); + +impl serde::Serialize for Borrowed<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::{SerializeMap, SerializeSeq}; + match self.0 { + Value::Null => serializer.serialize_unit(), + Value::Bool(value) => serializer.serialize_bool(*value), + Value::Signed(value) => serializer.serialize_i64(*value), + Value::Unsigned(value) => serializer.serialize_u64(*value), + Value::Float(value) => serializer.serialize_f64(*value), + Value::String(value) => serializer.serialize_str(value), + Value::Array(values) => { + let mut output = serializer.serialize_seq(Some(values.len()))?; + for value in values { + output.serialize_element(&Borrowed(value))?; + } + output.end() + } + Value::Object(values) => { + let mut output = serializer.serialize_map(Some(values.len()))?; + for (key, value) in values { + output.serialize_entry(key, &Borrowed(value))?; + } + output.end() + } + } + } +} + +#[derive(Default)] +struct Output { + bytes: Vec, + exceeded: bool, +} + +impl Write for Output { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > MAX_OUTPUT_BYTES - self.bytes.len() { + self.exceeded = true; + return Err(std::io::Error::other("JSON output limit")); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Encode a caller-owned value, checking depth/count/finite numbers first and +/// enforcing the output byte limit during serialization. No tree/string clone +/// or partial output is returned. These bounds do not limit caller allocations +/// made while constructing a value before this call. +pub fn encode(value: &Value, layout: Layout) -> Result, Error> { + let mut remaining = MAX_NODES; + validate(value, 0, &mut remaining)?; + let mut output = Output::default(); + let result = match layout { + Layout::Compact => serde_json::to_writer(&mut output, &Borrowed(value)), + Layout::Pretty => serde_json::to_writer_pretty(&mut output, &Borrowed(value)), + }; + if output.exceeded { + return Err(Error::OutputTooLarge); + } + result.map_err(|_| Error::EncodingFailed)?; + Ok(output.bytes) +} diff --git a/src/lib.rs b/src/lib.rs index 8af26f06..50b3e1a1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,9 @@ pub mod arguments; /// Bounded configuration decoding with caller-owned schemas and defaults. #[cfg(feature = "config-toml")] pub mod config; +/// Bounded JSON values and encoding with caller-owned schemas. +#[cfg(feature = "json")] +pub mod json; /// Kernel-owned BLAKE3 content hashing for bytes, readers, and files, plus /// an incremental hasher and key-derivation domain separation. diff --git a/tests/facade_policy.rs b/tests/facade_policy.rs index 0887a4d4..395291a2 100644 --- a/tests/facade_policy.rs +++ b/tests/facade_policy.rs @@ -677,7 +677,7 @@ fn public_type_positions(source: &str) -> Vec<(usize, &str)> { } #[test] -fn json_is_confined_to_the_external_firefox_export() { +fn json_backend_is_confined_to_owned_document_and_firefox_adapters() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); for path in rust_sources(&root) { let relative = path.strip_prefix(&root).expect("source below root"); @@ -687,9 +687,9 @@ fn json_is_confined_to_the_external_firefox_export() { assert!( matches!( normalized.as_str(), - "profile/export/firefox.rs" | "profile/tests.rs" + "json.rs" | "profile/export/firefox.rs" | "profile/tests.rs" ), - "{} uses JSON outside the Firefox export boundary", + "{} uses JSON outside the owned adapter boundaries", path.display() ); } diff --git a/tests/json_documents.rs b/tests/json_documents.rs new file mode 100644 index 00000000..2e130012 --- /dev/null +++ b/tests/json_documents.rs @@ -0,0 +1,110 @@ +#![cfg(feature = "json")] + +use kernal_api::json::{encode, parse, Error, Layout, Value}; + +#[test] +fn json_values_preserve_integer_extrema_and_unicode() { + let value = + parse(br#"[-9223372036854775808,18446744073709551615,"\u65e5\u672c",null,true,1.5]"#) + .unwrap(); + assert_eq!( + value, + Value::Array(vec![ + Value::Signed(i64::MIN), + Value::Unsigned(u64::MAX), + Value::String("日本".into()), + Value::Null, + Value::Bool(true), + Value::Float(1.5) + ]) + ); + assert_eq!( + parse(&encode(&value, Layout::Compact).unwrap()).unwrap(), + value + ); +} + +#[test] +fn objects_have_last_key_wins_and_deterministic_layout() { + let value = parse(br#"{"z":1,"a":"x","z":2}"#).unwrap(); + assert_eq!( + encode(&value, Layout::Compact).unwrap(), + br#"{"a":"x","z":2}"# + ); + assert_eq!( + encode(&value, Layout::Pretty).unwrap(), + b"{\n \"a\": \"x\",\n \"z\": 2\n}" + ); +} + +#[test] +fn malformed_input_and_nonfinite_values_are_errors() { + for input in [b"[".as_slice(), b"{} trailing", b"\xff", b"[1,]"] { + assert_eq!(parse(input), Err(Error::InvalidSyntax)); + } + assert_eq!( + encode(&Value::Float(f64::NAN), Layout::Compact), + Err(Error::NonFiniteNumber) + ); +} + +#[test] +fn source_and_output_byte_limits_are_exact() { + use kernal_api::json::{MAX_INPUT_BYTES, MAX_OUTPUT_BYTES}; + let mut source = vec![b' '; MAX_INPUT_BYTES]; + source[0] = b'0'; + assert_eq!(parse(&source), Ok(Value::Signed(0))); + source.push(b' '); + assert_eq!(parse(&source), Err(Error::InputTooLarge)); + let value = Value::String("a".repeat(MAX_OUTPUT_BYTES - 2)); + assert_eq!( + encode(&value, Layout::Compact).unwrap().len(), + MAX_OUTPUT_BYTES + ); + assert_eq!( + encode( + &Value::String("a".repeat(MAX_OUTPUT_BYTES - 1)), + Layout::Compact + ), + Err(Error::OutputTooLarge) + ); + // Escaping expands bytes, and must be included in the output bound. + assert_eq!( + encode( + &Value::String("\n".repeat(MAX_OUTPUT_BYTES / 2)), + Layout::Compact + ), + Err(Error::OutputTooLarge) + ); +} + +#[test] +fn decoded_and_constructed_tree_limits_are_exact() { + use kernal_api::json::{MAX_DEPTH, MAX_NODES}; + let value = Value::Array(vec![Value::Null; MAX_NODES - 1]); + let encoded = encode(&value, Layout::Compact).unwrap(); + assert_eq!(parse(&encoded).unwrap(), value); + let oversized = Value::Array(vec![Value::Null; MAX_NODES]); + assert_eq!( + encode(&oversized, Layout::Compact), + Err(Error::TooManyNodes) + ); + let source = format!("[{}null]", "null,".repeat(MAX_NODES - 1)); + assert_eq!(parse(source.as_bytes()), Err(Error::TooManyNodes)); + let mut value = Value::Null; + for _ in 0..MAX_DEPTH { + value = Value::Array(vec![value]); + } + assert_eq!( + parse(&encode(&value, Layout::Compact).unwrap()).unwrap(), + value + ); + value = Value::Array(vec![value]); + assert_eq!(encode(&value, Layout::Compact), Err(Error::TooDeep)); + let source = format!( + "{}null{}", + "[".repeat(MAX_DEPTH + 1), + "]".repeat(MAX_DEPTH + 1) + ); + assert_eq!(parse(source.as_bytes()), Err(Error::TooDeep)); +} From d2879eb8ae964099dcdf41c3b520acc0f22d623c Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 06:27:26 -0700 Subject: [PATCH 2/3] feat(json): preserve nested members for application schema validation --- .github/workflows/ci.yml | 5 ++ Cargo.toml | 2 +- docs/json.md | 25 +++++++++ src/json.rs | 117 ++++++++++++++++++++++++++++++++++++++- tests/json_documents.rs | 91 ++++++++++++++++++++++++++++++ 5 files changed, 238 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4efe486..3975069f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,6 +211,11 @@ jobs: - run: >- soldr cargo check --locked --no-default-features --features ${{ matrix.feature }} + - name: Test JSON with unified arbitrary-precision backend feature + if: matrix.feature == 'json' + run: >- + soldr cargo test --locked --no-default-features + --features json,serde_json/arbitrary_precision --test json_documents # A unit test does not own process main on every test harness, while Tauri # requires its event loop to start there. These executable proofs diff --git a/Cargo.toml b/Cargo.toml index 6299ad52..04ef0201 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -246,7 +246,7 @@ prost-types = { version = "=0.14.4", optional = true } # the exact set of supported hosts/filesystems this release covers. reflink-copy = { version = "=0.1.30", optional = true } serde = { version = "=1.0.229", features = ["derive"], optional = true } -serde_json = { version = "=1.0.151", optional = true } +serde_json = { version = "=1.0.151", optional = true, features = ["raw_value"] } sha2 = { version = "=0.10.9", optional = true } sysinfo = "=0.30.13" tar = { version = "=0.4.46", default-features = false, optional = true } diff --git a/docs/json.md b/docs/json.md index 535894e3..cb438213 100644 --- a/docs/json.md +++ b/docs/json.md @@ -28,3 +28,28 @@ allocations performed by callers while constructing their values. JSON is distinct from TOML configuration: null, unsigned integers and JSON output semantics should not alter the configuration contract. The private JSON backend is also used by the existing Firefox profile exporter. + +## Duplicate-aware schema inspection + +`parse_members` is an opt-in alternative for typed application protocols that +must distinguish duplicate known fields from duplicate unknown fields. Every +object, including nested objects, becomes `Value::ObjectMembers`, a sequence +of decoded key/value pairs in source order. It does not decide which names are +schema fields and does not merge repeated keys. Escaped equivalent keys have +the same decoded spelling. Ordinary `parse` continues to return last-key-wins +map objects, so existing dynamic-object consumers are unchanged. + +The same byte, depth and node limits apply. Unlike ordinary map parsing, +member parsing checks node/depth bounds during decoding and counts all repeated +member values, rather than only values surviving a merge. It does not build a +second tree or a separate duplicate index. Encoding a member object preserves +all members and their order; it does not promise to preserve source whitespace +or number spellings. Caller-constructed member objects receive the same +encoding validation and output limits as map objects. + +Private borrowed raw-value slices distinguish actual objects from the synthetic +number dispatch enabled by dependency feature unification. Numeric scalars stay +numeric with `serde_json/arbitrary_precision`; user object keys cannot impersonate +that dispatch. Raw syntax validation precedes member decoding and can revisit +nested slices, with work constrained by input size and accepted depth rather +than a separate CPU quota. No raw backend type crosses the public API. diff --git a/src/json.rs b/src/json.rs index 1abc8dde..24b5193f 100644 --- a/src/json.rs +++ b/src/json.rs @@ -13,7 +13,8 @@ pub const MAX_NODES: usize = 262_144; pub const MAX_DEPTH: usize = 64; /// Owned JSON values, independent of the private serialization backend. -/// Objects encode in key order. Positive parsed integers use `Signed` when +/// Map objects encode in key order; member objects preserve order and duplicates. +/// Positive parsed integers use `Signed` when /// representable, otherwise `Unsigned`; numeric variant identity is not a /// wire-format guarantee. Floating-point values must be finite when encoded. #[derive(Clone, Debug, PartialEq)] @@ -26,6 +27,10 @@ pub enum Value { String(String), Array(Vec), Object(BTreeMap), + /// Unmerged members from [`parse_members`], in source order. Applications + /// decide which duplicate fields their schemas accept. Encoding preserves + /// all entries; it does not merge them into a map. + ObjectMembers(Vec<(String, Value)>), } /// Output style; neither style appends a trailing newline. @@ -72,6 +77,104 @@ pub fn parse(source: &[u8]) -> Result { convert(value, 0, &mut remaining) } +/// Parse without merging object members, including objects nested in arrays or +/// other objects. Every object becomes [`Value::ObjectMembers`]. Scalars and +/// arrays have the same representation as [`parse`]. +/// +/// The input byte limit applies before parsing. Depth and node limits apply +/// during decoding, counting every member value even when keys repeat. Keys +/// are not nodes. There is no second tree or duplicate-key index. These bounds +/// are not independent CPU or allocator quotas; individual strings and keys +/// are also bounded by the source byte limit. No partial result is returned. +/// Private borrowed raw-value syntax validation precedes member decoding and +/// can revisit nested source slices, bounded by input size and accepted depth. +pub fn parse_members(source: &[u8]) -> Result { + use serde::de::DeserializeSeed; + if source.len() > MAX_INPUT_BYTES { + return Err(Error::InputTooLarge); + } + let mut state = MemberState { + remaining: MAX_NODES, + failure: None, + }; + let mut parser = serde_json::Deserializer::from_slice(source); + let result = MemberSeed { + state: &mut state, + depth: 0, + } + .deserialize(&mut parser); + let value = result.map_err(|_| state.failure.unwrap_or(Error::InvalidSyntax))?; + parser.end().map_err(|_| Error::InvalidSyntax)?; + Ok(value) +} + +struct MemberState { + remaining: usize, + failure: Option, +} + +struct MemberSeed<'a> { + state: &'a mut MemberState, + depth: usize, +} + +impl<'de> serde::de::DeserializeSeed<'de> for MemberSeed<'_> { + type Value = Value; + fn deserialize>(self, deserializer: D) -> Result { + if let Err(error) = visit(self.depth, &mut self.state.remaining) { + self.state.failure = Some(error); + return Err(serde::de::Error::custom("JSON resource limit")); + } + // A private raw slice distinguishes actual objects from the synthetic + // map used by serde_json when arbitrary_precision is feature-unified. + // Never interpret a user-controlled object key as a backend marker. + let raw: &'de serde_json::value::RawValue = serde::Deserialize::deserialize(deserializer)?; + let mut parser = serde_json::Deserializer::from_str(raw.get()); + use serde::Deserializer; + match raw.get().as_bytes().first() { + Some(b'{') => parser + .deserialize_map(self) + .map_err(serde::de::Error::custom), + Some(b'[') => parser + .deserialize_seq(self) + .map_err(serde::de::Error::custom), + _ => { + let value = serde_json::from_str(raw.get()).map_err(serde::de::Error::custom)?; + let mut scalar_budget = 1; + convert(value, 0, &mut scalar_budget).map_err(serde::de::Error::custom) + } + } + } +} + +impl<'de> serde::de::Visitor<'de> for MemberSeed<'_> { + type Value = Value; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON value") + } + fn visit_seq>(self, mut sequence: A) -> Result { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element_seed(MemberSeed { + state: &mut *self.state, + depth: self.depth + 1, + })? { + values.push(value); + } + Ok(Value::Array(values)) + } + fn visit_map>(self, mut object: A) -> Result { + let mut members = Vec::new(); + while let Some(key) = object.next_key::()? { + let value = object.next_value_seed(MemberSeed { + state: &mut *self.state, + depth: self.depth + 1, + })?; + members.push((key, value)); + } + Ok(Value::ObjectMembers(members)) + } +} + fn visit(depth: usize, remaining: &mut usize) -> Result<(), Error> { if depth > MAX_DEPTH { return Err(Error::TooDeep); @@ -124,6 +227,11 @@ fn validate(value: &Value, depth: usize, remaining: &mut usize) -> Result<(), Er validate(value, depth + 1, remaining)?; } } + Value::ObjectMembers(values) => { + for (_, value) in values { + validate(value, depth + 1, remaining)?; + } + } _ => {} } Ok(()) @@ -157,6 +265,13 @@ impl serde::Serialize for Borrowed<'_> { } output.end() } + Value::ObjectMembers(values) => { + let mut output = serializer.serialize_map(Some(values.len()))?; + for (key, value) in values { + output.serialize_entry(key, &Borrowed(value))?; + } + output.end() + } } } } diff --git a/tests/json_documents.rs b/tests/json_documents.rs index 2e130012..40b64c11 100644 --- a/tests/json_documents.rs +++ b/tests/json_documents.rs @@ -2,6 +2,97 @@ use kernal_api::json::{encode, parse, Error, Layout, Value}; +#[test] +fn member_parsing_retains_nested_duplicates_and_order() { + use kernal_api::json::parse_members; + let source = br#"{"a":null,"a":1,"nested":[{"x":false,"x":true}]}"#; + let value = parse_members(source).unwrap(); + assert_eq!( + value, + Value::ObjectMembers(vec![ + ("a".into(), Value::Null), + ("a".into(), Value::Signed(1)), + ( + "nested".into(), + Value::Array(vec![Value::ObjectMembers(vec![ + ("x".into(), Value::Bool(false)), + ("x".into(), Value::Bool(true)), + ])]) + ), + ]) + ); + assert_eq!(encode(&value, Layout::Compact).unwrap(), source); + assert_ne!(parse(source).unwrap(), value); +} + +#[test] +fn member_parser_bounds_count_repeated_values_and_depth() { + use kernal_api::json::{parse_members, MAX_DEPTH, MAX_INPUT_BYTES, MAX_NODES}; + let source = format!("{{{}\"x\":null}}", "\"x\":null,".repeat(MAX_NODES - 2)); + let Value::ObjectMembers(members) = parse_members(source.as_bytes()).unwrap() else { + panic!("member object") + }; + assert_eq!(members.len(), MAX_NODES - 1); + let source = format!("{{{}\"x\":null}}", "\"x\":null,".repeat(MAX_NODES - 1)); + assert_eq!(parse_members(source.as_bytes()), Err(Error::TooManyNodes)); + for depth in [MAX_DEPTH, MAX_DEPTH + 1] { + let source = format!("{}null{}", "[".repeat(depth), "]".repeat(depth)); + let result = parse_members(source.as_bytes()); + if depth == MAX_DEPTH { + assert!(result.is_ok()); + } else { + assert_eq!(result, Err(Error::TooDeep)); + } + } + let mut source = vec![b' '; MAX_INPUT_BYTES]; + source[0] = b'0'; + assert_eq!(parse_members(&source), Ok(Value::Signed(0))); + source.push(b' '); + assert_eq!(parse_members(&source), Err(Error::InputTooLarge)); + for source in [b"[".as_slice(), b"{} {}", b"\xff", b"{\"a\":1,}"] { + assert_eq!(parse_members(source), Err(Error::InvalidSyntax)); + } +} + +#[test] +fn member_parser_keeps_scalar_contract_and_decoded_key_spelling() { + use kernal_api::json::parse_members; + for source in [ + "null", + "true", + "-9223372036854775808", + "18446744073709551615", + "1.5", + "-0.0", + "\"日本\\ntext\"", + ] { + assert_eq!( + parse_members(source.as_bytes()).unwrap(), + parse(source.as_bytes()).unwrap() + ); + } + assert_eq!( + parse_members(br#"{"a":null,"\u0061":true}"#).unwrap(), + Value::ObjectMembers(vec![ + ("a".into(), Value::Null), + ("a".into(), Value::Bool(true)) + ]) + ); +} + +#[test] +fn member_parser_does_not_confuse_user_keys_with_private_number_markers() { + let key = "$serde_json::private::Number"; + let source = format!("{{\"{key}\":\"1.5\",\"{key}\":2.5}}"); + assert_eq!( + kernal_api::json::parse_members(source.as_bytes()).unwrap(), + Value::ObjectMembers(vec![ + (key.into(), Value::String("1.5".into())), + (key.into(), Value::Float(2.5)), + ]) + ); +} + #[test] fn json_values_preserve_integer_extrema_and_unicode() { let value = From 784c56b0894f7e59ad2570dfc676c2ff875bffe0 Mon Sep 17 00:00:00 2001 From: Zachary Vorhies Date: Sun, 13 Sep 2026 11:07:06 -0700 Subject: [PATCH 3/3] feat(command): add bounded command schema foundation (#216) * feat(command): add bounded schema facade Refs #215. Keep Clap private behind an opt-in facade with bounded input and schema validation. * feat(command): support option relations and repeats Refs #215. Add bounded optional values, repeated strings, relations, and exclusive groups. * feat(command): add positional schema values Refs #215. Preserve nested command matches and bounded positional parsing. * feat(command): parse bounded numeric scalars Refs #215. Add typed f64 and u32 schema values with validated defaults. * feat(command): render facade-owned help Refs #215. Render deterministic command help without exposing Clap. * fix(command): reject non-finite floating values Refs #215. Enforce finite float values and defaults in the facade. * feat(command): support hidden options Refs #215. Keep internal options parseable while omitting them from help. * feat(command): render facade-owned version text Refs #215. Add deterministic version metadata and rendering. * fix(command): distinguish explicit options from defaults Refs #215. Apply option relations only to caller-supplied options. * feat(command): preserve declared native path values Refs #215. Allow explicitly declared OS-string arguments without a lossy UTF-8 conversion while retaining bounded input validation and relation checks. * feat(error): add bounded application context facade Refs #218. Provide owned errors, source chains, context helpers, and a Result alias without a third-party error carrier. * feat(error): support generic result conversions * style(error): format generic conversion facade * ci(release): automate versioned GitHub releases * feat(http): own bounded websocket upgrades * feat(http): split owned websocket sessions * feat(pty): add owned terminal session facade * fix(pty): avoid Windows-only unused trait import --- .github/workflows/auto-release.yml | 97 ++++ .github/workflows/release.yml | 31 +- Cargo.lock | 183 +++++++ Cargo.toml | 13 + ci/test_release_package_features.py | 16 + src/command.rs | 819 ++++++++++++++++++++++++++++ src/error.rs | 151 +++++ src/http_server.rs | 128 ++++- src/http_server/websocket.rs | 358 ++++++++++++ src/lib.rs | 9 + src/pty.rs | 84 +++ tests/command_schema_contract.rs | 327 +++++++++++ 12 files changed, 2199 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/auto-release.yml create mode 100644 src/command.rs create mode 100644 src/error.rs create mode 100644 src/http_server/websocket.rs create mode 100644 src/pty.rs create mode 100644 tests/command_schema_contract.rs diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 00000000..253ee771 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,97 @@ +name: Autonomous Release + +on: + push: + branches: [main] + paths: + - Cargo.toml + - pyproject.toml + - python/kernal_api/__init__.py + workflow_dispatch: + inputs: + dry_run: + description: Report the release decision without creating a release. + type: boolean + default: false + +permissions: + contents: write + actions: write + +concurrency: + group: kernal-api-auto-release-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + prepare: + name: Detect version bump + runs-on: ubuntu-latest + outputs: + should_release: ${{ steps.version.outputs.should_release }} + tag: ${{ steps.version.outputs.tag }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - name: Validate synchronized version and detect a missing release + id: version + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + version="$(python3 - <<'PY' + import tomllib + from pathlib import Path + + cargo = tomllib.loads(Path('Cargo.toml').read_text())['package']['version'] + pyproject = tomllib.loads(Path('pyproject.toml').read_text())['project']['version'] + init = Path('python/kernal_api/__init__.py').read_text() + if cargo == '0.0.0': + raise SystemExit('0.0.0 is a migration-only version and cannot be released') + if cargo != pyproject or f'__version__ = "{cargo}"' not in init: + raise SystemExit('Cargo, pyproject, and Python companion versions must agree') + print(cargo) + PY + )" + tag="v${version}" + if gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + release_state="$(gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" --json isDraft,isPrerelease --jq 'if .isDraft then "draft" elif .isPrerelease then "prerelease" else "published" end')" + if [ "${release_state}" != "published" ]; then + echo "${tag} already has a ${release_state} release; resolve it before automatic release" >&2 + exit 1 + fi + should_release=false + echo "${tag} is already published from this commit" + else + should_release=true + echo "${tag} is not published" + fi + if [ "${should_release}" = true ] && git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + tag_commit="$(git rev-parse "refs/tags/${tag}^{}")" + if [ "${tag_commit}" != "${GITHUB_SHA}" ]; then + echo "${tag} points at ${tag_commit}, not this release candidate ${GITHUB_SHA}" >&2 + exit 1 + fi + fi + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "should_release=${should_release}" >> "$GITHUB_OUTPUT" + + release: + needs: prepare + if: needs.prepare.outputs.should_release == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Create immutable GitHub release from this exact commit + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.prepare.outputs.tag }} + run: >- + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" + --target "$GITHUB_SHA" --generate-notes --title "kernal-api $TAG" + - name: Dispatch release verification and asset packaging + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.prepare.outputs.tag }} + run: gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" -f tag="$TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93d90ddd..574f294a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,12 @@ name: Release on: release: types: [published] + workflow_dispatch: + inputs: + tag: + description: Existing v-prefixed release tag to verify and package. + required: true + type: string permissions: contents: write @@ -10,15 +16,16 @@ permissions: env: SOURCE_DATE_EPOCH: "0" + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} jobs: release-guard: - if: startsWith(github.event.release.tag_name, 'v') + if: startsWith(github.event.release.tag_name || inputs.tag, 'v') runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ env.RELEASE_TAG }} lfs: true - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 - name: Reject migration-only running-process paths @@ -28,13 +35,13 @@ jobs: validate-and-package: needs: release-guard - if: startsWith(github.event.release.tag_name, 'v') + if: startsWith(github.event.release.tag_name || inputs.tag, 'v') runs-on: ubuntu-latest environment: release steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ env.RELEASE_TAG }} lfs: true - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 - uses: zackees/setup-soldr@bb28e96d2dc32c058242f56722297caf1efcbd90 @@ -49,7 +56,7 @@ jobs: shell: bash run: | manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)" - test "v${manifest_version}" = "${{ github.event.release.tag_name }}" + test "v${manifest_version}" = "${{ env.RELEASE_TAG }}" test "${manifest_version}" != "0.0.0" grep -q "version = \"${manifest_version}\"" pyproject.toml grep -q "__version__ = \"${manifest_version}\"" python/kernal_api/__init__.py @@ -82,7 +89,7 @@ jobs: symbolizer-workers: needs: release-guard - if: startsWith(github.event.release.tag_name, 'v') + if: startsWith(github.event.release.tag_name || inputs.tag, 'v') strategy: fail-fast: false matrix: @@ -115,7 +122,7 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ env.RELEASE_TAG }} - if: ${{ !matrix.cross }} uses: zackees/setup-soldr@bb28e96d2dc32c058242f56722297caf1efcbd90 - if: ${{ matrix.cross }} @@ -143,12 +150,13 @@ jobs: publish-crates: needs: [release-guard, validate-and-package] + if: needs.release-guard.result == 'success' && needs.validate-and-package.result == 'success' && vars.ENABLE_REGISTRY_PUBLISH == 'true' runs-on: ubuntu-latest environment: release steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ env.RELEASE_TAG }} lfs: true - uses: zackees/setup-soldr@bb28e96d2dc32c058242f56722297caf1efcbd90 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -160,7 +168,7 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: | - version="${{ github.event.release.tag_name }}" + version="${{ env.RELEASE_TAG }}" version="${version#v}" crate_file="$(find release-packages -name "kernal-api-${version}.crate" -print -quit)" local_sha="$(sha256sum "${crate_file}" | awk '{print $1}')" @@ -179,6 +187,7 @@ jobs: publish-pypi: needs: validate-and-package + if: needs.validate-and-package.result == 'success' && vars.ENABLE_PYPI_PUBLISH == 'true' runs-on: ubuntu-latest environment: release steps: @@ -193,7 +202,7 @@ jobs: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: | - version="${{ github.event.release.tag_name }}" + version="${{ env.RELEASE_TAG }}" version="${version#v}" status="$(curl -sS -o pypi.json -w '%{http_code}' \ "https://pypi.org/pypi/kernal-api/${version}/json")" @@ -223,7 +232,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: >- - gh release upload "${{ github.event.release.tag_name }}" + gh release upload "${{ env.RELEASE_TAG }}" release-assets/conpty-sidecar-*.tar.zst release-assets/kernal-symbolize-* --repo "${{ github.repository }}" --clobber diff --git a/Cargo.lock b/Cargo.lock index 9d803b9c..f22e654b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -475,6 +525,33 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cobs" version = "0.3.0" @@ -484,6 +561,12 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.8" @@ -975,6 +1058,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deflate64" version = "0.1.12" @@ -2347,6 +2436,12 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -2542,6 +2637,7 @@ dependencies = [ "addr2line 0.24.2", "blake3", "bytes", + "clap", "console-api", "console-subscriber", "crash-handler", @@ -2549,6 +2645,7 @@ dependencies = [ "flate2", "framehop", "futures-core", + "futures-util", "getrandom 0.4.3", "globset", "gtk", @@ -2589,6 +2686,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tokio-stream", + "tokio-tungstenite", "toml 0.8.23", "tonic", "tracing", @@ -3344,6 +3442,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.81" @@ -3704,6 +3808,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -3924,6 +4037,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "range-collections" version = "0.4.6" @@ -5387,6 +5529,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -5725,6 +5879,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + [[package]] name = "twox-hash" version = "1.6.3" @@ -5864,12 +6035,24 @@ dependencies = [ "url", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.25.0" diff --git a/Cargo.toml b/Cargo.toml index 04ef0201..7bbc334f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,10 @@ exclude = ["/.github", "/ci", "/dylints", "/python", "/vendor", "/pyproject.toml name = "kernal_api" path = "src/lib.rs" +[[test]] +name = "command_schema_contract" +required-features = ["command-schema"] + [[bin]] name = "kernal-symbolize" path = "src/bin/kernal-symbolize.rs" @@ -38,6 +42,9 @@ default = [] secure-random = ["dep:getrandom"] text-similarity = ["dep:strsim"] command-arguments = ["dep:shell-words"] +command-schema = ["dep:clap"] +# Owned, bounded application-error context without a third-party error carrier. +error-context = [] config-toml = ["dep:toml"] json = ["dep:serde", "dep:serde_json"] source-cpp = ["dep:tree-sitter", "dep:tree-sitter-cpp"] @@ -80,6 +87,9 @@ daemon-registration-v2 = ["running-process/daemon-registration-v2"] pty = ["dep:portable-pty", "terminal-input"] event-stream = ["dep:futures-core", "dep:tokio-stream", "tokio-stream/sync"] http-server = ["dep:hyper", "hyper/server", "hyper/http1", "dep:hyper-util", "dep:http-body-util", "dep:bytes", "dep:futures-core"] +# HTTP/1 WebSocket upgrading remains part of the owned server surface: callers +# exchange facade messages and never name Hyper or a WebSocket implementation. +websocket = ["http-server", "dep:futures-util", "dep:tokio-tungstenite"] # Window-icon and stock-icon mechanics for the host console or a child. This # is GUI hosting: on Linux it decodes PNG and speaks the X11 client protocol, # so it is gated like every peer capability rather than sitting in the @@ -224,10 +234,12 @@ dirs = { version = "=6.0.0", optional = true } flate2 = { version = "=1.1.9", optional = true } framehop = { version = "=0.13.3", optional = true } futures-core = { version = "=0.3.34", optional = true } +futures-util = { version = "=0.3.34", default-features = false, features = ["sink"], 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 } +clap = { version = "=4.6.0", features = ["std", "string"], optional = true } tree-sitter = { version = "=0.26.11", optional = true } tree-sitter-cpp = { version = "=0.23.4", optional = true } globset = { version = "=0.4.18", optional = true } @@ -266,6 +278,7 @@ tokio = { version = "=1.53.1", default-features = false, features = [ "time", ] } tokio-stream = { version = "=0.1.19", optional = true } +tokio-tungstenite = { version = "=0.28.0", default-features = false, features = ["handshake"], optional = true } tonic = { version = "=0.14.6", default-features = false, features = [ "codegen", "transport", diff --git a/ci/test_release_package_features.py b/ci/test_release_package_features.py index 10d9095e..03d3b86b 100644 --- a/ci/test_release_package_features.py +++ b/ci/test_release_package_features.py @@ -12,6 +12,22 @@ def test_registry_package_verifies_all_advertised_features(self) -> None: commands = [line.strip() for line in workflow.splitlines()] self.assertIn("run: soldr cargo package --locked --all-features", commands) + def test_auto_release_dispatches_the_verified_pipeline_without_registry_credentials(self) -> None: + root = Path(__file__).resolve().parents[1] + automatic = (root / ".github/workflows/auto-release.yml").read_text() + release = (root / ".github/workflows/release.yml").read_text() + + self.assertIn("name: Autonomous Release", automatic) + self.assertIn("gh release create", automatic) + self.assertIn("gh workflow run release.yml", automatic) + self.assertIn("cargo == '0.0.0'", automatic) + self.assertIn('tag_commit="$(git rev-parse "refs/tags/${tag}^{}")"', automatic) + self.assertIn('"${tag_commit}" != "${GITHUB_SHA}"', automatic) + self.assertIn("RELEASE_TAG", release) + self.assertIn("github.event.release.tag_name || inputs.tag", release) + self.assertIn("vars.ENABLE_REGISTRY_PUBLISH == 'true'", release) + self.assertIn("vars.ENABLE_PYPI_PUBLISH == 'true'", release) + if __name__ == "__main__": unittest.main() diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 00000000..45409e5e --- /dev/null +++ b/src/command.rs @@ -0,0 +1,819 @@ +//! Bounded declarative command parsing. Applications own their command names, +//! field mapping, and effect policy; Clap is a private implementation detail. + +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::fmt::Write as _; + +/// Maximum number of command-line words accepted by [`Command::parse`]. +pub const MAX_ARGUMENTS: usize = 1_024; +/// Maximum UTF-8 bytes accepted for one command-line word. +pub const MAX_ARGUMENT_BYTES: usize = 64 * 1024; +/// Maximum total UTF-8 bytes accepted for all command-line words. +pub const MAX_TOTAL_ARGUMENT_BYTES: usize = 1024 * 1024; + +/// A facade-owned scalar validation rule. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ValueKind { + /// Any non-NUL UTF-8 string within the input limits. + String, + /// An operating-system string, retained without UTF-8 conversion. + OsString, + /// One of the declared strings. + Enumeration(Vec), + /// A finite IEEE-754 double accepted by Rust's `f64` parser. + F64, + /// A non-negative 32-bit integer. + U32, +} + +impl ValueKind { + /// Accept an arbitrary string value. + pub const fn string() -> Self { + Self::String + } + + /// Accept a non-NUL operating-system string within the input limits. + pub const fn os_string() -> Self { + Self::OsString + } + + /// Accept exactly one of `values`. + pub fn enumeration(values: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self::Enumeration(values.into_iter().map(Into::into).collect()) + } + + /// Accept a Rust `f64` value. + pub const fn f64() -> Self { + Self::F64 + } + + /// Accept a Rust `u32` value. + pub const fn u32() -> Self { + Self::U32 + } +} + +/// One long option in a [`Command`] schema. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OptionSpec { + name: String, + kind: Option, + default: Option, + default_missing: Option, + repeated: bool, + conflicts: Vec, + requires_any: Vec, + help: Option, + hidden: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PositionalSpec { + name: String, + kind: ValueKind, + optional: bool, +} + +impl OptionSpec { + /// Declare a boolean `--name` flag. + pub fn flag(name: impl Into) -> Self { + Self { + name: name.into(), + kind: None, + default: None, + default_missing: None, + repeated: false, + conflicts: Vec::new(), + requires_any: Vec::new(), + help: None, + hidden: false, + } + } + + /// Declare a string-valued `--name VALUE` option. + pub fn value(name: impl Into, kind: ValueKind) -> Self { + Self { + name: name.into(), + kind: Some(kind), + default: None, + default_missing: None, + repeated: false, + conflicts: Vec::new(), + requires_any: Vec::new(), + help: None, + hidden: false, + } + } + + /// Supply a value used when this option is absent. + pub fn default(mut self, value: impl Into) -> Self { + self.default = Some(value.into()); + self + } + + /// Allow this value option without a value and use `value` in that case. + pub fn optional_value(mut self, value: impl Into) -> Self { + self.default_missing = Some(value.into()); + self + } + + /// Preserve every occurrence of this value option in declaration order. + pub fn repeated(mut self) -> Self { + self.repeated = true; + self + } + + /// Reject this option when `other` is also present. + pub fn conflicts(mut self, other: impl Into) -> Self { + self.conflicts.push(other.into()); + self + } + + /// Require one of these option names whenever this option is present. + pub fn requires_any(mut self, names: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.requires_any.extend(names.into_iter().map(Into::into)); + self + } + + /// Describe this option in facade-rendered help. + pub fn help(mut self, text: impl Into) -> Self { + self.help = Some(text.into()); + self + } + + /// Keep this option parseable but omit it from facade-rendered help. + pub fn hidden(mut self) -> Self { + self.hidden = true; + self + } +} + +/// A declarative command and its nested subcommands. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Command { + name: String, + about: Option, + version: Option, + options: Vec, + positionals: Vec, + subcommands: Vec, + exclusive_groups: Vec<(String, Vec)>, +} + +impl Command { + /// Start a command schema. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + about: None, + version: None, + options: Vec::new(), + positionals: Vec::new(), + subcommands: Vec::new(), + exclusive_groups: Vec::new(), + } + } + + /// Add a long option. + pub fn option(mut self, option: OptionSpec) -> Self { + self.options.push(option); + self + } + + /// Describe this command in facade-rendered help. + pub fn about(mut self, text: impl Into) -> Self { + self.about = Some(text.into()); + self + } + + /// Set the version rendered by [`Command::render_version`]. + pub fn version(mut self, value: impl Into) -> Self { + self.version = Some(value.into()); + self + } + + /// Render deterministic, backend-independent version text. + pub fn render_version(&self) -> String { + match &self.version { + Some(version) => format!("{} {version}\n", self.name), + None => format!("{}\n", self.name), + } + } + + /// Render deterministic, backend-independent help for this command. + pub fn render_help(&self) -> String { + let mut output = format!("Usage: {}", self.name); + if !self.options.is_empty() { + output.push_str(" [OPTIONS]"); + } + if !self.positionals.is_empty() { + for positional in &self.positionals { + let token = format!("<{}>", positional.name); + if positional.optional { + let _ = write!(output, " [{token}]"); + } else { + let _ = write!(output, " {token}"); + } + } + } + if !self.subcommands.is_empty() { + output.push_str(" [COMMAND]"); + } + output.push('\n'); + if let Some(about) = &self.about { + let _ = write!(output, "\n{about}\n"); + } + if !self.options.is_empty() { + output.push_str("\nOptions:\n"); + for option in &self.options { + if option.hidden { + continue; + } + let suffix = match &option.kind { + None => String::new(), + Some(_) if option.default_missing.is_some() => " [VALUE]".to_owned(), + Some(_) => " ".to_owned(), + }; + let repeat = if option.repeated { "..." } else { "" }; + let help = option.help.as_deref().unwrap_or(""); + let _ = writeln!(output, " --{}{suffix}{repeat}\t{help}", option.name); + } + } + if !self.subcommands.is_empty() { + output.push_str("\nCommands:\n"); + for command in &self.subcommands { + let about = command.about.as_deref().unwrap_or(""); + let _ = writeln!(output, " {}\t{about}", command.name); + } + } + output + } + + /// Add a required positional value in declaration order. + pub fn positional(mut self, name: impl Into, kind: ValueKind) -> Self { + self.positionals.push(PositionalSpec { + name: name.into(), + kind, + optional: false, + }); + self + } + + /// Add an optional positional value in declaration order. + pub fn optional_positional(mut self, name: impl Into, kind: ValueKind) -> Self { + self.positionals.push(PositionalSpec { + name: name.into(), + kind, + optional: true, + }); + self + } + + /// Add a nested subcommand. + pub fn subcommand(mut self, command: Self) -> Self { + self.subcommands.push(command); + self + } + + /// Require at most one named option from this group. + pub fn exclusive_group(mut self, name: impl Into, options: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.exclusive_groups + .push((name.into(), options.into_iter().map(Into::into).collect())); + self + } + + /// Parse one bounded command line into facade-owned values. + /// + /// The private parser never runs a shell. Input limits are checked before + /// it receives any owned words; errors deliberately do not echo input. + pub fn parse(&self, arguments: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut total = 0usize; + let mut words = Vec::new(); + for argument in arguments { + if words.len() == MAX_ARGUMENTS { + return Err(CommandError::TooManyArguments); + } + let value = argument.as_ref(); + let bytes = value.as_encoded_bytes(); + if bytes.contains(&b'\0') { + return Err(CommandError::ContainsNul); + } + if bytes.len() > MAX_ARGUMENT_BYTES { + return Err(CommandError::ArgumentTooLarge); + } + total = total + .checked_add(bytes.len()) + .ok_or(CommandError::InputTooLarge)?; + if total > MAX_TOTAL_ARGUMENT_BYTES { + return Err(CommandError::InputTooLarge); + } + words.push(value.to_os_string()); + } + let explicit_options = explicit_option_names(&words)?; + self.validate()?; + let command = self.clap_command(); + let matches = command + .try_get_matches_from(words) + .map_err(|_| CommandError::InvalidArguments)?; + let mut path = vec![self.name.clone()]; + let mut schema = self; + let mut selected = vec![(self, &matches)]; + let mut final_matches = &matches; + while let Some((name, next)) = final_matches.subcommand() { + let Some(next_schema) = schema.subcommands.iter().find(|child| child.name == name) + else { + return Err(CommandError::InvalidArguments); + }; + path.push(name.to_owned()); + schema = next_schema; + final_matches = next; + selected.push((schema, final_matches)); + } + let mut values = BTreeMap::new(); + for (command, command_matches) in &selected { + command.collect_values(command_matches, &mut values)?; + } + let selected_schemas = selected + .iter() + .map(|(command, _)| *command) + .collect::>(); + Self::validate_selected_relations(&selected_schemas, &explicit_options)?; + Ok(ParsedCommand { path, values }) + } + + fn clap_command(&self) -> clap::Command { + let mut command = clap::Command::new(self.name.clone()) + .disable_help_flag(false) + .disable_version_flag(true) + .disable_help_subcommand(true) + .subcommand_precedence_over_arg(true); + for option in &self.options { + let mut argument = clap::Arg::new(option.name.clone()) + .long(option.name.clone()) + .global(true); + match &option.kind { + None => argument = argument.action(clap::ArgAction::SetTrue), + Some(ValueKind::String) => argument = argument.action(clap::ArgAction::Set), + Some(ValueKind::OsString) => { + argument = argument + .action(clap::ArgAction::Set) + .value_parser(clap::value_parser!(std::ffi::OsString)); + } + Some(ValueKind::Enumeration(values)) => { + argument = argument + .action(clap::ArgAction::Set) + .value_parser(values.clone()); + } + Some(ValueKind::F64) => { + argument = argument + .action(clap::ArgAction::Set) + .value_parser(clap::value_parser!(f64)); + } + Some(ValueKind::U32) => { + argument = argument + .action(clap::ArgAction::Set) + .value_parser(clap::value_parser!(u32)); + } + } + if let Some(default) = &option.default { + argument = argument.default_value(default); + } + if let Some(default_missing) = &option.default_missing { + argument = argument + .num_args(0..=1) + .default_missing_value(default_missing); + } + if option.repeated { + argument = argument.action(clap::ArgAction::Append); + } + command = command.arg(argument); + } + for (index, positional) in self.positionals.iter().enumerate() { + let mut argument = clap::Arg::new(positional.name.clone()) + .index(index + 1) + .required(!positional.optional) + .action(clap::ArgAction::Set); + match &positional.kind { + ValueKind::String => {} + ValueKind::OsString => { + argument = argument.value_parser(clap::value_parser!(std::ffi::OsString)); + } + ValueKind::Enumeration(values) => argument = argument.value_parser(values.clone()), + ValueKind::F64 => argument = argument.value_parser(clap::value_parser!(f64)), + ValueKind::U32 => argument = argument.value_parser(clap::value_parser!(u32)), + } + command = command.arg(argument); + } + for child in &self.subcommands { + command = command.subcommand(child.clap_command()); + } + command + } + + fn validate(&self) -> Result<(), CommandError> { + let mut option_names = std::collections::BTreeSet::new(); + self.validate_into(&mut option_names)?; + self.validate_references(&option_names) + } + + fn validate_into( + &self, + option_names: &mut std::collections::BTreeSet, + ) -> Result<(), CommandError> { + if !valid_name(&self.name) || self.name == "help" { + return Err(CommandError::InvalidSchema); + } + let mut child_names = std::collections::BTreeSet::new(); + for option in &self.options { + if !valid_name(&option.name) + || option.name == "help" + || !option_names.insert(option.name.clone()) + { + return Err(CommandError::InvalidSchema); + } + match (&option.kind, &option.default, &option.default_missing) { + (None, Some(_), _) | (None, _, Some(_)) => return Err(CommandError::InvalidSchema), + (None, _, _) if option.repeated => return Err(CommandError::InvalidSchema), + (Some(_), Some(_), Some(_)) | (Some(_), Some(_), _) if option.repeated => { + return Err(CommandError::InvalidSchema) + } + (Some(ValueKind::Enumeration(values)), default, default_missing) + if values.is_empty() + || values.iter().any(|value| value.contains('\0')) + || default + .as_ref() + .is_some_and(|value| !values.contains(value)) + || default_missing + .as_ref() + .is_some_and(|value| !values.contains(value)) => + { + return Err(CommandError::InvalidSchema); + } + (_, Some(value), _) if value.contains('\0') => { + return Err(CommandError::InvalidSchema) + } + (_, _, Some(value)) if value.contains('\0') => { + return Err(CommandError::InvalidSchema) + } + _ => {} + } + if let Some(kind) = &option.kind { + if (option.repeated && !matches!(kind, ValueKind::String | ValueKind::OsString)) + || option + .default + .as_ref() + .is_some_and(|value| !valid_value(kind, value)) + || option + .default_missing + .as_ref() + .is_some_and(|value| !valid_value(kind, value)) + { + return Err(CommandError::InvalidSchema); + } + } + } + let mut optional_positional_seen = false; + for positional in &self.positionals { + if !valid_name(&positional.name) + || positional.name == "help" + || !option_names.insert(positional.name.clone()) + || (optional_positional_seen && !positional.optional) + || matches!(&positional.kind, ValueKind::Enumeration(values) if values.is_empty() || values.iter().any(|value| value.contains('\0'))) + { + return Err(CommandError::InvalidSchema); + } + optional_positional_seen |= positional.optional; + } + for child in &self.subcommands { + if child.name == "help" || !child_names.insert(child.name.clone()) { + return Err(CommandError::InvalidSchema); + } + child.validate_into(option_names)?; + } + Ok(()) + } + + fn validate_references( + &self, + option_names: &std::collections::BTreeSet, + ) -> Result<(), CommandError> { + for option in &self.options { + if option + .conflicts + .iter() + .chain(&option.requires_any) + .any(|name| !option_names.contains(name)) + { + return Err(CommandError::InvalidSchema); + } + } + let mut group_names = std::collections::BTreeSet::new(); + for (name, options) in &self.exclusive_groups { + if !valid_name(name) + || !group_names.insert(name) + || options.len() < 2 + || options.iter().any(|option| !option_names.contains(option)) + { + return Err(CommandError::InvalidSchema); + } + } + for child in &self.subcommands { + child.validate_references(option_names)?; + } + Ok(()) + } + + fn collect_values( + &self, + matches: &clap::ArgMatches, + values: &mut BTreeMap, + ) -> Result<(), CommandError> { + for option in &self.options { + let value = match option.kind { + None => ParsedValue::Flag( + matches + .try_get_one::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .copied() + .unwrap_or(false), + ), + Some(ValueKind::String) if option.repeated => matches + .try_get_many::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .map(|items| ParsedValue::Strings(items.cloned().collect())) + .unwrap_or(ParsedValue::Absent), + Some(ValueKind::String) | Some(ValueKind::Enumeration(_)) => matches + .try_get_one::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .cloned() + .map(ParsedValue::String) + .unwrap_or(ParsedValue::Absent), + Some(ValueKind::OsString) if option.repeated => matches + .try_get_many::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .map(|items| ParsedValue::OsStrings(items.cloned().collect())) + .unwrap_or(ParsedValue::Absent), + Some(ValueKind::OsString) => matches + .try_get_one::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .cloned() + .map(ParsedValue::OsString) + .unwrap_or(ParsedValue::Absent), + Some(ValueKind::F64) => match matches + .try_get_one::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .copied() + { + Some(value) if value.is_finite() => ParsedValue::F64(value), + Some(_) => return Err(CommandError::InvalidArguments), + None => ParsedValue::Absent, + }, + Some(ValueKind::U32) => matches + .try_get_one::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .copied() + .map(ParsedValue::U32) + .unwrap_or(ParsedValue::Absent), + }; + values.insert(option.name.clone(), value); + } + for positional in &self.positionals { + let value = match &positional.kind { + ValueKind::String | ValueKind::Enumeration(_) => matches + .try_get_one::(&positional.name) + .map_err(|_| CommandError::InvalidArguments)? + .cloned() + .map(ParsedValue::String) + .unwrap_or(ParsedValue::Absent), + ValueKind::OsString => matches + .try_get_one::(&positional.name) + .map_err(|_| CommandError::InvalidArguments)? + .cloned() + .map(ParsedValue::OsString) + .unwrap_or(ParsedValue::Absent), + ValueKind::F64 => match matches + .try_get_one::(&positional.name) + .map_err(|_| CommandError::InvalidArguments)? + .copied() + { + Some(value) if value.is_finite() => ParsedValue::F64(value), + Some(_) => return Err(CommandError::InvalidArguments), + None => ParsedValue::Absent, + }, + ValueKind::U32 => matches + .try_get_one::(&positional.name) + .map_err(|_| CommandError::InvalidArguments)? + .copied() + .map(ParsedValue::U32) + .unwrap_or(ParsedValue::Absent), + }; + values.insert(positional.name.clone(), value); + } + Ok(()) + } + + fn validate_selected_relations( + selected: &[&Self], + explicit_options: &std::collections::BTreeSet, + ) -> Result<(), CommandError> { + for command in selected { + for option in &command.options { + if explicit_options.contains(&option.name) + && option + .conflicts + .iter() + .any(|name| explicit_options.contains(name)) + { + return Err(CommandError::InvalidArguments); + } + if explicit_options.contains(&option.name) + && !option.requires_any.is_empty() + && !option + .requires_any + .iter() + .any(|name| explicit_options.contains(name)) + { + return Err(CommandError::InvalidArguments); + } + } + for (_, options) in &command.exclusive_groups { + if options + .iter() + .filter(|name| explicit_options.contains(*name)) + .take(2) + .count() + > 1 + { + return Err(CommandError::InvalidArguments); + } + } + } + Ok(()) + } +} + +fn explicit_option_names( + words: &[std::ffi::OsString], +) -> Result, CommandError> { + let mut names = std::collections::BTreeSet::new(); + let mut options_enabled = true; + for word in words.iter().skip(1) { + let bytes = word.as_encoded_bytes(); + if options_enabled && bytes == b"--" { + options_enabled = false; + } else if options_enabled { + if let Some(name) = bytes.strip_prefix(b"--") { + let name = name.split(|byte| *byte == b'=').next().unwrap_or_default(); + if let Ok(name) = std::str::from_utf8(name) { + names.insert(name.to_owned()); + } + } + } + } + Ok(names) +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('-') + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +fn valid_value(kind: &ValueKind, value: &str) -> bool { + match kind { + ValueKind::String => !value.contains('\0'), + ValueKind::OsString => !value.contains('\0'), + ValueKind::Enumeration(values) => values.iter().any(|candidate| candidate == value), + ValueKind::F64 => value.parse::().is_ok_and(f64::is_finite), + ValueKind::U32 => value.parse::().is_ok(), + } +} + +/// Parsed scalar values independent of the private parser backend. +#[derive(Clone, Debug, PartialEq)] +pub enum ParsedValue { + /// A declared flag. + Flag(bool), + /// A declared value option. + String(String), + /// A declared lossless operating-system-string option or positional. + OsString(std::ffi::OsString), + /// A repeated value option, in command-line order. + Strings(Vec), + /// A repeated declared lossless operating-system-string option. + OsStrings(Vec), + /// A declared `f64` option or positional. + F64(f64), + /// A declared `u32` option or positional. + U32(u32), + /// A declared value option was absent and has no default. + Absent, +} + +/// A facade-owned parsed command line. +#[derive(Clone, Debug, PartialEq)] +pub struct ParsedCommand { + path: Vec, + values: BTreeMap, +} + +impl ParsedCommand { + /// Selected command names including the root. + pub fn command_path(&self) -> &[String] { + &self.path + } + + /// Read a declared boolean flag. + pub fn flag(&self, name: &str) -> Option { + match self.values.get(name) { + Some(ParsedValue::Flag(value)) => Some(*value), + _ => None, + } + } + + /// Read a declared string option or its default. + pub fn value(&self, name: &str) -> Option<&str> { + match self.values.get(name) { + Some(ParsedValue::String(value)) => Some(value), + _ => None, + } + } + + /// Read all values of a repeated option. + pub fn values(&self, name: &str) -> Option<&[String]> { + match self.values.get(name) { + Some(ParsedValue::Strings(values)) => Some(values), + _ => None, + } + } + + /// Read a declared operating-system-string option or positional. + pub fn os_value(&self, name: &str) -> Option<&OsStr> { + match self.values.get(name) { + Some(ParsedValue::OsString(value)) => Some(value.as_os_str()), + _ => None, + } + } + + /// Read all values of a repeated declared operating-system-string option. + pub fn os_values(&self, name: &str) -> Option<&[std::ffi::OsString]> { + match self.values.get(name) { + Some(ParsedValue::OsStrings(values)) => Some(values), + _ => None, + } + } + + /// Read a declared `f64` option or positional. + pub fn f64(&self, name: &str) -> Option { + match self.values.get(name) { + Some(ParsedValue::F64(value)) => Some(*value), + _ => None, + } + } + + /// Read a declared `u32` option or positional. + pub fn u32(&self, name: &str) -> Option { + match self.values.get(name) { + Some(ParsedValue::U32(value)) => Some(*value), + _ => None, + } + } +} + +/// Parsing or schema failures without backend diagnostics or input echo. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum CommandError { + #[error("command schema is invalid")] + InvalidSchema, + #[error("command line contains a NUL character")] + ContainsNul, + #[error("command line contains a non-UTF-8 argument")] + InvalidUtf8, + #[error("command line has too many arguments")] + TooManyArguments, + #[error("command-line argument exceeds byte limit")] + ArgumentTooLarge, + #[error("command line exceeds byte limit")] + InputTooLarge, + #[error("invalid command-line arguments")] + InvalidArguments, +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..dfc1b874 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,151 @@ +//! Owned application error context with bounded display text. + +use std::error::Error as StdError; +use std::fmt::{self, Display, Formatter, Write as _}; + +/// Maximum UTF-8 bytes retained for one error context message. +pub const MAX_CONTEXT_BYTES: usize = 8 * 1024; + +/// Context wrapper used by [`Context`] to retain an underlying error. +#[derive(Debug)] +struct ContextError { + message: String, + source: Error, +} + +impl ContextError { + fn new(message: impl Display, source: E) -> Self + where + E: Into, + { + Self { + message: bounded(message), + source: source.into(), + } + } +} + +impl Display for ContextError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message)?; + if formatter.alternate() { + let mut source: Option<&(dyn StdError + 'static)> = Some(self.source.as_ref()); + while let Some(error) = source { + write!(formatter, ": {error}")?; + source = error.source(); + } + } + Ok(()) + } +} + +impl StdError for ContextError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(self.source.as_ref()) + } +} + +/// Facade-owned application error type. Its standard conversion support lets +/// callers use `?` with any thread-safe standard error. +pub type Error = Box; + +/// Construct a message-only error, truncating excessively long text. +pub fn message(message: impl Display) -> Error { + Box::new(MessageError(bounded(message))) +} + +#[derive(Debug)] +struct MessageError(String); + +impl Display for MessageError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl StdError for MessageError {} + +/// Facade-owned application result alias. The optional second parameter keeps +/// ordinary `Result` signatures source-compatible during migration. +pub type Result = std::result::Result; + +/// Add bounded context to a standard fallible result. +pub trait Context { + /// Attach a context message only if the result is an error. + fn context(self, message: impl Display) -> Result; + /// Lazily attach a context message only if the result is an error. + fn with_context(self, message: F) -> Result + where + F: FnOnce() -> M, + M: Display; +} + +impl Context for std::result::Result +where + E: Into, +{ + fn context(self, message: impl Display) -> Result { + self.map_err(|source| Box::new(ContextError::new(message, source)) as Error) + } + + fn with_context(self, message: F) -> Result + where + F: FnOnce() -> M, + M: Display, + { + self.map_err(|source| Box::new(ContextError::new(message(), source)) as Error) + } +} + +impl Context for Option { + fn context(self, message: impl Display) -> Result { + self.ok_or_else(|| crate::error::message(message)) + } + + fn with_context(self, message: F) -> Result + where + F: FnOnce() -> M, + M: Display, + { + self.ok_or_else(|| crate::error::message(message())) + } +} + +fn bounded(message: impl Display) -> String { + let mut output = BoundedText(String::with_capacity(MAX_CONTEXT_BYTES)); + let _ = write!(&mut output, "{message}"); + output.0 +} + +struct BoundedText(String); + +impl fmt::Write for BoundedText { + fn write_str(&mut self, text: &str) -> fmt::Result { + let remaining = MAX_CONTEXT_BYTES.saturating_sub(self.0.len()); + let mut end = remaining.min(text.len()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + self.0.push_str(&text[..end]); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_retains_source_and_bounds_display() { + let error = Err::<(), _>(std::io::Error::other("source detail")) + .context("opening test file") + .unwrap_err(); + assert_eq!(error.to_string(), "opening test file"); + assert!(error.source().is_some()); + assert_eq!( + message("x".repeat(MAX_CONTEXT_BYTES + 1)).to_string().len(), + MAX_CONTEXT_BYTES + ); + assert!(format!("{error:#}").contains("source detail")); + } +} diff --git a/src/http_server.rs b/src/http_server.rs index 7e9f6751..724cb2c7 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -7,7 +7,16 @@ use bytes::Bytes; use http_body_util::{BodyExt, Limited}; use hyper::{body::Incoming, service::service_fn}; use hyper_util::rt::{TokioIo, TokioTimer}; -use std::{convert::Infallible, future::Future, io, net::SocketAddr, sync::Arc, time::Duration}; +use std::{ + convert::Infallible, future::Future, io, net::SocketAddr, pin::Pin, sync::Arc, time::Duration, +}; + +#[cfg(feature = "websocket")] +mod websocket; +#[cfg(feature = "websocket")] +pub use websocket::{ + Message as WebSocketMessage, Upgrade as WebSocketUpgrade, WebSocket, WebSocketLimits, +}; mod body; mod diagnostics; @@ -18,6 +27,9 @@ use diagnostics::increment; pub use diagnostics::{Diagnostics, Snapshot}; pub use target::QueryPairs; +#[cfg(feature = "websocket")] +type UpgradeTask = Pin + Send + 'static>>; + /// Shared bounded native preparation of file responses. Clones share admission; /// use one instance for a server's routes, not one instance per request. #[derive(Clone, Debug)] @@ -185,10 +197,13 @@ impl Limits { #[derive(Debug)] pub struct Request { method: String, + http_1_1: bool, target: String, uri: hyper::Uri, headers: hyper::HeaderMap, body: Vec, + #[cfg(feature = "websocket")] + upgrade: Option, } impl Request { @@ -230,14 +245,33 @@ impl Request { pub fn body(&self) -> &[u8] { &self.body } + + /// Consume this request as an RFC 6455 WebSocket upgrade. Validate route, + /// origin, host and authorization before calling this method. The returned + /// value owns the upgrade future; it cannot be reused as an HTTP request. + #[cfg(feature = "websocket")] + pub fn into_websocket(self) -> io::Result { + websocket::Upgrade::from_request(self) + } } /// An application-selected HTTP response with validated status and headers. -#[derive(Debug)] pub struct Response { status: hyper::StatusCode, headers: hyper::HeaderMap, body: ServerBody, + #[cfg(feature = "websocket")] + upgrade_task: Option, +} + +impl std::fmt::Debug for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Response") + .field("status", &self.status) + .field("headers", &self.headers) + .field("body", &self.body) + .finish_non_exhaustive() + } } impl Default for Response { @@ -247,11 +281,37 @@ impl Default for Response { status: hyper::StatusCode::INTERNAL_SERVER_ERROR, headers: hyper::HeaderMap::new(), body: ServerBody::bytes(Bytes::new()), + #[cfg(feature = "websocket")] + upgrade_task: None, } } } impl Response { + #[cfg(feature = "websocket")] + pub(super) fn websocket_upgrade(accept: &str, upgrade_task: UpgradeTask) -> Self { + let mut headers = hyper::HeaderMap::new(); + headers.insert( + hyper::header::CONNECTION, + hyper::header::HeaderValue::from_static("Upgrade"), + ); + headers.insert( + hyper::header::UPGRADE, + hyper::header::HeaderValue::from_static("websocket"), + ); + headers.insert( + hyper::header::HeaderName::from_static("sec-websocket-accept"), + hyper::header::HeaderValue::from_str(accept) + .expect("derived WebSocket accept key is valid"), + ); + Self { + status: hyper::StatusCode::SWITCHING_PROTOCOLS, + headers, + body: ServerBody::bytes(Bytes::new()), + upgrade_task: Some(upgrade_task), + } + } + /// Construct a response. The server separately enforces its body-size limit. /// /// # Errors @@ -275,6 +335,8 @@ impl Response { status: hyper::StatusCode::from_u16(status).map_err(io::Error::other)?, headers: hyper::HeaderMap::new(), body: ServerBody::bytes(Bytes::from(body)), + #[cfg(feature = "websocket")] + upgrade_task: None, }) } @@ -433,15 +495,49 @@ impl Server { let read_budget = self.read_budget.clone(); tasks.spawn(async move { let request_diagnostics = diagnostics.clone(); - let service = service_fn(move |request| dispatch(request, handler.clone(), limits, request_diagnostics.clone(), response_headers.clone(), read_budget.clone())); + #[cfg(feature = "websocket")] + let (upgrades_tx, mut upgrades_rx) = tokio::sync::mpsc::unbounded_channel(); + let service = service_fn(move |request| { + dispatch( + request, + handler.clone(), + limits, + request_diagnostics.clone(), + response_headers.clone(), + read_budget.clone(), + #[cfg(feature = "websocket")] + upgrades_tx.clone(), + ) + }); let mut builder = hyper::server::conn::http1::Builder::new(); builder.timer(TokioTimer::new()) .header_read_timeout(limits.header_timeout) .max_buf_size(limits.max_header_bytes) .max_headers(limits.max_headers); let socket = transport::ProgressIo::new(socket, limits.write_timeout); - let connection = builder.serve_connection(TokioIo::new(socket), service); - match tokio::time::timeout(limits.connection_timeout, connection).await { + let connection = builder + .serve_connection(TokioIo::new(socket), service) + .with_upgrades(); + #[cfg(feature = "websocket")] + let mut upgrade_tasks = tokio::task::JoinSet::new(); + #[cfg(feature = "websocket")] + let outcome = tokio::time::timeout(limits.connection_timeout, async { + tokio::pin!(connection); + loop { + tokio::select! { + result = &mut connection => break result, + Some(task) = upgrades_rx.recv() => { upgrade_tasks.spawn(task); } + Some(result) = upgrade_tasks.join_next(), if !upgrade_tasks.is_empty() => { + if result.is_err() { increment(&diagnostics.0.task_failures); } + } + } + } + }).await; + #[cfg(not(feature = "websocket"))] + let outcome = tokio::time::timeout(limits.connection_timeout, connection).await; + #[cfg(feature = "websocket")] + upgrade_tasks.abort_all(); + match outcome { Err(_) => increment(&diagnostics.0.connection_timeouts), Ok(Err(_)) => increment(&diagnostics.0.connection_errors), Ok(Ok(())) => increment(&diagnostics.0.completed_connections), @@ -466,12 +562,21 @@ async fn dispatch( diagnostics: Diagnostics, response_headers: Arc, read_budget: body::ReadBudget, + #[cfg(feature = "websocket")] upgrades_tx: tokio::sync::mpsc::UnboundedSender, ) -> Result, Infallible> where H: Fn(Request) -> F, F: Future, { - let mut result = dispatch_inner(request, handler, limits, diagnostics.clone()).await?; + let mut result = dispatch_inner( + request, + handler, + limits, + diagnostics.clone(), + #[cfg(feature = "websocket")] + upgrades_tx, + ) + .await?; result.body_mut().set_read_budget(read_budget); for (name, value) in response_headers.iter() { result.headers_mut().insert(name.clone(), value.clone()); @@ -500,11 +605,15 @@ async fn dispatch_inner( handler: H, limits: Limits, diagnostics: Diagnostics, + #[cfg(feature = "websocket")] upgrades_tx: tokio::sync::mpsc::UnboundedSender, ) -> Result, Infallible> where H: Fn(Request) -> F, F: Future, { + let mut request = request; + #[cfg(feature = "websocket")] + let upgrade = hyper::upgrade::on(&mut request); let (parts, body) = request.into_parts(); let collected = tokio::time::timeout( limits.body_timeout, @@ -528,10 +637,13 @@ where }; let request = Request { method: parts.method.to_string(), + http_1_1: parts.version == hyper::Version::HTTP_11, target: parts.uri.to_string(), uri: parts.uri, headers: parts.headers, body, + #[cfg(feature = "websocket")] + upgrade: Some(upgrade), }; let mut response = match tokio::time::timeout(limits.handler_timeout, handler(request)).await { Ok(response) => response, @@ -540,6 +652,10 @@ where return Ok(empty(hyper::StatusCode::GATEWAY_TIMEOUT)); } }; + #[cfg(feature = "websocket")] + if let Some(upgrade_task) = response.upgrade_task.take() { + let _ = upgrades_tx.send(upgrade_task); + } if !headers_fit(&response.headers, limits) || response.body.configure(limits).is_err() { increment(&diagnostics.0.response_rejections); return Ok(empty(hyper::StatusCode::INTERNAL_SERVER_ERROR)); diff --git a/src/http_server/websocket.rs b/src/http_server/websocket.rs new file mode 100644 index 00000000..3bf9b77b --- /dev/null +++ b/src/http_server/websocket.rs @@ -0,0 +1,358 @@ +//! Owned RFC 6455 upgrade and message transport for [`super::Server`]. +//! +//! Route, origin, authentication and application protocol policy remain with +//! callers. This module owns only HTTP upgrade validation, bounded frames and +//! the private transport implementation. + +use super::{Request, Response}; +use futures_util::{ + stream::{SplitSink, SplitStream}, + SinkExt, StreamExt, +}; +use hyper::header; +use hyper_util::rt::TokioIo; +use std::{future::Future, io}; +use tokio_tungstenite::{tungstenite, WebSocketStream}; + +/// Per-connection WebSocket acceptance limits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WebSocketLimits { + /// Maximum decoded message payload bytes. + pub max_message_bytes: usize, + /// Maximum single-frame payload bytes. + pub max_frame_bytes: usize, +} + +impl Default for WebSocketLimits { + fn default() -> Self { + Self { + max_message_bytes: 64 * 1024, + max_frame_bytes: 64 * 1024, + } + } +} + +impl WebSocketLimits { + fn config(self) -> io::Result { + if !(1..=64 * 1024 * 1024).contains(&self.max_message_bytes) + || !(1..=self.max_message_bytes).contains(&self.max_frame_bytes) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid WebSocket message/frame limits", + )); + } + Ok(tungstenite::protocol::WebSocketConfig::default() + .max_message_size(Some(self.max_message_bytes)) + .max_frame_size(Some(self.max_frame_bytes))) + } +} + +/// Facade-owned WebSocket message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Message { + Text(String), + Binary(Vec), + Ping(Vec), + Pong(Vec), + Close, +} + +fn from_transport(message: tungstenite::Message) -> Message { + match message { + tungstenite::Message::Text(text) => Message::Text(text.to_string()), + tungstenite::Message::Binary(data) => Message::Binary(data.to_vec()), + tungstenite::Message::Ping(data) => Message::Ping(data.to_vec()), + tungstenite::Message::Pong(data) => Message::Pong(data.to_vec()), + tungstenite::Message::Close(_) => Message::Close, + tungstenite::Message::Frame(_) => Message::Close, + } +} + +fn into_transport(message: Message) -> tungstenite::Message { + match message { + Message::Text(text) => tungstenite::Message::Text(text.into()), + Message::Binary(data) => tungstenite::Message::Binary(data.into()), + Message::Ping(data) => tungstenite::Message::Ping(data.into()), + Message::Pong(data) => tungstenite::Message::Pong(data.into()), + Message::Close => tungstenite::Message::Close(None), + } +} + +/// An upgraded connection with bounded facade messages. +pub struct WebSocket { + inner: WebSocketStream>, + limits: WebSocketLimits, +} + +/// Write half of an upgraded connection. It retains the connection's bounded +/// outbound-message policy after [`WebSocket::split`]. +pub struct WebSocketSender { + inner: SplitSink>, tungstenite::Message>, + limits: WebSocketLimits, +} + +/// Read half of an upgraded connection, returned by [`WebSocket::split`]. +pub struct WebSocketReceiver { + inner: SplitStream>>, +} + +impl WebSocket { + /// Receive one message. `Ok(None)` means the peer closed cleanly. + pub async fn receive(&mut self) -> io::Result> { + match self.inner.next().await { + Some(Ok(message)) => Ok(Some(from_transport(message))), + Some(Err(error)) => Err(io::Error::other(error)), + None => Ok(None), + } + } + + /// Send one message, flushing it to the connection. + pub async fn send(&mut self, message: Message) -> io::Result<()> { + validate_outgoing(self.limits, &message)?; + self.inner + .send(into_transport(message)) + .await + .map_err(io::Error::other) + } + + /// Split this connection into independently owned read and write halves. + /// Both halves are cancelled when their owning tasks are dropped. + pub fn split(self) -> (WebSocketSender, WebSocketReceiver) { + let (inner, receiver) = self.inner.split(); + ( + WebSocketSender { + inner, + limits: self.limits, + }, + WebSocketReceiver { inner: receiver }, + ) + } +} + +impl WebSocketSender { + /// Send one bounded message and flush it to the peer. + pub async fn send(&mut self, message: Message) -> io::Result<()> { + validate_outgoing(self.limits, &message)?; + self.inner + .send(into_transport(message)) + .await + .map_err(io::Error::other) + } +} + +impl WebSocketReceiver { + /// Receive one message. `Ok(None)` means the peer closed cleanly. + pub async fn receive(&mut self) -> io::Result> { + match self.inner.next().await { + Some(Ok(message)) => Ok(Some(from_transport(message))), + Some(Err(error)) => Err(io::Error::other(error)), + None => Ok(None), + } + } +} + +fn validate_outgoing(limits: WebSocketLimits, message: &Message) -> io::Result<()> { + let bytes = match message { + Message::Text(text) => text.len(), + Message::Binary(data) | Message::Ping(data) | Message::Pong(data) => data.len(), + Message::Close => 0, + }; + if bytes > limits.max_message_bytes + || bytes > limits.max_frame_bytes + || (matches!(message, Message::Ping(_) | Message::Pong(_)) && bytes > 125) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "WebSocket outgoing message exceeds configured limits", + )); + } + Ok(()) +} + +/// A validated, one-shot WebSocket upgrade. +pub struct Upgrade { + upgrade: hyper::upgrade::OnUpgrade, + accept: String, +} + +impl Upgrade { + pub(super) fn from_request(request: Request) -> io::Result { + if !request.http_1_1 + || request.method != "GET" + || !has_token(&request.headers, header::CONNECTION, "upgrade") + || !has_token(&request.headers, header::UPGRADE, "websocket") + || singleton(&request.headers, "sec-websocket-version") != Some(b"13".as_slice()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "request is not a WebSocket upgrade", + )); + } + let key = singleton(&request.headers, "sec-websocket-key") + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing WebSocket key"))?; + if !valid_key(key) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid WebSocket key", + )); + } + let upgrade = request + .upgrade + .ok_or_else(|| io::Error::other("WebSocket upgrade unavailable"))?; + Ok(Self { + upgrade, + accept: tungstenite::handshake::derive_accept_key(key), + }) + } + + /// Return the switching-protocol response and run `callback` after upgrade. + /// Callback errors are isolated to its connection and do not stop serving. + pub fn on_upgrade(self, limits: WebSocketLimits, callback: F) -> io::Result + where + F: FnOnce(WebSocket) -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + let config = limits.config()?; + let accept = self.accept; + let task = Box::pin(async move { + if let Ok(upgraded) = self.upgrade.await { + let socket = WebSocket { + inner: WebSocketStream::from_raw_socket( + TokioIo::new(upgraded), + tungstenite::protocol::Role::Server, + Some(config), + ) + .await, + limits, + }; + callback(socket).await; + } + }); + Ok(Response::websocket_upgrade(&accept, task)) + } +} + +fn has_token(headers: &hyper::HeaderMap, name: hyper::header::HeaderName, token: &str) -> bool { + headers.get_all(name).iter().any(|value| { + std::str::from_utf8(value.as_bytes()).is_ok_and(|value| { + value + .split(',') + .any(|candidate| candidate.trim().eq_ignore_ascii_case(token)) + }) + }) +} + +fn singleton<'a>(headers: &'a hyper::HeaderMap, name: &str) -> Option<&'a [u8]> { + let values = headers.get_all(name); + let mut values = values.iter(); + let value = values.next()?; + if values.next().is_some() { + None + } else { + Some(value.as_bytes()) + } +} + +/// RFC 6455's nonce is exactly sixteen bytes encoded as standard Base64. +/// Its canonical encoding is consequently twenty-four ASCII bytes ending in +/// two padding characters. The handshake digest consumes the original text. +fn valid_key(key: &[u8]) -> bool { + key.len() == 24 + && key.ends_with(b"==") + && key[..22] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn limits_are_bounded_and_frame_cannot_exceed_message() { + assert!(WebSocketLimits::default().config().is_ok()); + assert!(WebSocketLimits { + max_message_bytes: 0, + max_frame_bytes: 1, + } + .config() + .is_err()); + assert!(WebSocketLimits { + max_message_bytes: 10, + max_frame_bytes: 11, + } + .config() + .is_err()); + } + + #[test] + fn upgrade_rejects_non_websocket_before_claiming_transport() { + let request = Request { + method: "GET".into(), + http_1_1: true, + target: "/terminal/ws".into(), + uri: "/terminal/ws".parse().unwrap(), + headers: hyper::HeaderMap::new(), + body: Vec::new(), + upgrade: None, + }; + assert!(Upgrade::from_request(request).is_err()); + } + + #[test] + fn websocket_key_must_be_one_canonical_16_byte_nonce() { + assert!(valid_key(b"dGhlIHNhbXBsZSBub25jZQ==")); + assert!(!valid_key(b"")); + assert!(!valid_key(b"dGhlIHNhbXBsZSBub25jZQ=")); + assert!(!valid_key(b"!!!!!!!!!!!!!!!!!!!!!!==")); + } + + #[test] + fn facade_messages_do_not_expose_transport_values() { + let message = Message::Binary(vec![1, 2, 3]); + assert_eq!(from_transport(into_transport(message.clone())), message); + } + + #[tokio::test] + async fn server_emits_a_real_switching_protocols_response() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let server = super::super::Server::bind( + "127.0.0.1:0".parse().unwrap(), + super::super::Limits::default(), + ) + .await + .unwrap(); + let address = server.local_addr().unwrap(); + let serving = tokio::spawn(server.serve(|request| async move { + match request.into_websocket() { + Ok(upgrade) => upgrade + .on_upgrade(WebSocketLimits::default(), |_socket| async {}) + .unwrap(), + Err(_) => Response::new(400, Vec::new()).unwrap(), + } + })); + let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); + client + .write_all( + b"GET /ws HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ) + .await + .unwrap(); + let mut response = [0; 1024]; + let count = tokio::time::timeout( + std::time::Duration::from_secs(1), + client.read(&mut response), + ) + .await + .unwrap() + .unwrap(); + let response = std::str::from_utf8(&response[..count]).unwrap(); + assert!(response.starts_with("HTTP/1.1 101 Switching Protocols\r\n")); + assert!(response.contains("connection: Upgrade\r\n")); + assert!(response.contains("upgrade: websocket\r\n")); + assert!(response.contains("sec-websocket-accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n")); + serving.abort(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 50b3e1a1..df154700 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,12 @@ mod process_adapter; #[cfg(feature = "command-arguments")] pub mod arguments; +/// Bounded command-line schema parsing with facade-owned values and diagnostics. +#[cfg(feature = "command-schema")] +pub mod command; +/// Owned application error context and source chaining. +#[cfg(feature = "error-context")] +pub mod error; /// Bounded configuration decoding with caller-owned schemas and defaults. #[cfg(feature = "config-toml")] @@ -59,6 +65,9 @@ pub mod archive; pub mod http; #[cfg(feature = "http-server")] pub mod http_server; +/// Owned native pseudo-terminal sessions without backend descriptor types. +#[cfg(feature = "pty")] +pub mod pty; /// Facade-owned identity, sidecar, probe, and endpoint-mux semantics for an /// existing daemon endpoint. diff --git a/src/pty.rs b/src/pty.rs new file mode 100644 index 00000000..cd2875a9 --- /dev/null +++ b/src/pty.rs @@ -0,0 +1,84 @@ +//! Owned pseudo-terminal session facade. + +#[cfg(not(windows))] +use crate::platform::terminal::PtyChild; +use crate::{ + platform::terminal::{PtyBackend, PtyMaster, PtySize, PtySlave}, + Backend, +}; +use std::{ + ffi::OsString, + io::{self, Read, Write}, + path::PathBuf, +}; + +/// Caller-selected process program and arguments for a native PTY session. +#[derive(Debug, Clone)] +pub struct PtyCommand { + pub program: OsString, + pub arguments: Vec, + pub cwd: Option, + pub environment: Option>, +} + +impl PtyCommand { + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + arguments: Vec::new(), + cwd: None, + environment: None, + } + } +} + +/// An owned child process and its pseudo-terminal. +pub struct PtySession { + master: ::Master, + child: <::Slave as PtySlave>::Child, + writer: Box, +} + +impl PtySession { + pub fn spawn(command: PtyCommand, size: PtySize) -> io::Result<(Self, Box)> { + let (mut master, slave) = Backend::openpty(size)?; + let reader = master.try_clone_reader()?; + let writer = master.take_writer()?; + let mut argv = Vec::with_capacity(command.arguments.len() + 1); + argv.push(command.program); + argv.extend(command.arguments); + let child = slave.spawn( + &argv, + command.cwd.as_deref(), + command.environment.as_deref(), + )?; + Ok(( + Self { + master, + child, + writer, + }, + reader, + )) + } + + pub fn write(&mut self, bytes: &[u8]) -> io::Result<()> { + self.writer + .write_all(bytes) + .and_then(|_| self.writer.flush()) + } + pub fn resize(&self, size: PtySize) -> io::Result<()> { + self.master.resize(size) + } + pub fn try_wait(&mut self) -> io::Result> { + self.child.try_wait() + } +} + +impl Drop for PtySession { + fn drop(&mut self) { + let _ = self.master.kill_process_group(); + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} diff --git a/tests/command_schema_contract.rs b/tests/command_schema_contract.rs new file mode 100644 index 00000000..a10748e5 --- /dev/null +++ b/tests/command_schema_contract.rs @@ -0,0 +1,327 @@ +use kernal_api::command::{ + Command, CommandError, OptionSpec, ValueKind, MAX_ARGUMENTS, MAX_ARGUMENT_BYTES, +}; + +#[test] +fn parses_nested_commands_defaults_repeated_values_and_constraints() { + let schema = Command::new("fastled") + .option(OptionSpec::flag("quick")) + .option( + OptionSpec::value("link", ValueKind::enumeration(["static", "dynamic"])) + .default("static"), + ) + .subcommand( + Command::new("source").subcommand( + Command::new("update") + .option(OptionSpec::value("ref", ValueKind::string()).default("master")), + ), + ); + let parsed = schema + .parse(["fastled", "--quick", "source", "update", "--ref", "main"]) + .unwrap(); + assert_eq!(parsed.command_path(), ["fastled", "source", "update"]); + assert_eq!(parsed.flag("quick"), Some(true)); + assert_eq!(parsed.value("link"), Some("static")); + assert_eq!(parsed.value("ref"), Some("main")); +} + +#[test] +fn rejects_invalid_enumerations_and_bounds_before_backend_parsing() { + let schema = Command::new("fastled").option(OptionSpec::value( + "link", + ValueKind::enumeration(["static", "dynamic"]), + )); + assert_eq!( + schema.parse(["fastled", "--link", "unsupported"]), + Err(CommandError::InvalidArguments) + ); + assert_eq!( + schema.parse(["fastled", "--link", &"x".repeat(MAX_ARGUMENT_BYTES + 1)]), + Err(CommandError::ArgumentTooLarge) + ); + let too_many = std::iter::once("fastled").chain(std::iter::repeat_n("arg", MAX_ARGUMENTS)); + assert_eq!(schema.parse(too_many), Err(CommandError::TooManyArguments)); + let too_large = std::iter::once("fastled".to_owned()) + .chain(std::iter::repeat_n("x".repeat(MAX_ARGUMENT_BYTES), 17)) + .collect::>(); + assert_eq!(schema.parse(&too_large), Err(CommandError::InputTooLarge)); +} + +#[test] +fn invalid_schema_never_exposes_a_backend_error() { + let invalid = Command::new("fastled").option(OptionSpec::value( + "mode", + ValueKind::enumeration(Vec::::new()), + )); + assert_eq!(invalid.parse(["fastled"]), Err(CommandError::InvalidSchema)); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::flag("bad\0name")) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::flag("help")) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::flag("-bad")) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::flag("flag").default("true")) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::flag("flag").optional_value("true")) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::value("timeout", ValueKind::f64()).default("never")) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::flag("same")) + .subcommand(Command::new("child").option(OptionSpec::flag("same"))) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); +} + +#[test] +fn root_and_sibling_subcommands_only_collect_selected_schema_values() { + let schema = Command::new("fastled") + .option(OptionSpec::flag("quick")) + .subcommand(Command::new("one").option(OptionSpec::value("first", ValueKind::string()))) + .subcommand(Command::new("two").option(OptionSpec::value("second", ValueKind::string()))); + let root = schema.parse(["fastled", "--quick"]).unwrap(); + assert_eq!(root.flag("quick"), Some(true)); + assert_eq!(root.value("first"), None); + let selected = schema + .parse(["fastled", "two", "--second", "value"]) + .unwrap(); + assert_eq!(selected.command_path(), ["fastled", "two"]); + assert_eq!(selected.value("second"), Some("value")); + assert_eq!(selected.value("first"), None); +} + +#[test] +fn optional_and_repeated_values_and_option_relations_are_enforced() { + let schema = Command::new("fastled") + .option( + OptionSpec::value("init", ValueKind::string()) + .optional_value("__init__") + .conflicts("purge"), + ) + .option(OptionSpec::flag("purge").conflicts("init")) + .option(OptionSpec::flag("test")) + .option(OptionSpec::flag("check")) + .option( + OptionSpec::value("test-cmd", ValueKind::string()) + .repeated() + .requires_any(["test", "check"]), + ) + .exclusive_group("production-test", ["test", "check"]); + + let parsed = schema + .parse([ + "fastled", + "--init", + "--test", + "--test-cmd=first", + "--test-cmd", + "second", + ]) + .unwrap(); + assert_eq!(parsed.value("init"), Some("__init__")); + assert_eq!( + parsed.values("test-cmd").unwrap(), + &["first".to_owned(), "second".to_owned()] + ); + assert_eq!( + schema.parse(["fastled", "--test-cmd=first"]), + Err(CommandError::InvalidArguments) + ); + assert_eq!( + schema.parse(["fastled", "--test", "--check"]), + Err(CommandError::InvalidArguments) + ); + assert_eq!( + schema.parse(["fastled", "--init", "--purge"]), + Err(CommandError::InvalidArguments) + ); +} + +#[test] +fn defaulted_options_do_not_count_as_explicit_relation_inputs() { + let schema = Command::new("fastled") + .option(OptionSpec::flag("test")) + .option( + OptionSpec::value("timeout", ValueKind::f64()) + .default("120") + .requires_any(["test"]), + ); + assert!(schema.parse(["fastled"]).is_ok()); + assert_eq!( + schema.parse(["fastled", "--timeout", "10"]), + Err(CommandError::InvalidArguments) + ); +} + +#[test] +fn optional_positionals_do_not_hide_subcommands() { + let schema = Command::new("fastled") + .optional_positional("directory", ValueKind::string()) + .subcommand( + Command::new("toolchain") + .subcommand(Command::new("activate").positional("package-id", ValueKind::string())), + ); + + let directory = schema.parse(["fastled", "sketch"]).unwrap(); + assert_eq!(directory.value("directory"), Some("sketch")); + assert_eq!(directory.command_path(), ["fastled"]); + + let nested = schema + .parse(["fastled", "toolchain", "activate", "wasm-3.1"]) + .unwrap(); + assert_eq!(nested.command_path(), ["fastled", "toolchain", "activate"]); + assert_eq!(nested.value("directory"), None); + assert_eq!(nested.value("package-id"), Some("wasm-3.1")); + assert_eq!( + schema.parse(["fastled", "toolchain", "activate"]), + Err(CommandError::InvalidArguments) + ); +} + +#[test] +fn typed_scalars_keep_their_types_and_reject_invalid_input() { + let schema = Command::new("fastled") + .option(OptionSpec::value("timeout", ValueKind::f64()).default("120")) + .option(OptionSpec::value("count", ValueKind::u32())); + + let parsed = schema + .parse(["fastled", "--timeout", "1.5", "--count", "10"]) + .unwrap(); + assert_eq!(parsed.f64("timeout"), Some(1.5)); + assert_eq!(parsed.u32("count"), Some(10)); + assert_eq!( + schema.parse(["fastled", "--timeout", "not-a-float"]), + Err(CommandError::InvalidArguments) + ); + assert_eq!( + schema.parse(["fastled", "--timeout", "NaN"]), + Err(CommandError::InvalidArguments) + ); + assert_eq!( + schema.parse(["fastled", "--timeout", "inf"]), + Err(CommandError::InvalidArguments) + ); + assert_eq!( + schema.parse(["fastled", "--count", "-1"]), + Err(CommandError::InvalidArguments) + ); +} + +#[test] +fn help_is_rendered_without_exposing_the_parser_backend() { + let schema = Command::new("fastled") + .about("FastLED WASM compilation CLI") + .option(OptionSpec::flag("quick").help("Build quickly.")) + .option( + OptionSpec::value("link", ValueKind::enumeration(["static", "dynamic"])) + .help("Select static or dynamic linking."), + ) + .subcommand(Command::new("source").about("Manage cached source.")); + + let help = schema.render_help(); + assert!(help.contains("Usage: fastled [OPTIONS] [COMMAND]")); + assert!(help.contains("FastLED WASM compilation CLI")); + assert!(help.contains("--quick")); + assert!(help.contains("Select static or dynamic linking.")); + assert!(help.contains("source")); + assert!(!help.contains("clap")); +} + +#[test] +fn hidden_options_parse_but_are_absent_from_help_and_double_dash_is_literal() { + let schema = Command::new("fastled") + .option(OptionSpec::flag("internal").hidden()) + .optional_positional("directory", ValueKind::string()); + + let hidden = schema.parse(["fastled", "--internal"]).unwrap(); + assert_eq!(hidden.flag("internal"), Some(true)); + assert!(!schema.render_help().contains("internal")); + + let literal = schema.parse(["fastled", "--", "--not-an-option"]).unwrap(); + assert_eq!(literal.value("directory"), Some("--not-an-option")); +} + +#[test] +fn version_is_rendered_from_facade_owned_metadata() { + let schema = Command::new("fastled").version("2.0.20"); + assert_eq!(schema.render_version(), "fastled 2.0.20\n"); +} + +#[cfg(unix)] +#[test] +fn non_utf8_native_arguments_require_an_os_string_schema_value() { + use std::os::unix::ffi::OsStringExt; + + let string_schema = + Command::new("fastled").optional_positional("directory", ValueKind::string()); + assert_eq!( + string_schema.parse([ + std::ffi::OsString::from("fastled"), + std::ffi::OsString::from_vec(vec![0xff]) + ]), + Err(CommandError::InvalidArguments) + ); + + let os_schema = + Command::new("fastled").optional_positional("directory", ValueKind::os_string()); + let parsed = os_schema + .parse([ + std::ffi::OsString::from("fastled"), + std::ffi::OsString::from_vec(vec![0xff]), + ]) + .unwrap(); + assert_eq!( + parsed.os_value("directory").unwrap().as_encoded_bytes(), + [0xff] + ); + + let guarded = Command::new("fastled") + .option(OptionSpec::flag("enabled")) + .option( + OptionSpec::value("path", ValueKind::os_string()) + .repeated() + .requires_any(["enabled"]), + ); + let inline = std::ffi::OsString::from_vec(b"--path=\xff".to_vec()); + assert_eq!( + guarded.parse([std::ffi::OsString::from("fastled"), inline.clone()]), + Err(CommandError::InvalidArguments) + ); + let parsed = guarded + .parse([ + std::ffi::OsString::from("fastled"), + std::ffi::OsString::from("--enabled"), + inline, + ]) + .unwrap(); + assert_eq!( + parsed.os_values("path").unwrap()[0].as_encoded_bytes(), + b"\xff" + ); +}