Skip to main content

camel_component_exec/
config.rs

1//! Configuration types for the exec component. Lives under
2//! `[components.exec]` / `[[components.exec.profiles]]` in Camel.toml.
3
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7/// How args are validated against a profile. Applied **per argv element**.
8#[derive(Clone, Debug, serde::Deserialize)]
9#[serde(deny_unknown_fields, tag = "allow", rename_all = "lowercase")]
10pub enum ArgPolicy {
11    /// Every element accepted (explicit opt-in; operator-curated).
12    Any,
13    /// Every element must string-equal one of `values`.
14    Exact { values: Vec<String> },
15    /// Every element must byte-start-with one of `values`.
16    Prefix { values: Vec<String> },
17}
18
19impl Default for ArgPolicy {
20    fn default() -> Self {
21        // Omitted `args` => deny all non-empty args (safest, fail-closed).
22        // Represented as Exact with empty values: any non-empty arg fails.
23        ArgPolicy::Exact { values: Vec::new() }
24    }
25}
26
27/// Optional flag denylist; applied first regardless of `allow`.
28#[derive(Clone, Debug, Default, serde::Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct DenyFlags(pub Vec<String>);
31
32/// Environment policy for the child.
33#[derive(Clone, Debug, Default, serde::Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct EnvPolicy {
36    /// Var names the child may receive from the host env.
37    #[serde(default)]
38    pub allow: Vec<String>,
39    /// Explicit var=value pairs to set.
40    #[serde(default)]
41    pub set: HashMap<String, String>,
42}
43
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
45#[serde(deny_unknown_fields, rename_all = "lowercase")]
46pub enum OutputMode {
47    #[default]
48    Materialized,
49    /// Reserved for future use; validate() rejects at startup in v1.
50    Stream,
51}
52
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
54#[serde(deny_unknown_fields, rename_all = "lowercase")]
55pub enum Sandbox {
56    #[default]
57    None,
58    /// Reserved for future use; validate() rejects at startup in v1.
59    WorkspaceRead,
60    /// Reserved for future use; validate() rejects at startup in v1.
61    WorkspaceWrite,
62}
63
64/// A single named execution profile.
65#[derive(Clone, Debug, serde::Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct ExecProfile {
68    pub name: String,
69    pub executable: String, // name (PATH-resolved) or absolute path
70    #[serde(default)]
71    pub args: ArgPolicy,
72    #[serde(default)]
73    pub deny_flags: DenyFlags,
74    #[serde(default)]
75    pub allow_shell: bool,
76    #[serde(default)]
77    pub env: EnvPolicy,
78    #[serde(default = "default_dot")]
79    pub working_dir: String,
80    #[serde(default = "default_timeout")]
81    pub timeout_secs: u64,
82    #[serde(default = "default_accepted")]
83    pub accepted_exit_codes: Vec<i32>,
84    #[serde(default = "default_concurrency")]
85    pub concurrency: usize,
86    #[serde(default = "default_stdin_cap")]
87    pub stdin_max_bytes: usize,
88    #[serde(default = "default_stdout_cap")]
89    pub stdout_max_bytes: usize,
90    #[serde(default = "default_stderr_cap")]
91    pub stderr_max_bytes: usize,
92    #[serde(default)]
93    pub sandbox: Sandbox,
94    #[serde(default)]
95    pub output_mode: OutputMode,
96
97    /// Startup-pinned executable path (NOT deserialized, NOT symlink-resolved).
98    ///
99    /// Resolved once via `which()` at validate-time. Stored as-is (no
100    /// `fs::canonicalize()`) to preserve argv[0] for multi-call binary
101    /// compat (BusyBox/uutils). See CONTEXT.md "Canonical executable / pin".
102    #[serde(skip)]
103    pub canonical_executable: Option<PathBuf>,
104}
105
106fn default_dot() -> String {
107    ".".into()
108}
109fn default_timeout() -> u64 {
110    30
111}
112fn default_accepted() -> Vec<i32> {
113    vec![0]
114}
115fn default_concurrency() -> usize {
116    1
117}
118fn default_stdin_cap() -> usize {
119    1_048_576 // 1 MiB
120}
121fn default_stdout_cap() -> usize {
122    10_485_760 // 10 MiB
123}
124fn default_stderr_cap() -> usize {
125    1_048_576 // 1 MiB
126}
127
128/// Global `[components.exec]` config.
129#[derive(Clone, Debug, Default, serde::Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct ExecGlobalConfig {
132    #[serde(default = "default_dot")]
133    pub workspace_root: String,
134    #[serde(default = "default_timeout")]
135    pub default_timeout_secs: u64,
136    #[serde(default = "default_concurrency")]
137    pub default_concurrency: usize,
138    #[serde(default = "default_deny_env")]
139    pub deny_env: Vec<String>,
140    #[serde(default)]
141    pub profiles: Vec<ExecProfile>,
142    /// Resolved at startup pinning (not deserialized). Runtime confinement uses
143    /// THIS, never a `"."` fallback (I-6: fail-closed, no silent weakening).
144    #[serde(skip)]
145    pub canonical_workspace_root: Option<std::path::PathBuf>,
146}
147
148impl ExecGlobalConfig {
149    /// Fail-fast validation + canonical pinning. Must run at startup (ADR-0033).
150    pub fn validate(&mut self, base: &std::path::Path) -> Result<(), camel_api::CamelError> {
151        if self.profiles.is_empty() {
152            return Err(camel_api::CamelError::Config(
153                "exec: no profiles configured (fail-closed: refusing to execute anything)".into(),
154            ));
155        }
156        let ws_root = base.join(&self.workspace_root);
157        let canonical_root = ws_root.canonicalize().map_err(|e| {
158            camel_api::CamelError::Config(format!("exec workspace_root unresolvable: {e}"))
159        })?;
160        // I-6: pin once, reuse at runtime (never fall back to ".").
161        self.canonical_workspace_root = Some(canonical_root.clone());
162
163        let mut seen = std::collections::HashSet::new();
164        for p in &mut self.profiles {
165            if !seen.insert(p.name.clone()) {
166                return Err(camel_api::CamelError::Config(format!(
167                    "dup profile name {:?}",
168                    p.name
169                )));
170            }
171            if p.concurrency == 0 {
172                return Err(camel_api::CamelError::Config(
173                    "concurrency must be > 0".into(),
174                ));
175            }
176            if p.timeout_secs == 0 {
177                return Err(camel_api::CamelError::Config(
178                    "timeout_secs must be > 0".into(),
179                ));
180            }
181            if p.accepted_exit_codes.is_empty() {
182                return Err(camel_api::CamelError::Config(
183                    "accepted_exit_codes must be non-empty".into(),
184                ));
185            }
186
187            // v1 reserved enums: reject future values at startup.
188            if p.output_mode != OutputMode::Materialized {
189                return Err(camel_api::CamelError::Config(
190                    "output_mode: only \"materialized\" supported in v1".into(),
191                ));
192            }
193            if p.sandbox != Sandbox::None {
194                return Err(camel_api::CamelError::Config(
195                    "sandbox: only \"none\" supported in v1".into(),
196                ));
197            }
198
199            // Canonical-pin the executable (no PATH lookup at spawn time).
200            let resolved = resolve_executable(&p.executable)
201                .map_err(|e| camel_api::CamelError::Config(format!("profile {:?}: {e}", p.name)))?;
202            p.canonical_executable = Some(resolved);
203
204            // Confine working_dir under canonical_root.
205            confine(&canonical_root, &p.working_dir)
206                .map_err(|e| camel_api::CamelError::Config(format!("profile {:?}: {e}", p.name)))?;
207        }
208        Ok(())
209    }
210
211    /// Look up a profile by logical name (used by endpoint + producer).
212    pub fn profile(&self, name: &str) -> Option<&ExecProfile> {
213        self.profiles.iter().find(|p| p.name == name)
214    }
215}
216
217/// Resolve an executable to a startup-pinned path. Name → PATH lookup; absolute → as-is.
218/// Does NOT canonicalize — multi-call binary compat (BusyBox/uutils) needs argv[0] to
219/// preserve the symlink path. See CONTEXT.md "Canonical executable / pin".
220fn resolve_executable(spec: &str) -> Result<std::path::PathBuf, String> {
221    let p = std::path::Path::new(spec);
222    if p.is_absolute() {
223        return p
224            .is_file()
225            .then(|| p.to_path_buf())
226            .ok_or_else(|| format!("absolute executable not found: {spec}"));
227    }
228    which(spec).ok_or_else(|| format!("executable {spec:?} not found on PATH"))
229}
230
231/// Minimal PATH lookup (avoid pulling a crate; std-only).
232///
233/// NOTE: does NOT canonicalize — multi-call binaries (e.g. BusyBox, uutils coreutils)
234/// need argv[0] to match the symlink name, not the resolved multi-call binary
235/// path. Startup pinning stores the directory-path as-is.
236fn which(name: &str) -> Option<std::path::PathBuf> {
237    let path = std::env::var_os("PATH")?;
238    for dir in std::env::split_paths(&path) {
239        let cand = dir.join(name);
240        if cand.is_file() {
241            return Some(cand);
242        }
243    }
244    None
245}
246
247/// Confine `rel` under `root`: reject absolute and `..`, canonicalize, require starts_with.
248///
249/// NOTE (M-3): for v1, `confine` does NOT create dirs; it canonicalizes existing
250/// paths and errors if the working_dir does not exist. (Operator must pre-create
251/// working dirs; fail-closed over silent creation.)
252pub(crate) fn confine(root: &std::path::Path, rel: &str) -> Result<std::path::PathBuf, String> {
253    let p = std::path::Path::new(rel);
254    if p.is_absolute() {
255        return Err(format!("working_dir must be relative, got {rel:?}"));
256    }
257    if p.components()
258        .any(|c| matches!(c, std::path::Component::ParentDir))
259    {
260        return Err(format!(
261            "working_dir escapes workspace root (must not contain '..'): {rel:?}"
262        ));
263    }
264    let joined = root.join(p);
265    let canon = joined
266        .canonicalize()
267        .map_err(|e| format!("working_dir unresolvable: {e}"))?;
268    if !canon.starts_with(root) {
269        return Err(format!(
270            "working_dir escapes workspace root: {}",
271            canon.display()
272        ));
273    }
274    Ok(canon)
275}
276
277fn default_deny_env() -> Vec<String> {
278    vec![
279        "LD_*".into(),
280        "DYLD_*".into(),
281        "PYTHONPATH".into(),
282        "RUSTFLAGS".into(),
283        "GIT_*".into(),
284        "SSH_AUTH_SOCK".into(),
285        "*_TOKEN".into(),
286        "*_KEY".into(),
287    ]
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    fn gcfg() -> ExecGlobalConfig {
295        toml::from_str(
296            r#"
297workspace_root = "."
298[[profiles]]
299name = "echo"
300executable = "echo"
301args = { allow = "any" }
302working_dir = "."
303"#,
304        )
305        .unwrap()
306    }
307
308    #[test]
309    fn validate_rejects_empty_profiles_fail_closed() {
310        let mut c = ExecGlobalConfig::default();
311        let err = c.validate(&std::env::current_dir().unwrap()).unwrap_err();
312        assert!(err.to_string().contains("no profiles"), "{err}");
313    }
314
315    #[test]
316    fn validate_pins_executable_canonical() {
317        let mut c = gcfg();
318        c.validate(&std::env::current_dir().unwrap()).unwrap();
319        assert!(
320            c.profiles[0].canonical_executable.is_some(),
321            "must be pinned"
322        );
323    }
324
325    #[test]
326    fn validate_rejects_unknown_output_mode_stream() {
327        let mut c = toml::from_str::<ExecGlobalConfig>(
328            r#"
329[[profiles]]
330name = "x"
331executable = "echo"
332output_mode = "stream"
333"#,
334        )
335        .unwrap();
336        let err = c.validate(&std::env::current_dir().unwrap());
337        assert!(err.is_err(), "stream must be rejected at startup in v1");
338    }
339
340    #[test]
341    fn validate_rejects_working_dir_escape() {
342        let mut c = toml::from_str::<ExecGlobalConfig>(
343            r#"
344workspace_root = "."
345[[profiles]]
346name = "x"
347executable = "echo"
348working_dir = "../escape"
349"#,
350        )
351        .unwrap();
352        let err = c.validate(&std::env::current_dir().unwrap()).unwrap_err();
353        assert!(
354            err.to_string().to_lowercase().contains("workspace"),
355            "{err}"
356        );
357    }
358}