#![cfg(feature = "bash")]
use std::time::Duration;
use earl::protocol::builder::{PreparedBashScript, PreparedProtocolData, PreparedRequest};
use earl::protocol::executor::execute_prepared_request_with_host_validator;
use earl::protocol::transport::ResolvedTransport;
use earl::template::schema::{CommandMode, ResultDecode, ResultTemplate};
use earl_core::Redactor;
use earl_protocol_bash::ResolvedBashSandbox;
use serde_json::Map;
use std::net::IpAddr;
fn loopback_resolver() -> Vec<IpAddr> {
vec![]
}
fn default_transport() -> ResolvedTransport {
ResolvedTransport {
timeout: Duration::from_secs(10),
follow_redirects: false,
max_redirect_hops: 0,
retry_max_attempts: 1,
retry_backoff: Duration::from_millis(1),
retry_on_status: vec![],
compression: true,
tls_min_version: None,
proxy_url: None,
max_response_bytes: 8 * 1024 * 1024,
}
}
fn default_sandbox() -> ResolvedBashSandbox {
ResolvedBashSandbox {
network: false,
writable_paths: vec![],
max_time_ms: None,
max_output_bytes: None,
max_memory_bytes: None,
max_cpu_time_ms: None,
}
}
fn prepared_bash_request(script: &str, result_template: ResultTemplate) -> PreparedRequest {
PreparedRequest {
key: "test.bash".to_string(),
mode: CommandMode::Read,
stream: false,
allow_rules: vec![],
allow_private_ips: false,
transport: default_transport(),
result_template,
args: Map::new(),
redactor: Redactor::default(),
protocol_data: PreparedProtocolData::Bash(PreparedBashScript {
script: script.to_string(),
env: vec![],
cwd: None,
stdin: None,
sandbox: default_sandbox(),
}),
}
}
fn prepared_bash_request_with_sandbox(
script: &str,
result_template: ResultTemplate,
sandbox: ResolvedBashSandbox,
) -> PreparedRequest {
PreparedRequest {
key: "test.bash".to_string(),
mode: CommandMode::Read,
stream: false,
allow_rules: vec![],
allow_private_ips: false,
transport: default_transport(),
result_template,
args: Map::new(),
redactor: Redactor::default(),
protocol_data: PreparedProtocolData::Bash(PreparedBashScript {
script: script.to_string(),
env: vec![],
cwd: None,
stdin: None,
sandbox,
}),
}
}
fn prepared_bash_request_with_env(
script: &str,
result_template: ResultTemplate,
env: Vec<(String, String)>,
) -> PreparedRequest {
PreparedRequest {
key: "test.bash".to_string(),
mode: CommandMode::Read,
stream: false,
allow_rules: vec![],
allow_private_ips: false,
transport: default_transport(),
result_template,
args: Map::new(),
redactor: Redactor::default(),
protocol_data: PreparedProtocolData::Bash(PreparedBashScript {
script: script.to_string(),
env,
cwd: None,
stdin: None,
sandbox: default_sandbox(),
}),
}
}
#[tokio::test]
async fn bash_echo_returns_output() {
let result_template = ResultTemplate {
decode: ResultDecode::Text,
extract: None,
output: "{{ result }}".to_string(),
result_alias: None,
};
let prepared = prepared_bash_request("echo hello world", result_template);
let out = execute_prepared_request_with_host_validator(&prepared, |_url| async {
Ok(loopback_resolver())
})
.await
.unwrap();
assert_eq!(out.result.as_str().unwrap().trim(), "hello world");
}
#[tokio::test]
async fn bash_nonzero_exit_code_is_captured() {
let result_template = ResultTemplate {
decode: ResultDecode::Text,
extract: None,
output: "{{ result }}".to_string(),
result_alias: None,
};
let prepared = prepared_bash_request("exit 42", result_template);
let out = execute_prepared_request_with_host_validator(&prepared, |_url| async {
Ok(loopback_resolver())
})
.await
.unwrap();
assert_eq!(out.status, 42);
}
#[tokio::test]
async fn bash_captures_stderr() {
let result_template = ResultTemplate {
decode: ResultDecode::Text,
extract: None,
output: "{{ result }}".to_string(),
result_alias: None,
};
let prepared = prepared_bash_request("echo error_msg >&2", result_template);
let out = execute_prepared_request_with_host_validator(&prepared, |_url| async {
Ok(loopback_resolver())
})
.await
.unwrap();
assert_eq!(out.result.as_str().unwrap().trim(), "error_msg");
}
#[tokio::test]
async fn bash_env_var_is_accessible_in_script() {
let result_template = ResultTemplate {
decode: ResultDecode::Text,
extract: None,
output: "{{ result }}".to_string(),
result_alias: None,
};
let prepared = prepared_bash_request_with_env(
"echo $MY_VAR",
result_template,
vec![("MY_VAR".to_string(), "test_value_123".to_string())],
);
let out = execute_prepared_request_with_host_validator(&prepared, |_url| async {
Ok(loopback_resolver())
})
.await
.unwrap();
assert_eq!(out.result.as_str().unwrap().trim(), "test_value_123");
}
#[tokio::test]
async fn bash_env_var_metacharacters_are_not_interpreted() {
let result_template = ResultTemplate {
decode: ResultDecode::Text,
extract: None,
output: "{{ result }}".to_string(),
result_alias: None,
};
let prepared = prepared_bash_request_with_env(
r#"echo "$EARL_PATH""#,
result_template,
vec![("EARL_PATH".to_string(), ". ; echo injected".to_string())],
);
let out = execute_prepared_request_with_host_validator(&prepared, |_url| async {
Ok(loopback_resolver())
})
.await
.unwrap();
let output = out.result.as_str().unwrap().trim();
assert_eq!(output, ". ; echo injected");
}
#[tokio::test]
async fn bash_sandbox_timeout_overrides_transport() {
let result_template = ResultTemplate {
decode: ResultDecode::Text,
extract: None,
output: "{{ result }}".to_string(),
result_alias: None,
};
let prepared = prepared_bash_request_with_sandbox(
"sleep 30",
result_template,
ResolvedBashSandbox {
max_time_ms: Some(500),
..default_sandbox()
},
);
let start = std::time::Instant::now();
let result = execute_prepared_request_with_host_validator(&prepared, |_url| async {
Ok(loopback_resolver())
})
.await;
let elapsed = start.elapsed();
assert!(result.is_err(), "expected timeout error");
assert!(
elapsed < Duration::from_secs(5),
"sandbox timeout should have triggered well before the transport timeout"
);
}
#[tokio::test]
async fn bash_sandbox_output_limit_enforced() {
let result_template = ResultTemplate {
decode: ResultDecode::Text,
extract: None,
output: "{{ result }}".to_string(),
result_alias: None,
};
let prepared = prepared_bash_request_with_sandbox(
"for i in $(seq 1 200); do echo 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; done",
result_template,
ResolvedBashSandbox {
max_output_bytes: Some(1024),
..default_sandbox()
},
);
let result = execute_prepared_request_with_host_validator(&prepared, |_url| async {
Ok(loopback_resolver())
})
.await;
assert!(result.is_err(), "expected output limit error");
}
#[tokio::test]
async fn bash_json_output_is_decoded_and_extracted() {
let result_template = ResultTemplate {
decode: ResultDecode::Json,
extract: Some(earl::template::schema::ResultExtract::JsonPointer {
json_pointer: "/greeting".to_string(),
}),
output: "{{ result }}".to_string(),
result_alias: None,
};
let prepared = prepared_bash_request(r#"echo '{"greeting": "hi"}'"#, result_template);
let out = execute_prepared_request_with_host_validator(&prepared, |_url| async {
Ok(loopback_resolver())
})
.await
.unwrap();
assert_eq!(out.result, serde_json::json!("hi"));
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn bash_sandbox_memory_limit_enforced() {
let result_template = ResultTemplate {
decode: ResultDecode::Text,
extract: None,
output: "{{ result }}".to_string(),
result_alias: None,
};
let prepared = prepared_bash_request_with_sandbox(
"python3 -c \"x = bytearray(300 * 1024 * 1024)\"",
result_template,
ResolvedBashSandbox {
max_memory_bytes: Some(100 * 1024 * 1024), ..default_sandbox()
},
);
let out = execute_prepared_request_with_host_validator(&prepared, |_url| async {
Ok(loopback_resolver())
})
.await
.unwrap();
assert_ne!(out.status, 0, "expected non-zero exit due to memory limit");
}
#[tokio::test]
async fn bash_sandbox_cpu_limit_enforced() {
let result_template = ResultTemplate {
decode: ResultDecode::Text,
extract: None,
output: "{{ result }}".to_string(),
result_alias: None,
};
let prepared = prepared_bash_request_with_sandbox(
"python3 -c \"while True: pass\"",
result_template,
ResolvedBashSandbox {
max_cpu_time_ms: Some(1_000), max_time_ms: Some(5_000), ..default_sandbox()
},
);
let start = std::time::Instant::now();
let out = execute_prepared_request_with_host_validator(&prepared, |_url| async {
Ok(loopback_resolver())
})
.await
.unwrap();
let elapsed = start.elapsed();
assert_ne!(out.status, 0, "expected non-zero exit due to CPU limit");
assert!(
elapsed < std::time::Duration::from_secs(4),
"CPU limit should have fired before wall-clock guard"
);
}