diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 922ed88c..1e97ab6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,6 +176,7 @@ jobs: - hash-sha256 - secure-random - text-similarity + - command-arguments - terminal-style - terminal-input - event-stream diff --git a/Cargo.lock b/Cargo.lock index 6c11b14c..875ef582 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2577,6 +2577,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "shell-words", "strsim", "sysinfo", "tar", diff --git a/Cargo.toml b/Cargo.toml index c8e2fb75..d8326968 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ default = [] # Bounded OS entropy, independent of crash/profiling facilities. secure-random = ["dep:getrandom"] text-similarity = ["dep:strsim"] +command-arguments = ["dep:shell-words"] terminal-style = [] # Native terminal capture and key decoding without PTY process spawning. terminal-input = [] @@ -222,6 +223,7 @@ framehop = { version = "=0.13.3", optional = true } futures-core = { version = "=0.3.34", optional = true } getrandom = { version = "=0.4.3", optional = true } strsim = { version = "=0.11.1", optional = true } +shell-words = { version = "=1.1.1", optional = true } globset = { version = "=0.4.18", optional = true } interprocess = { version = "=2.4.3", optional = true } jwalk = { version = "=0.8.1", optional = true } diff --git a/ci/check_compilation_boundary_dependencies.py b/ci/check_compilation_boundary_dependencies.py index 928f20ad..110fd1fb 100644 --- a/ci/check_compilation_boundary_dependencies.py +++ b/ci/check_compilation_boundary_dependencies.py @@ -14,6 +14,7 @@ CASES = ( ("pty", "portable-pty"), ("text-similarity", "strsim"), + ("command-arguments", "shell-words"), ("wasm-sketch-host", "wasmtime"), ("ipc", "interprocess"), ("tokio-console", "console-subscriber"), diff --git a/src/arguments.rs b/src/arguments.rs new file mode 100644 index 00000000..60e6cd85 --- /dev/null +++ b/src/arguments.rs @@ -0,0 +1,44 @@ +//! Bounded argument decoding for tool output, not shell execution. + +/// Maximum UTF-8 input size, checked before parsing or allocation. +pub const MAX_POSIX_INPUT_BYTES: usize = 1024 * 1024; +/// Maximum number of decoded arguments returned to a caller. +pub const MAX_POSIX_ARGUMENTS: usize = 16_384; + +/// Semantic failures independent of the private parsing implementation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ArgumentParseError { + #[error("argument source exceeds 1048576 UTF-8 bytes")] + InputTooLarge, + #[error("argument source contains a NUL character")] + ContainsNul, + #[error("missing closing quote")] + UnterminatedQuote, + #[error("argument source exceeds 16384 decoded arguments")] + TooManyArguments, +} + +/// Decode POSIX-style quoting, escapes, continuations and comments into words. +/// +/// No shell is started and no variable, tilde, glob, arithmetic or command +/// expansion occurs. Operators are literal text rather than shell grammar. +/// This is not Windows command-line decoding. Empty quoted words are retained; +/// empty or comment-only input returns an empty vector for caller policy. +/// +/// Input is limited to [`MAX_POSIX_INPUT_BYTES`] before backend allocation. +/// This also bounds intermediate storage when [`MAX_POSIX_ARGUMENTS`] is +/// exceeded: the word-count check occurs after parsing. Errors never return a +/// truncated list. NUL is rejected because process arguments cannot contain it. +pub fn parse_posix(source: &str) -> Result, ArgumentParseError> { + if source.len() > MAX_POSIX_INPUT_BYTES { + return Err(ArgumentParseError::InputTooLarge); + } + if source.contains('\0') { + return Err(ArgumentParseError::ContainsNul); + } + let words = shell_words::split(source).map_err(|_| ArgumentParseError::UnterminatedQuote)?; + if words.len() > MAX_POSIX_ARGUMENTS { + return Err(ArgumentParseError::TooManyArguments); + } + Ok(words) +} diff --git a/src/lib.rs b/src/lib.rs index ccb85bca..176e1109 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,9 @@ use std::process::ExitStatus; mod process_adapter; +#[cfg(feature = "command-arguments")] +pub mod arguments; + /// Kernel-owned BLAKE3 content hashing for bytes, readers, and files, plus /// an incremental hasher and key-derivation domain separation. pub mod hash; diff --git a/tests/posix_arguments.rs b/tests/posix_arguments.rs new file mode 100644 index 00000000..e4d5c2b8 --- /dev/null +++ b/tests/posix_arguments.rs @@ -0,0 +1,58 @@ +#![cfg(feature = "command-arguments")] + +use kernal_api::arguments::{ + parse_posix, ArgumentParseError, MAX_POSIX_ARGUMENTS, MAX_POSIX_INPUT_BYTES, +}; + +#[test] +fn tool_arguments_preserve_quoting_without_expansion() { + assert_eq!( + parse_posix("-I'/path with spaces' \"\" '日本語' '$HOME' '*.cpp' $(literal)").unwrap(), + [ + "-I/path with spaces", + "", + "日本語", + "$HOME", + "*.cpp", + "$(literal)" + ] + ); +} + +#[test] +fn escapes_comments_and_empty_input_keep_tool_output_semantics() { + assert_eq!(parse_posix(" # ignored\n").unwrap(), Vec::::new()); + assert_eq!( + parse_posix("a\\ b c\\\nd 'x#y' # ignored\nend").unwrap(), + ["a b", "cd", "x#y", "end"] + ); + assert_eq!( + parse_posix("'open"), + Err(ArgumentParseError::UnterminatedQuote) + ); + assert_eq!( + parse_posix("\"open"), + Err(ArgumentParseError::UnterminatedQuote) + ); + assert_eq!(parse_posix("a\0b"), Err(ArgumentParseError::ContainsNul)); +} + +#[test] +fn input_bytes_and_output_count_have_exact_limits() { + let limit = "é".repeat(MAX_POSIX_INPUT_BYTES / 2); + assert_eq!(parse_posix(&limit).unwrap(), std::slice::from_ref(&limit)); + assert_eq!( + parse_posix(&(limit + "x")), + Err(ArgumentParseError::InputTooLarge) + ); + assert_eq!( + parse_posix(&"x ".repeat(MAX_POSIX_ARGUMENTS)) + .unwrap() + .len(), + MAX_POSIX_ARGUMENTS + ); + assert_eq!( + parse_posix(&"x ".repeat(MAX_POSIX_ARGUMENTS + 1)), + Err(ArgumentParseError::TooManyArguments) + ); +}