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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ jobs:
- hash-sha256
- secure-random
- text-similarity
- command-arguments
- terminal-style
- terminal-input
- event-stream
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions ci/check_compilation_boundary_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
44 changes: 44 additions & 0 deletions src/arguments.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<String>, 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)
}
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
58 changes: 58 additions & 0 deletions tests/posix_arguments.rs
Original file line number Diff line number Diff line change
@@ -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::<String>::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)
);
}
Loading