1use std::collections::HashMap;
5use std::path::PathBuf;
6
7#[derive(Clone, Debug, serde::Deserialize)]
9#[serde(deny_unknown_fields, tag = "allow", rename_all = "lowercase")]
10pub enum ArgPolicy {
11 Any,
13 Exact { values: Vec<String> },
15 Prefix { values: Vec<String> },
17}
18
19impl Default for ArgPolicy {
20 fn default() -> Self {
21 ArgPolicy::Exact { values: Vec::new() }
24 }
25}
26
27#[derive(Clone, Debug, Default, serde::Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct DenyFlags(pub Vec<String>);
31
32#[derive(Clone, Debug, Default, serde::Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct EnvPolicy {
36 #[serde(default)]
38 pub allow: Vec<String>,
39 #[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 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 WorkspaceRead,
60 WorkspaceWrite,
62}
63
64#[derive(Clone, Debug, serde::Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct ExecProfile {
68 pub name: String,
69 pub executable: String, #[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 #[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 }
121fn default_stdout_cap() -> usize {
122 10_485_760 }
124fn default_stderr_cap() -> usize {
125 1_048_576 }
127
128#[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 #[serde(skip)]
145 pub canonical_workspace_root: Option<std::path::PathBuf>,
146}
147
148impl ExecGlobalConfig {
149 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 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 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 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(&canonical_root, &p.working_dir)
206 .map_err(|e| camel_api::CamelError::Config(format!("profile {:?}: {e}", p.name)))?;
207 }
208
209 for p in &self.profiles {
214 tracing::info!(
215 profile = p.name,
216 exe = ?p.canonical_executable,
217 "exec profile active"
218 );
219 }
220 Ok(())
221 }
222
223 pub fn profile(&self, name: &str) -> Option<&ExecProfile> {
225 self.profiles.iter().find(|p| p.name == name)
226 }
227}
228
229fn resolve_executable(spec: &str) -> Result<std::path::PathBuf, String> {
233 let p = std::path::Path::new(spec);
234 if p.is_absolute() {
235 return p
236 .is_file()
237 .then(|| p.to_path_buf())
238 .ok_or_else(|| format!("absolute executable not found: {spec}"));
239 }
240 which(spec).ok_or_else(|| format!("executable {spec:?} not found on PATH"))
241}
242
243fn which(name: &str) -> Option<std::path::PathBuf> {
249 let path = std::env::var_os("PATH")?;
250 for dir in std::env::split_paths(&path) {
251 let cand = dir.join(name);
252 if cand.is_file() {
253 return Some(cand);
254 }
255 }
256 None
257}
258
259pub(crate) fn confine(root: &std::path::Path, rel: &str) -> Result<std::path::PathBuf, String> {
265 let p = std::path::Path::new(rel);
266 if p.is_absolute() {
267 return Err(format!("working_dir must be relative, got {rel:?}"));
268 }
269 if p.components()
270 .any(|c| matches!(c, std::path::Component::ParentDir))
271 {
272 return Err(format!(
273 "working_dir escapes workspace root (must not contain '..'): {rel:?}"
274 ));
275 }
276 let joined = root.join(p);
277 let canon = joined
278 .canonicalize()
279 .map_err(|e| format!("working_dir unresolvable: {e}"))?;
280 if !canon.starts_with(root) {
281 return Err(format!(
282 "working_dir escapes workspace root: {}",
283 canon.display()
284 ));
285 }
286 Ok(canon)
287}
288
289fn default_deny_env() -> Vec<String> {
290 vec![
291 "LD_*".into(),
292 "DYLD_*".into(),
293 "PYTHONPATH".into(),
294 "RUSTFLAGS".into(),
295 "GIT_*".into(),
296 "SSH_AUTH_SOCK".into(),
297 "*_TOKEN".into(),
298 "*_KEY".into(),
299 ]
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 fn gcfg() -> ExecGlobalConfig {
307 toml::from_str(
308 r#"
309workspace_root = "."
310[[profiles]]
311name = "echo"
312executable = "echo"
313args = { allow = "any" }
314working_dir = "."
315"#,
316 )
317 .unwrap()
318 }
319
320 #[test]
321 fn validate_rejects_empty_profiles_fail_closed() {
322 let mut c = ExecGlobalConfig::default();
323 let err = c.validate(&std::env::current_dir().unwrap()).unwrap_err();
324 assert!(err.to_string().contains("no profiles"), "{err}");
325 }
326
327 #[test]
328 fn validate_pins_executable_canonical() {
329 let mut c = gcfg();
330 c.validate(&std::env::current_dir().unwrap()).unwrap();
331 assert!(
332 c.profiles[0].canonical_executable.is_some(),
333 "must be pinned"
334 );
335 }
336
337 #[test]
338 fn validate_rejects_unknown_output_mode_stream() {
339 let mut c = toml::from_str::<ExecGlobalConfig>(
340 r#"
341[[profiles]]
342name = "x"
343executable = "echo"
344output_mode = "stream"
345"#,
346 )
347 .unwrap();
348 let err = c.validate(&std::env::current_dir().unwrap());
349 assert!(err.is_err(), "stream must be rejected at startup in v1");
350 }
351
352 #[test]
353 fn validate_rejects_working_dir_escape() {
354 let mut c = toml::from_str::<ExecGlobalConfig>(
355 r#"
356workspace_root = "."
357[[profiles]]
358name = "x"
359executable = "echo"
360working_dir = "../escape"
361"#,
362 )
363 .unwrap();
364 let err = c.validate(&std::env::current_dir().unwrap()).unwrap_err();
365 assert!(
366 err.to_string().to_lowercase().contains("workspace"),
367 "{err}"
368 );
369 }
370}