use apcore::{ErrorCode, ModuleError};
use serde_json::Value;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
const BINDING_INJECTION_CHARS: &[char] = &[
';', '|', '&', '$', '`', '\\', '\'', '"', '\n', '\r', '\0', '(', ')', '<', '>',
];
const CONTROL_CHARS: &[char] = &['\0', '\n', '\r', '\u{2028}', '\u{2029}', '\u{0085}'];
pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
#[allow(clippy::result_large_err)] pub fn validate_no_injection(param_name: &str, value: &str) -> Result<(), ModuleError> {
let found: Vec<char> = value
.chars()
.filter(|c| BINDING_INJECTION_CHARS.contains(c))
.collect();
if !found.is_empty() {
return Err(ModuleError::new(
ErrorCode::GeneralInvalidInput,
format!(
"Parameter '{}' contains prohibited characters: {:?}",
param_name, found
),
));
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ValueLocation<'a> {
pub param_name: &'a str,
pub index: Option<usize>,
}
impl ValueLocation<'_> {
fn describe(&self) -> String {
match self.index {
Some(index) => format!("Element {index} of parameter '{}'", self.param_name),
None => format!("Parameter '{}'", self.param_name),
}
}
}
#[allow(clippy::result_large_err)] pub fn validate_argument_value(
location: ValueLocation<'_>,
value: &str,
numeric: bool,
separator_available: bool,
) -> Result<(), ModuleError> {
let found: Vec<char> = value
.chars()
.filter(|c| CONTROL_CHARS.contains(c))
.collect();
if !found.is_empty() {
return Err(ModuleError::new(
ErrorCode::GeneralInvalidInput,
format!(
"{} contains prohibited control characters: {:?}",
location.describe(),
found
),
));
}
if !numeric && !separator_available && value.starts_with('-') {
return Err(ModuleError::new(
ErrorCode::GeneralInvalidInput,
format!(
"{} is '{value}', which starts with '-'; the wrapped command \
would parse it as an option rather than a value",
location.describe()
),
));
}
Ok(())
}
fn json_value_to_string(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
other => other.to_string(),
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum OperandPlacement {
AfterFlags,
BeforeFlags,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum FlagPlacement {
Default,
BeforeOperands,
BeforeSubcommand,
}
enum ArgForm {
Flag(String, FlagPlacement),
Positional(u64, OperandPlacement),
}
fn arg_form(schema: Option<&Value>, key: &str) -> ArgForm {
let property = schema
.and_then(|s| s.get("properties"))
.and_then(|p| p.get(key));
if let Some(index) = property
.and_then(|p| p.get("x-apexe-positional"))
.and_then(Value::as_u64)
{
let placement = match property
.and_then(|p| p.get("x-apexe-operand-position"))
.and_then(Value::as_str)
{
Some("before-flags") => OperandPlacement::BeforeFlags,
_ => OperandPlacement::AfterFlags,
};
return ArgForm::Positional(index, placement);
}
let placement = match property
.and_then(|p| p.get("x-apexe-flag-position"))
.and_then(Value::as_str)
{
Some("before-operands") => FlagPlacement::BeforeOperands,
Some("before-subcommand") => FlagPlacement::BeforeSubcommand,
_ => FlagPlacement::Default,
};
if let Some(literal) = property
.and_then(|p| p.get("x-apexe-flag"))
.and_then(Value::as_str)
{
return ArgForm::Flag(literal.to_string(), placement);
}
ArgForm::Flag(format!("--{}", key.replace('_', "-")), placement)
}
const END_OF_OPTIONS: &str = "--";
fn honours_end_of_options(input_schema: Option<&Value>) -> bool {
input_schema
.and_then(|s| s.get("x-apexe-end-of-options"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn operands_precede_flags(input_schema: Option<&Value>) -> bool {
input_schema
.and_then(|s| s.get("properties"))
.and_then(Value::as_object)
.is_some_and(|properties| {
properties.values().any(|property| {
property
.get("x-apexe-operand-position")
.and_then(Value::as_str)
== Some("before-flags")
})
})
}
fn value_must_be_attached(input_schema: Option<&Value>, key: &str) -> bool {
input_schema
.and_then(|s| s.get("properties"))
.and_then(|p| p.get(key))
.and_then(|p| p.get("x-apexe-value-optional"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn is_effective(value: &Value) -> bool {
!matches!(value, Value::Null | Value::Bool(false))
}
#[allow(clippy::result_large_err)] fn reject_conflicting_flags(
kwargs: &serde_json::Map<String, Value>,
input_schema: Option<&Value>,
) -> Result<(), ModuleError> {
let Some(properties) = input_schema
.and_then(|s| s.get("properties"))
.and_then(Value::as_object)
else {
return Ok(());
};
for (key, value) in kwargs {
if !is_effective(value) {
continue;
}
let Some(conflicts) = properties
.get(key)
.and_then(|p| p.get("x-apexe-conflicts-with"))
.and_then(Value::as_array)
else {
continue;
};
for other in conflicts.iter().filter_map(Value::as_str) {
if other <= key.as_str() {
continue;
}
if kwargs.get(other).is_some_and(is_effective) {
return Err(ModuleError::new(
ErrorCode::GeneralInvalidInput,
format!(
"Parameters '{key}' and '{other}' cannot be used together; \
send one or the other"
),
));
}
}
}
Ok(())
}
#[allow(clippy::result_large_err)] fn reject_bare_flag_for_value_option(
input_schema: Option<&Value>,
key: &str,
literal: &str,
) -> Result<(), ModuleError> {
let declared_type = input_schema
.and_then(|s| s.get("properties"))
.and_then(|p| p.get(key))
.and_then(|p| p.get("type"))
.and_then(Value::as_str);
if matches!(
declared_type,
Some("string" | "number" | "integer") ) {
return Err(ModuleError::new(
ErrorCode::GeneralInvalidInput,
format!(
"Parameter '{key}' takes a value, so it cannot be sent as `true`: \
'{literal}' would be rendered bare and the wrapped command would \
read the next argument as its value"
),
));
}
Ok(())
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RenderedArgv {
pub before_subcommand: Vec<String>,
pub after_subcommand: Vec<String>,
}
impl RenderedArgv {
pub fn into_flat(self) -> Vec<String> {
let mut args = self.before_subcommand;
args.extend(self.after_subcommand);
args
}
}
#[derive(Debug, Default)]
struct ArgvGroups {
before_subcommand: Vec<String>,
leading_flags: Vec<String>,
trailing_flags: Vec<String>,
leading_operands: Vec<(u64, Vec<String>)>,
trailing_operands: Vec<(u64, Vec<String>)>,
saw_dash_leading_value: bool,
}
impl ArgvGroups {
fn extend(&mut self, form: &ArgForm, tokens: Vec<String>) {
match *form {
ArgForm::Flag(_, FlagPlacement::BeforeSubcommand) => {
self.before_subcommand.extend(tokens);
}
ArgForm::Flag(_, FlagPlacement::BeforeOperands) => self.leading_flags.extend(tokens),
ArgForm::Flag(_, FlagPlacement::Default) => self.trailing_flags.extend(tokens),
ArgForm::Positional(index, OperandPlacement::BeforeFlags) => {
self.leading_operands.push((index, tokens));
}
ArgForm::Positional(index, OperandPlacement::AfterFlags) => {
self.trailing_operands.push((index, tokens));
}
}
}
}
#[allow(clippy::result_large_err)] fn render_property_tokens(
key: &str,
value: &Value,
form: &ArgForm,
attached: bool,
honours_separator: bool,
saw_dash: &mut bool,
) -> Result<Vec<String>, ModuleError> {
let (items, indexed): (Vec<&Value>, bool) = match value {
Value::Array(items) => (items.iter().collect(), true),
other => (vec![other], false),
};
let mut tokens: Vec<String> = Vec::new();
for (position, item) in items.into_iter().enumerate() {
let text = json_value_to_string(item);
let location = ValueLocation {
param_name: key,
index: indexed.then_some(position),
};
validate_argument_value(location, &text, item.is_number(), honours_separator)?;
if honours_separator && text.starts_with('-') {
*saw_dash = true;
}
match *form {
ArgForm::Flag(ref literal, _) if attached && literal.starts_with("--") => {
tokens.push(format!("{literal}={text}"));
}
ArgForm::Flag(ref literal, _) => {
tokens.push(literal.clone());
tokens.push(text);
}
ArgForm::Positional(..) => tokens.push(text),
}
}
Ok(tokens)
}
#[allow(clippy::result_large_err)] fn collect_argv_groups(
kwargs: &serde_json::Map<String, Value>,
input_schema: Option<&Value>,
honours_separator: bool,
) -> Result<ArgvGroups, ModuleError> {
let mut groups = ArgvGroups::default();
for (key, value) in kwargs {
let form = arg_form(input_schema, key);
if let Value::Bool(enabled) = value {
if let ArgForm::Flag(ref literal, _) = form {
if *enabled {
reject_bare_flag_for_value_option(input_schema, key, literal)?;
groups.extend(&form, vec![literal.clone()]);
}
}
continue;
}
if value.is_null() {
continue;
}
let tokens = render_property_tokens(
key,
value,
&form,
value_must_be_attached(input_schema, key),
honours_separator,
&mut groups.saw_dash_leading_value,
)?;
groups.extend(&form, tokens);
}
groups.leading_operands.sort_by_key(|(index, _)| *index);
groups.trailing_operands.sort_by_key(|(index, _)| *index);
Ok(groups)
}
fn needs_end_of_options(
groups: &ArgvGroups,
honours_separator: bool,
separator_precedes_operands: bool,
) -> bool {
let nothing_stops_option_parsing = separator_precedes_operands
&& groups.leading_operands.is_empty()
&& !(groups.trailing_flags.is_empty() && groups.trailing_operands.is_empty());
honours_separator && (groups.saw_dash_leading_value || nothing_stops_option_parsing)
}
#[allow(clippy::result_large_err)] pub fn build_arguments(
kwargs: &serde_json::Map<String, Value>,
input_schema: Option<&Value>,
) -> Result<Vec<String>, ModuleError> {
Ok(build_argv(kwargs, input_schema)?.into_flat())
}
#[allow(clippy::result_large_err)] pub fn build_argv(
kwargs: &serde_json::Map<String, Value>,
input_schema: Option<&Value>,
) -> Result<RenderedArgv, ModuleError> {
reject_conflicting_flags(kwargs, input_schema)?;
let honours_separator = honours_end_of_options(input_schema);
let groups = collect_argv_groups(kwargs, input_schema, honours_separator)?;
let separator_precedes_operands = operands_precede_flags(input_schema);
let needs_separator =
needs_end_of_options(&groups, honours_separator, separator_precedes_operands);
let mut args: Vec<String> = groups.leading_flags;
if needs_separator && separator_precedes_operands {
args.push(END_OF_OPTIONS.to_string());
}
args.extend(
groups
.leading_operands
.into_iter()
.flat_map(|(_, values)| values),
);
args.extend(groups.trailing_flags);
if needs_separator && !separator_precedes_operands {
args.push(END_OF_OPTIONS.to_string());
}
args.extend(
groups
.trailing_operands
.into_iter()
.flat_map(|(_, values)| values),
);
Ok(RenderedArgv {
before_subcommand: groups.before_subcommand,
after_subcommand: args,
})
}
#[derive(Debug)]
pub struct SubprocessOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
}
fn truncate_output(bytes: &[u8], max_len: usize) -> (String, bool) {
if bytes.len() <= max_len {
return (String::from_utf8_lossy(bytes).to_string(), false);
}
let mut cut = max_len;
while cut > 0 && (bytes[cut] & 0xC0) == 0x80 {
cut -= 1;
}
(String::from_utf8_lossy(&bytes[..cut]).to_string(), true)
}
const READ_CHUNK_SIZE: usize = 8 * 1024;
async fn read_up_to<R: tokio::io::AsyncRead + Unpin>(
reader: &mut R,
max_len: usize,
) -> std::io::Result<Vec<u8>> {
let mut buf = Vec::new();
let mut chunk = [0u8; READ_CHUNK_SIZE];
while buf.len() < max_len {
let to_read = READ_CHUNK_SIZE.min(max_len - buf.len());
let n = reader.read(&mut chunk[..to_read]).await?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
}
Ok(buf)
}
const ENV_ALLOWLIST: &[&str] = &[
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "TERM", "TZ", "TMPDIR",
];
fn is_allowed_env(key: &str) -> bool {
ENV_ALLOWLIST.contains(&key) || key.starts_with("LC_")
}
fn execute_error(binary_path: &str, doing: &str, cause: impl std::fmt::Display) -> ModuleError {
ModuleError::new(
ErrorCode::ModuleExecuteError,
format!("Failed to {doing} '{binary_path}': {cause}"),
)
}
#[allow(clippy::result_large_err)] fn spawn_isolated(
binary_path: &str,
args: &[String],
) -> Result<tokio::process::Child, ModuleError> {
let mut command = Command::new(binary_path);
command
.args(args)
.env_clear()
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
for (key, value) in std::env::vars() {
if is_allowed_env(&key) {
command.env(key, value);
}
}
command
.spawn()
.map_err(|e| execute_error(binary_path, "execute", e))
}
#[allow(clippy::result_large_err)] async fn collect_output(
child: &mut tokio::process::Child,
binary_path: &str,
max_output_bytes: usize,
) -> Result<SubprocessOutput, ModuleError> {
let read_cap = max_output_bytes.saturating_add(1);
let mut stdout_pipe = child.stdout.take().expect("stdout was piped");
let mut stderr_pipe = child.stderr.take().expect("stderr was piped");
let (stdout_bytes, stderr_bytes, status) = tokio::join!(
read_up_to(&mut stdout_pipe, read_cap),
read_up_to(&mut stderr_pipe, read_cap),
child.wait(),
);
let stdout_bytes = stdout_bytes.map_err(|e| execute_error(binary_path, "read stdout of", e))?;
let stderr_bytes = stderr_bytes.map_err(|e| execute_error(binary_path, "read stderr of", e))?;
let status = status.map_err(|e| execute_error(binary_path, "wait on", e))?;
let (stdout, stdout_truncated) = truncate_output(&stdout_bytes, max_output_bytes);
let (stderr, stderr_truncated) = truncate_output(&stderr_bytes, max_output_bytes);
Ok(SubprocessOutput {
stdout,
stderr,
exit_code: status.code().unwrap_or(-1),
stdout_truncated,
stderr_truncated,
})
}
#[allow(clippy::result_large_err)] pub async fn execute_subprocess(
binary_path: &str,
args: &[String],
json_flag: Option<&str>,
timeout_ms: u64,
max_output_bytes: usize,
) -> Result<SubprocessOutput, ModuleError> {
let mut full_args: Vec<String> = args.to_vec();
if let Some(flag) = json_flag {
full_args.extend(shell_words::split(flag).unwrap_or_default());
}
let run = async {
let mut child = spawn_isolated(binary_path, &full_args)?;
collect_output(&mut child, binary_path, max_output_bytes).await
};
let timeout_duration = std::time::Duration::from_millis(timeout_ms);
match tokio::time::timeout(timeout_duration, run).await {
Ok(result) => result,
Err(_elapsed) => Err(ModuleError::new(
ErrorCode::ModuleTimeout,
format!("Command '{}' timed out after {}ms", binary_path, timeout_ms),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_build_arguments_string_value() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("file".to_string(), json!("test.txt"));
let args = build_arguments(&kwargs, None).unwrap();
assert_eq!(args, vec!["--file", "test.txt"]);
}
#[test]
fn test_build_arguments_boolean_true() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("all".to_string(), json!(true));
let args = build_arguments(&kwargs, None).unwrap();
assert_eq!(args, vec!["--all"]);
}
#[test]
fn test_build_arguments_boolean_false() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("all".to_string(), json!(false));
let args = build_arguments(&kwargs, None).unwrap();
assert!(args.is_empty());
}
#[test]
fn test_build_arguments_places_marked_operands_before_flags() {
let schema = json!({
"type": "object",
"properties": {
"path": {
"type": "array",
"x-apexe-positional": 0,
"x-apexe-operand-position": "before-flags"
},
"expression": { "type": "array", "x-apexe-positional": 1 },
"name": { "type": "string", "x-apexe-flag": "-name" },
}
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("name".to_string(), json!("*.txt"));
kwargs.insert("path".to_string(), json!(["sandbox"]));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["sandbox", "-name", "*.txt"]
);
}
#[test]
fn test_build_arguments_places_marked_flags_before_operands() {
let schema = json!({
"type": "object",
"properties": {
"path": {
"type": "array",
"x-apexe-positional": 0,
"x-apexe-operand-position": "before-flags"
},
"L": {
"type": "boolean",
"x-apexe-flag": "-L",
"x-apexe-flag-position": "before-operands"
},
"name": { "type": "string", "x-apexe-flag": "-name" },
}
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("name".to_string(), json!("*.txt"));
kwargs.insert("path".to_string(), json!(["sandbox"]));
kwargs.insert("L".to_string(), json!(true));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["-L", "sandbox", "-name", "*.txt"],
"options precede the path, primaries follow it"
);
}
#[test]
fn test_build_arguments_places_a_marked_value_flag_before_operands() {
let schema = json!({
"type": "object",
"properties": {
"path": {
"type": "array",
"x-apexe-positional": 0,
"x-apexe-operand-position": "before-flags"
},
"f": {
"type": "string",
"x-apexe-flag": "-f",
"x-apexe-flag-position": "before-operands"
},
}
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("path".to_string(), json!(["sandbox"]));
kwargs.insert("f".to_string(), json!("extra"));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["-f", "extra", "sandbox"]
);
}
#[test]
fn test_build_arguments_keeps_unmarked_flags_after_leading_operands() {
let schema = json!({
"type": "object",
"properties": {
"path": {
"type": "array",
"x-apexe-positional": 0,
"x-apexe-operand-position": "before-flags"
},
"name": { "type": "string", "x-apexe-flag": "-name" },
}
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("name".to_string(), json!("*.txt"));
kwargs.insert("path".to_string(), json!(["dir"]));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["dir", "-name", "*.txt"]
);
}
#[test]
fn test_build_arguments_keeps_unmarked_operands_after_flags() {
let schema = json!({
"type": "object",
"properties": {
"file": { "type": "array", "x-apexe-positional": 0 },
"l": { "type": "boolean", "x-apexe-flag": "-l" },
}
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("file".to_string(), json!(["dir"]));
kwargs.insert("l".to_string(), json!(true));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["-l", "dir"]
);
}
#[test]
fn test_build_arguments_orders_operands_on_both_sides_of_the_flags() {
let schema = json!({
"type": "object",
"properties": {
"root": {
"type": "string",
"x-apexe-positional": 1,
"x-apexe-operand-position": "before-flags"
},
"start": {
"type": "string",
"x-apexe-positional": 0,
"x-apexe-operand-position": "before-flags"
},
"tail": { "type": "string", "x-apexe-positional": 0 },
"v": { "type": "boolean", "x-apexe-flag": "-v" },
}
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("tail".to_string(), json!("last"));
kwargs.insert("root".to_string(), json!("second"));
kwargs.insert("v".to_string(), json!(true));
kwargs.insert("start".to_string(), json!("first"));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["first", "second", "-v", "last"]
);
}
#[test]
fn test_build_arguments_rejects_true_for_a_value_taking_option() {
let schema = json!({
"type": "object",
"properties": {
"proxy": { "type": "string", "x-apexe-flag": "--proxy" },
"connect_timeout": { "type": "number", "x-apexe-flag": "--connect-timeout" },
}
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("proxy".to_string(), json!(true));
kwargs.insert("connect_timeout".to_string(), json!(true));
let err = build_arguments(&kwargs, Some(&schema))
.expect_err("a value-taking option must not render bare");
assert_eq!(err.code, ErrorCode::GeneralInvalidInput);
assert!(
err.message.contains("takes a value"),
"unhelpful message: {}",
err.message
);
}
#[test]
fn test_build_arguments_still_renders_declared_boolean_flags() {
let schema = json!({
"type": "object",
"properties": { "verbose": { "type": "boolean", "x-apexe-flag": "-v" } }
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("verbose".to_string(), json!(true));
assert_eq!(build_arguments(&kwargs, Some(&schema)).unwrap(), vec!["-v"]);
}
#[test]
fn test_build_arguments_null_skipped() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("x".to_string(), json!(null));
let args = build_arguments(&kwargs, None).unwrap();
assert!(args.is_empty());
}
#[test]
fn test_build_arguments_array_values() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("include".to_string(), json!(["a", "b"]));
let args = build_arguments(&kwargs, None).unwrap();
assert_eq!(args, vec!["--include", "a", "--include", "b"]);
}
#[test]
fn test_build_arguments_underscore_to_hyphen() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("no_cache".to_string(), json!(true));
let args = build_arguments(&kwargs, None).unwrap();
assert_eq!(args, vec!["--no-cache"]);
}
#[test]
fn test_build_arguments_integer_value() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("count".to_string(), json!(5));
let args = build_arguments(&kwargs, None).unwrap();
assert_eq!(args, vec!["--count", "5"]);
}
#[test]
fn test_build_arguments_option_like_value_blocked() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("data".to_string(), json!("--output=/etc/passwd"));
let result = build_arguments(&kwargs, None);
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::GeneralInvalidInput);
}
#[test]
fn test_build_arguments_option_like_value_blocked_in_array() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("tags".to_string(), json!(["ok", "-K/etc/shadow"]));
let result = build_arguments(&kwargs, None);
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::GeneralInvalidInput);
}
#[test]
fn test_build_arguments_allows_shell_metacharacters() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("data".to_string(), json!(r#"{"name":"apexe"}"#));
let args = build_arguments(&kwargs, None).expect("a JSON body must be passable");
assert_eq!(args, vec!["--data", r#"{"name":"apexe"}"#]);
let mut kwargs = serde_json::Map::new();
kwargs.insert("data".to_string(), json!("name=apexe&lang=rust"));
let args = build_arguments(&kwargs, None).expect("a form body must be passable");
assert_eq!(args, vec!["--data", "name=apexe&lang=rust"]);
let mut kwargs = serde_json::Map::new();
kwargs.insert("filter".to_string(), json!(r#".items[] | select(.n > $x)"#));
let args = build_arguments(&kwargs, None).expect("a jq filter must be passable");
assert_eq!(args, vec!["--filter", r#".items[] | select(.n > $x)"#]);
}
#[test]
fn test_build_arguments_rejects_control_characters() {
for bad in ["a\nb", "a\rb", "a\0b"] {
let mut kwargs = serde_json::Map::new();
kwargs.insert("msg".to_string(), json!(bad));
let result = build_arguments(&kwargs, None);
assert!(
result.is_err(),
"control character in {bad:?} must be rejected"
);
assert_eq!(result.unwrap_err().code, ErrorCode::GeneralInvalidInput);
}
}
#[test]
fn test_build_arguments_rejects_unicode_line_terminators() {
for bad in [
"/tmp\u{2028}INJECTED",
"/tmp\u{2029}INJECTED",
"/tmp\u{0085}INJECTED",
] {
let mut kwargs = serde_json::Map::new();
kwargs.insert("msg".to_string(), json!(bad));
let err = build_arguments(&kwargs, None)
.expect_err("a Unicode line terminator must be rejected");
assert_eq!(err.code, ErrorCode::GeneralInvalidInput);
assert!(
err.message.contains("prohibited control characters"),
"the message must name the rule: {}",
err.message
);
}
}
#[test]
fn test_build_arguments_allows_the_escaped_control_characters() {
for allowed in ["a\tb", "a\u{000b}b", "a\u{000c}b", "a\u{001b}[31mb"] {
let mut kwargs = serde_json::Map::new();
kwargs.insert("data".to_string(), json!(allowed));
assert!(
build_arguments(&kwargs, None).is_ok(),
"{allowed:?} must still be passable"
);
}
}
#[test]
fn test_build_argv_reports_global_flags_before_the_subcommand() {
let schema = json!({
"type": "object",
"properties": {
"C": {
"type": "string",
"x-apexe-flag": "-C",
"x-apexe-flag-position": "before-subcommand"
},
"paginate": {
"type": "boolean",
"x-apexe-flag": "--paginate",
"x-apexe-flag-position": "before-subcommand"
},
"oneline": { "type": "boolean", "x-apexe-flag": "--oneline" },
}
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("C".to_string(), json!("/repo"));
kwargs.insert("paginate".to_string(), json!(true));
kwargs.insert("oneline".to_string(), json!(true));
let argv = build_argv(&kwargs, Some(&schema)).unwrap();
assert_eq!(argv.before_subcommand, vec!["-C", "/repo", "--paginate"]);
assert_eq!(argv.after_subcommand, vec!["--oneline"]);
}
#[test]
fn test_build_argv_keeps_unmarked_flags_after_the_subcommand() {
let schema = json!({
"type": "object",
"properties": { "oneline": { "type": "boolean", "x-apexe-flag": "--oneline" } }
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("oneline".to_string(), json!(true));
let argv = build_argv(&kwargs, Some(&schema)).unwrap();
assert!(argv.before_subcommand.is_empty());
assert_eq!(argv.after_subcommand, vec!["--oneline"]);
}
#[test]
fn test_build_argv_keeps_the_three_flag_groups_apart() {
let schema = json!({
"type": "object",
"properties": {
"C": {
"type": "string",
"x-apexe-flag": "-C",
"x-apexe-flag-position": "before-subcommand"
},
"f": {
"type": "string",
"x-apexe-flag": "-f",
"x-apexe-flag-position": "before-operands"
},
"path": { "type": "array", "x-apexe-positional": 0,
"x-apexe-operand-position": "before-flags" },
"name": { "type": "string", "x-apexe-flag": "-name" },
}
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("C".to_string(), json!("/repo"));
kwargs.insert("f".to_string(), json!("dir"));
kwargs.insert("path".to_string(), json!(["root"]));
kwargs.insert("name".to_string(), json!("*.txt"));
let argv = build_argv(&kwargs, Some(&schema)).unwrap();
assert_eq!(argv.before_subcommand, vec!["-C", "/repo"]);
assert_eq!(
argv.after_subcommand,
vec!["-f", "dir", "root", "-name", "*.txt"]
);
}
#[test]
fn test_build_arguments_flattens_the_leading_group_first() {
let schema = json!({
"type": "object",
"properties": {
"C": {
"type": "string",
"x-apexe-flag": "-C",
"x-apexe-flag-position": "before-subcommand"
},
"oneline": { "type": "boolean", "x-apexe-flag": "--oneline" },
}
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("oneline".to_string(), json!(true));
kwargs.insert("C".to_string(), json!("/repo"));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["-C", "/repo", "--oneline"]
);
}
#[test]
fn test_build_arguments_negative_number_allowed() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("offset".to_string(), json!(-5));
let args = build_arguments(&kwargs, None).unwrap();
assert_eq!(args, vec!["--offset", "-5"]);
}
#[test]
fn test_build_arguments_negative_number_as_string_blocked() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("offset".to_string(), json!("-5"));
assert!(build_arguments(&kwargs, None).is_err());
}
fn schema_with(properties: Value) -> Value {
json!({ "type": "object", "properties": properties })
}
#[test]
fn test_build_arguments_short_flag_uses_single_dash() {
let schema = schema_with(json!({
"l": { "type": "boolean", "x-apexe-flag": "-l" },
"a": { "type": "boolean", "x-apexe-flag": "-a" },
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("l".to_string(), json!(true));
kwargs.insert("a".to_string(), json!(true));
let args = build_arguments(&kwargs, Some(&schema)).unwrap();
assert_eq!(args, vec!["-l", "-a"]);
}
#[test]
fn test_build_arguments_rejects_conflicting_flags() {
let schema = schema_with(json!({
"l": { "type": "boolean", "x-apexe-flag": "-l", "x-apexe-conflicts-with": ["1"] },
"1": { "type": "boolean", "x-apexe-flag": "-1", "x-apexe-conflicts-with": ["l"] },
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("l".to_string(), json!(true));
kwargs.insert("1".to_string(), json!(true));
let err = build_arguments(&kwargs, Some(&schema)).unwrap_err();
assert_eq!(err.code, ErrorCode::GeneralInvalidInput);
assert!(
err.message.contains('l') && err.message.contains('1'),
"the error should name both flags: {}",
err.message
);
}
#[test]
fn test_build_arguments_conflict_is_order_independent() {
let schema = schema_with(json!({
"l": { "type": "boolean", "x-apexe-flag": "-l", "x-apexe-conflicts-with": ["1"] },
"1": { "type": "boolean", "x-apexe-flag": "-1", "x-apexe-conflicts-with": ["l"] },
}));
for keys in [["l", "1"], ["1", "l"]] {
let mut kwargs = serde_json::Map::new();
for key in keys {
kwargs.insert(key.to_string(), json!(true));
}
assert!(
build_arguments(&kwargs, Some(&schema)).is_err(),
"order {keys:?} must be rejected too"
);
}
}
#[test]
fn test_build_arguments_disabled_flag_is_not_a_conflict() {
let schema = schema_with(json!({
"l": { "type": "boolean", "x-apexe-flag": "-l", "x-apexe-conflicts-with": ["1"] },
"1": { "type": "boolean", "x-apexe-flag": "-1", "x-apexe-conflicts-with": ["l"] },
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("l".to_string(), json!(true));
kwargs.insert("1".to_string(), json!(false));
assert_eq!(build_arguments(&kwargs, Some(&schema)).unwrap(), vec!["-l"]);
}
#[test]
fn test_build_arguments_without_conflict_annotations_is_unaffected() {
let schema = schema_with(json!({
"l": { "type": "boolean", "x-apexe-flag": "-l" },
"1": { "type": "boolean", "x-apexe-flag": "-1" },
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("l".to_string(), json!(true));
kwargs.insert("1".to_string(), json!(true));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["-l", "-1"]
);
}
#[test]
fn test_build_arguments_optional_value_long_flag_uses_equals() {
let schema = schema_with(json!({
"classify": {
"type": ["string", "boolean"],
"x-apexe-flag": "--classify",
"x-apexe-value-optional": true,
},
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("classify".to_string(), json!("never"));
let args = build_arguments(&kwargs, Some(&schema)).unwrap();
assert_eq!(args, vec!["--classify=never"]);
}
#[test]
fn test_build_arguments_required_value_long_flag_stays_separate() {
let schema = schema_with(json!({
"max_time": { "type": "number", "x-apexe-flag": "--max-time" },
"user_agent": { "type": "string", "x-apexe-flag": "--user-agent" },
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("max_time".to_string(), json!(1));
kwargs.insert("user_agent".to_string(), json!("apexe"));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["--max-time", "1", "--user-agent", "apexe"]
);
}
#[test]
fn test_build_arguments_optional_value_short_flag_stays_separate() {
let schema = schema_with(json!({
"I": {
"type": ["string", "boolean"],
"x-apexe-flag": "-I",
"x-apexe-value-optional": true,
},
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("I".to_string(), json!("*.tmp"));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["-I", "*.tmp"]
);
}
#[test]
fn test_build_arguments_optional_value_flag_still_renders_bare_for_true() {
let schema = schema_with(json!({
"exec_path": {
"type": ["string", "boolean"],
"x-apexe-flag": "--exec-path",
"x-apexe-value-optional": true,
},
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("exec_path".to_string(), json!(true));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["--exec-path"]
);
}
fn find_schema() -> Value {
json!({
"type": "object",
"x-apexe-end-of-options": true,
"properties": {
"path": {
"type": "array",
"x-apexe-positional": 0,
"x-apexe-operand-position": "before-flags"
},
"expression": { "type": "array", "x-apexe-positional": 1 },
"f": {
"type": "array",
"x-apexe-flag": "-f",
"x-apexe-flag-position": "before-operands"
},
"L": {
"type": "boolean",
"x-apexe-flag": "-L",
"x-apexe-flag-position": "before-operands"
},
"name": { "type": "string", "x-apexe-flag": "-name" },
}
})
}
#[test]
fn test_build_arguments_emits_end_of_options_before_the_operands() {
let schema = find_schema();
let mut kwargs = serde_json::Map::new();
kwargs.insert("path".to_string(), json!(["sandbox"]));
kwargs.insert("expression".to_string(), json!(["-name", "*.txt"]));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["--", "sandbox", "-name", "*.txt"]
);
}
#[test]
fn test_build_arguments_end_of_options_follows_the_pre_operand_flags() {
let schema = find_schema();
let mut kwargs = serde_json::Map::new();
kwargs.insert("f".to_string(), json!(["-weird-dir"]));
kwargs.insert("name".to_string(), json!("*.txt"));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["-f", "-weird-dir", "--", "-name", "*.txt"]
);
}
#[test]
fn test_build_arguments_end_of_options_follows_every_flag_by_default() {
let schema = json!({
"type": "object",
"x-apexe-end-of-options": true,
"properties": {
"file": { "type": "array", "x-apexe-positional": 0 },
"i": { "type": "boolean", "x-apexe-flag": "-i" },
}
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("file".to_string(), json!(["-weird-name"]));
kwargs.insert("i".to_string(), json!(true));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["-i", "--", "-weird-name"]
);
}
#[test]
fn test_build_arguments_emits_end_of_options_when_no_operand_stops_parsing() {
let schema = json!({
"type": "object",
"x-apexe-end-of-options": true,
"properties": {
"f": {"type": "array", "x-apexe-flag": "-f", "x-apexe-flag-position": "before-operands"},
"path": {"type": "array", "x-apexe-positional": 0, "x-apexe-operand-position": "before-flags"},
"name": {"type": "string", "x-apexe-flag": "-name"},
}
});
let kwargs = json!({"f": ["sandbox"], "name": "*.txt"})
.as_object()
.expect("object literal")
.clone();
let args = build_arguments(&kwargs, Some(&schema)).unwrap();
assert_eq!(args, vec!["-f", "sandbox", "--", "-name", "*.txt"]);
}
#[test]
fn test_build_arguments_omits_end_of_options_when_an_operand_stops_parsing() {
let schema = json!({
"type": "object",
"x-apexe-end-of-options": true,
"properties": {
"path": {"type": "array", "x-apexe-positional": 0, "x-apexe-operand-position": "before-flags"},
"name": {"type": "string", "x-apexe-flag": "-name"},
}
});
let kwargs = json!({"path": ["sandbox"], "name": "*.txt"})
.as_object()
.expect("object literal")
.clone();
let args = build_arguments(&kwargs, Some(&schema)).unwrap();
assert_eq!(args, vec!["sandbox", "-name", "*.txt"]);
}
#[test]
fn test_build_arguments_omits_end_of_options_when_nothing_needs_it() {
let schema = find_schema();
let mut kwargs = serde_json::Map::new();
kwargs.insert("path".to_string(), json!(["sandbox"]));
kwargs.insert("name".to_string(), json!("*.txt"));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["sandbox", "-name", "*.txt"]
);
}
#[test]
fn test_build_arguments_still_rejects_option_like_values_without_the_marker() {
let schema = json!({
"type": "object",
"properties": {
"path": {
"type": "array",
"x-apexe-positional": 0,
"x-apexe-operand-position": "before-flags"
},
"expression": { "type": "array", "x-apexe-positional": 1 },
}
});
let mut kwargs = serde_json::Map::new();
kwargs.insert("path".to_string(), json!(["sandbox"]));
kwargs.insert("expression".to_string(), json!(["-name", "*.txt"]));
let err = build_arguments(&kwargs, Some(&schema))
.expect_err("without the marker the guard must still refuse");
assert_eq!(err.code, ErrorCode::GeneralInvalidInput);
}
#[test]
fn test_validate_argument_value_names_the_offending_array_element() {
let mut kwargs = serde_json::Map::new();
kwargs.insert(
"expression".to_string(),
json!(["(", "-name", "*.txt", ")"]),
);
let err = build_arguments(&kwargs, None).expect_err("the element must be refused");
assert!(
err.message.contains("Element 1 of parameter 'expression'"),
"the message must name the element and its index: {}",
err.message
);
assert!(
err.message.contains("'-name'"),
"the message must quote the offending value: {}",
err.message
);
}
#[test]
fn test_validate_argument_value_names_a_scalar_without_an_index() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("data".to_string(), json!("--output=/etc/passwd"));
let err = build_arguments(&kwargs, None).expect_err("the value must be refused");
assert!(
err.message.starts_with("Parameter 'data' is"),
"a scalar names no index: {}",
err.message
);
}
#[test]
fn test_build_arguments_short_flag_value_stays_separate() {
let schema = schema_with(json!({
"I": { "type": "string", "x-apexe-flag": "-I" },
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("I".to_string(), json!("*.tmp"));
let args = build_arguments(&kwargs, Some(&schema)).unwrap();
assert_eq!(args, vec!["-I", "*.tmp"]);
}
#[test]
fn test_build_arguments_multi_character_single_dash_flag() {
let schema = schema_with(json!({
"daystart": { "type": "boolean", "x-apexe-flag": "-daystart" },
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("daystart".to_string(), json!(true));
let args = build_arguments(&kwargs, Some(&schema)).unwrap();
assert_eq!(args, vec!["-daystart"]);
}
#[test]
fn test_build_arguments_positional_is_passed_bare() {
let schema = schema_with(json!({
"file": {
"type": "array",
"items": { "type": "string" },
"x-apexe-positional": 0,
},
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("file".to_string(), json!(["/tmp", "/var"]));
let args = build_arguments(&kwargs, Some(&schema)).unwrap();
assert_eq!(args, vec!["/tmp", "/var"]);
}
#[test]
fn test_build_arguments_positional_order_ignores_key_order() {
let schema = schema_with(json!({
"source": { "type": "string", "x-apexe-positional": 0 },
"target": { "type": "string", "x-apexe-positional": 1 },
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("target".to_string(), json!("/dst"));
kwargs.insert("source".to_string(), json!("/src"));
let args = build_arguments(&kwargs, Some(&schema)).unwrap();
assert_eq!(args, vec!["/src", "/dst"]);
}
#[test]
fn test_build_arguments_flags_precede_positionals() {
let schema = schema_with(json!({
"l": { "type": "boolean", "x-apexe-flag": "-l" },
"file": { "type": "string", "x-apexe-positional": 0 },
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("file".to_string(), json!("/tmp"));
kwargs.insert("l".to_string(), json!(true));
let args = build_arguments(&kwargs, Some(&schema)).unwrap();
assert_eq!(args, vec!["-l", "/tmp"]);
}
#[test]
fn test_build_arguments_boolean_positional_is_skipped() {
let schema = schema_with(json!({
"file": { "type": "string", "x-apexe-positional": 0 },
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("file".to_string(), json!(true));
assert!(build_arguments(&kwargs, Some(&schema)).unwrap().is_empty());
}
#[test]
fn test_build_arguments_falls_back_without_schema_annotations() {
let schema = schema_with(json!({ "no_cache": { "type": "boolean" } }));
let mut kwargs = serde_json::Map::new();
kwargs.insert("no_cache".to_string(), json!(true));
assert_eq!(
build_arguments(&kwargs, Some(&schema)).unwrap(),
vec!["--no-cache"]
);
assert_eq!(
build_arguments(&kwargs, None).unwrap(),
vec!["--no-cache"],
"no schema at all must behave the same as an unannotated one"
);
}
#[test]
fn test_build_arguments_long_flag_literal_survives_underscore() {
let schema = schema_with(json!({
"foo_bar": { "type": "string", "x-apexe-flag": "--foo_bar" },
}));
let mut kwargs = serde_json::Map::new();
kwargs.insert("foo_bar".to_string(), json!("v"));
let args = build_arguments(&kwargs, Some(&schema)).unwrap();
assert_eq!(args, vec!["--foo_bar", "v"]);
}
#[test]
fn test_validate_no_injection_clean() {
let result = validate_no_injection("file", "hello world");
assert!(result.is_ok());
}
#[test]
fn test_validate_no_injection_semicolon() {
let result = validate_no_injection("arg", "a;b");
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::GeneralInvalidInput);
}
#[test]
fn test_validate_argument_value_trust_classes_differ() {
let location = ValueLocation {
param_name: "data",
index: None,
};
assert!(validate_argument_value(location, "a;b|c", false, false).is_ok());
assert!(validate_no_injection("json_flag", "a;b|c").is_err());
}
#[test]
fn test_is_allowed_env() {
assert!(is_allowed_env("PATH"));
assert!(is_allowed_env("HOME"));
assert!(is_allowed_env("LC_CTYPE")); assert!(!is_allowed_env("AWS_SECRET_ACCESS_KEY"));
assert!(!is_allowed_env("GH_TOKEN"));
assert!(!is_allowed_env("OPENAI_API_KEY"));
assert!(!is_allowed_env("CARGO_PKG_NAME"));
}
#[tokio::test]
async fn test_execute_subprocess_kills_the_child_when_the_timeout_elapses() {
let tmp = tempfile::TempDir::new().unwrap();
let marker = tmp.path().join("orphan-ran");
let script = format!("sleep 1; : > {}", marker.display());
let error = execute_subprocess("sh", &["-c".to_string(), script], None, 150, 4096)
.await
.expect_err("the command must time out");
assert_eq!(error.code, ErrorCode::ModuleTimeout);
tokio::time::sleep(std::time::Duration::from_millis(1_500)).await;
assert!(
!marker.exists(),
"the timed-out child kept running and completed its side effect"
);
}
#[tokio::test]
async fn test_execute_subprocess_scrubs_environment() {
let out = execute_subprocess(
"sh",
&["-c".to_string(), "env".to_string()],
None,
5_000,
DEFAULT_MAX_OUTPUT_BYTES,
)
.await
.unwrap();
assert!(
out.stdout.lines().any(|l| l.starts_with("PATH=")),
"PATH should pass through: {}",
out.stdout
);
let count = out.stdout.lines().count();
assert!(
count <= 20,
"environment was not scrubbed, child sees {count} vars: {}",
out.stdout
);
assert!(
!out.stdout.contains("CARGO_"),
"cargo/apexe env leaked to the wrapped subprocess: {}",
out.stdout
);
}
#[tokio::test]
async fn test_execute_subprocess_echo() {
let result = execute_subprocess(
"echo",
&["hello".to_string()],
None,
5000,
DEFAULT_MAX_OUTPUT_BYTES,
)
.await
.unwrap();
assert_eq!(result.stdout, "hello\n");
assert!(result.stderr.is_empty());
assert_eq!(result.exit_code, 0);
assert!(!result.stdout_truncated);
assert!(!result.stderr_truncated);
}
#[tokio::test]
async fn test_execute_subprocess_false() {
let result = execute_subprocess("false", &[], None, 5000, DEFAULT_MAX_OUTPUT_BYTES)
.await
.unwrap();
assert_ne!(result.exit_code, 0);
}
#[tokio::test]
async fn test_execute_subprocess_nonexistent() {
let result = execute_subprocess(
"/nonexistent_binary_that_does_not_exist",
&[],
None,
5000,
DEFAULT_MAX_OUTPUT_BYTES,
)
.await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::ModuleExecuteError);
}
#[tokio::test]
async fn test_execute_subprocess_timeout_leaves_retryable_unset() {
let result = execute_subprocess("sleep", &["1".to_string()], None, 10, 1024).await;
let err = result.unwrap_err();
assert_eq!(err.code, ErrorCode::ModuleTimeout);
assert_eq!(err.retryable, None);
}
#[tokio::test]
async fn test_execute_subprocess_truncates_large_output() {
let result = execute_subprocess(
"seq",
&["1".to_string(), "500".to_string()],
None,
5000,
64,
)
.await
.unwrap();
assert!(result.stdout.len() <= 64);
assert!(result.stdout_truncated);
}
#[tokio::test]
async fn test_execute_subprocess_kills_hung_process_on_timeout() {
let start = std::time::Instant::now();
let result = execute_subprocess("sleep", &["10".to_string()], None, 20, 1024).await;
assert!(result.is_err());
assert!(
start.elapsed() < std::time::Duration::from_secs(2),
"execute_subprocess should return promptly once the timeout elapses"
);
}
#[tokio::test]
async fn test_read_up_to_does_not_eagerly_allocate_full_cap() {
let data = b"tiny output";
let mut cursor = std::io::Cursor::new(data.to_vec());
let huge_cap = 64 * 1024 * 1024;
let result = read_up_to(&mut cursor, huge_cap).await.unwrap();
assert_eq!(result, data);
assert!(
result.capacity() < 1024 * 1024,
"capacity {} should stay proportional to the {}-byte output, not the {}-byte cap",
result.capacity(),
data.len(),
huge_cap
);
}
#[test]
fn test_truncate_output_under_limit() {
let (s, truncated) = truncate_output(b"hello", 100);
assert_eq!(s, "hello");
assert!(!truncated);
}
#[test]
fn test_truncate_output_over_limit() {
let (s, truncated) = truncate_output(b"hello world", 5);
assert_eq!(s, "hello");
assert!(truncated);
}
#[test]
fn test_truncate_output_respects_utf8_boundary() {
let bytes = "héllo".as_bytes();
let (s, truncated) = truncate_output(bytes, 2);
assert!(truncated);
assert!(s.is_char_boundary(s.len()));
assert_eq!(s, "h");
}
}