use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(deny_unknown_fields, tag = "allow", rename_all = "lowercase")]
pub enum ArgPolicy {
Any,
Exact { values: Vec<String> },
Prefix { values: Vec<String> },
}
impl Default for ArgPolicy {
fn default() -> Self {
ArgPolicy::Exact { values: Vec::new() }
}
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DenyFlags(pub Vec<String>);
#[derive(Clone, Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnvPolicy {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub set: HashMap<String, String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
#[serde(deny_unknown_fields, rename_all = "lowercase")]
pub enum OutputMode {
#[default]
Materialized,
Stream,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
#[serde(deny_unknown_fields, rename_all = "lowercase")]
pub enum Sandbox {
#[default]
None,
WorkspaceRead,
WorkspaceWrite,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecProfile {
pub name: String,
pub executable: String, #[serde(default)]
pub args: ArgPolicy,
#[serde(default)]
pub deny_flags: DenyFlags,
#[serde(default)]
pub allow_shell: bool,
#[serde(default)]
pub env: EnvPolicy,
#[serde(default = "default_dot")]
pub working_dir: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_accepted")]
pub accepted_exit_codes: Vec<i32>,
#[serde(default = "default_concurrency")]
pub concurrency: usize,
#[serde(default = "default_stdin_cap")]
pub stdin_max_bytes: usize,
#[serde(default = "default_stdout_cap")]
pub stdout_max_bytes: usize,
#[serde(default = "default_stderr_cap")]
pub stderr_max_bytes: usize,
#[serde(default)]
pub sandbox: Sandbox,
#[serde(default)]
pub output_mode: OutputMode,
#[serde(skip)]
pub canonical_executable: Option<PathBuf>,
}
fn default_dot() -> String {
".".into()
}
fn default_timeout() -> u64 {
30
}
fn default_accepted() -> Vec<i32> {
vec![0]
}
fn default_concurrency() -> usize {
1
}
fn default_stdin_cap() -> usize {
1_048_576 }
fn default_stdout_cap() -> usize {
10_485_760 }
fn default_stderr_cap() -> usize {
1_048_576 }
#[derive(Clone, Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecGlobalConfig {
#[serde(default = "default_dot")]
pub workspace_root: String,
#[serde(default = "default_timeout")]
pub default_timeout_secs: u64,
#[serde(default = "default_concurrency")]
pub default_concurrency: usize,
#[serde(default = "default_deny_env")]
pub deny_env: Vec<String>,
#[serde(default)]
pub profiles: Vec<ExecProfile>,
#[serde(skip)]
pub canonical_workspace_root: Option<std::path::PathBuf>,
}
impl ExecGlobalConfig {
pub fn validate(&mut self, base: &std::path::Path) -> Result<(), camel_api::CamelError> {
if self.profiles.is_empty() {
return Err(camel_api::CamelError::Config(
"exec: no profiles configured (fail-closed: refusing to execute anything)".into(),
));
}
let ws_root = base.join(&self.workspace_root);
let canonical_root = ws_root.canonicalize().map_err(|e| {
camel_api::CamelError::Config(format!("exec workspace_root unresolvable: {e}"))
})?;
self.canonical_workspace_root = Some(canonical_root.clone());
let mut seen = std::collections::HashSet::new();
for p in &mut self.profiles {
if !seen.insert(p.name.clone()) {
return Err(camel_api::CamelError::Config(format!(
"dup profile name {:?}",
p.name
)));
}
if p.concurrency == 0 {
return Err(camel_api::CamelError::Config(
"concurrency must be > 0".into(),
));
}
if p.timeout_secs == 0 {
return Err(camel_api::CamelError::Config(
"timeout_secs must be > 0".into(),
));
}
if p.accepted_exit_codes.is_empty() {
return Err(camel_api::CamelError::Config(
"accepted_exit_codes must be non-empty".into(),
));
}
if p.output_mode != OutputMode::Materialized {
return Err(camel_api::CamelError::Config(
"output_mode: only \"materialized\" supported in v1".into(),
));
}
if p.sandbox != Sandbox::None {
return Err(camel_api::CamelError::Config(
"sandbox: only \"none\" supported in v1".into(),
));
}
let resolved = resolve_executable(&p.executable)
.map_err(|e| camel_api::CamelError::Config(format!("profile {:?}: {e}", p.name)))?;
p.canonical_executable = Some(resolved);
confine(&canonical_root, &p.working_dir)
.map_err(|e| camel_api::CamelError::Config(format!("profile {:?}: {e}", p.name)))?;
}
Ok(())
}
pub fn profile(&self, name: &str) -> Option<&ExecProfile> {
self.profiles.iter().find(|p| p.name == name)
}
}
fn resolve_executable(spec: &str) -> Result<std::path::PathBuf, String> {
let p = std::path::Path::new(spec);
if p.is_absolute() {
return p
.canonicalize()
.map_err(|e| format!("absolute executable unresolvable: {e}"));
}
which(spec).ok_or_else(|| format!("executable {spec:?} not found on PATH"))
}
fn which(name: &str) -> Option<std::path::PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let cand = dir.join(name);
if cand.is_file() {
return Some(cand);
}
}
None
}
pub(crate) fn confine(root: &std::path::Path, rel: &str) -> Result<std::path::PathBuf, String> {
let p = std::path::Path::new(rel);
if p.is_absolute() {
return Err(format!("working_dir must be relative, got {rel:?}"));
}
if p.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(format!(
"working_dir escapes workspace root (must not contain '..'): {rel:?}"
));
}
let joined = root.join(p);
let canon = joined
.canonicalize()
.map_err(|e| format!("working_dir unresolvable: {e}"))?;
if !canon.starts_with(root) {
return Err(format!(
"working_dir escapes workspace root: {}",
canon.display()
));
}
Ok(canon)
}
fn default_deny_env() -> Vec<String> {
vec![
"LD_*".into(),
"DYLD_*".into(),
"PYTHONPATH".into(),
"RUSTFLAGS".into(),
"GIT_*".into(),
"SSH_AUTH_SOCK".into(),
"*_TOKEN".into(),
"*_KEY".into(),
]
}
#[cfg(test)]
mod tests {
use super::*;
fn gcfg() -> ExecGlobalConfig {
toml::from_str(
r#"
workspace_root = "."
[[profiles]]
name = "echo"
executable = "echo"
args = { allow = "any" }
working_dir = "."
"#,
)
.unwrap()
}
#[test]
fn validate_rejects_empty_profiles_fail_closed() {
let mut c = ExecGlobalConfig::default();
let err = c.validate(&std::env::current_dir().unwrap()).unwrap_err();
assert!(err.to_string().contains("no profiles"), "{err}");
}
#[test]
fn validate_pins_executable_canonical() {
let mut c = gcfg();
c.validate(&std::env::current_dir().unwrap()).unwrap();
assert!(
c.profiles[0].canonical_executable.is_some(),
"must be pinned"
);
}
#[test]
fn validate_rejects_unknown_output_mode_stream() {
let mut c = toml::from_str::<ExecGlobalConfig>(
r#"
[[profiles]]
name = "x"
executable = "echo"
output_mode = "stream"
"#,
)
.unwrap();
let err = c.validate(&std::env::current_dir().unwrap());
assert!(err.is_err(), "stream must be rejected at startup in v1");
}
#[test]
fn validate_rejects_working_dir_escape() {
let mut c = toml::from_str::<ExecGlobalConfig>(
r#"
workspace_root = "."
[[profiles]]
name = "x"
executable = "echo"
working_dir = "../escape"
"#,
)
.unwrap();
let err = c.validate(&std::env::current_dir().unwrap()).unwrap_err();
assert!(
err.to_string().to_lowercase().contains("workspace"),
"{err}"
);
}
}