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)]
99 pub canonical_executable: Option<PathBuf>,
100}
101
102fn default_dot() -> String {
103 ".".into()
104}
105fn default_timeout() -> u64 {
106 30
107}
108fn default_accepted() -> Vec<i32> {
109 vec![0]
110}
111fn default_concurrency() -> usize {
112 1
113}
114fn default_stdin_cap() -> usize {
115 1_048_576 }
117fn default_stdout_cap() -> usize {
118 10_485_760 }
120fn default_stderr_cap() -> usize {
121 1_048_576 }
123
124#[derive(Clone, Debug, Default, serde::Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct ExecGlobalConfig {
128 #[serde(default = "default_dot")]
129 pub workspace_root: String,
130 #[serde(default = "default_timeout")]
131 pub default_timeout_secs: u64,
132 #[serde(default = "default_concurrency")]
133 pub default_concurrency: usize,
134 #[serde(default = "default_deny_env")]
135 pub deny_env: Vec<String>,
136 #[serde(default)]
137 pub profiles: Vec<ExecProfile>,
138 #[serde(skip)]
141 pub canonical_workspace_root: Option<std::path::PathBuf>,
142}
143
144impl ExecGlobalConfig {
145 pub fn validate(&mut self, base: &std::path::Path) -> Result<(), camel_api::CamelError> {
147 if self.profiles.is_empty() {
148 return Err(camel_api::CamelError::Config(
149 "exec: no profiles configured (fail-closed: refusing to execute anything)".into(),
150 ));
151 }
152 let ws_root = base.join(&self.workspace_root);
153 let canonical_root = ws_root.canonicalize().map_err(|e| {
154 camel_api::CamelError::Config(format!("exec workspace_root unresolvable: {e}"))
155 })?;
156 self.canonical_workspace_root = Some(canonical_root.clone());
158
159 let mut seen = std::collections::HashSet::new();
160 for p in &mut self.profiles {
161 if !seen.insert(p.name.clone()) {
162 return Err(camel_api::CamelError::Config(format!(
163 "dup profile name {:?}",
164 p.name
165 )));
166 }
167 if p.concurrency == 0 {
168 return Err(camel_api::CamelError::Config(
169 "concurrency must be > 0".into(),
170 ));
171 }
172 if p.timeout_secs == 0 {
173 return Err(camel_api::CamelError::Config(
174 "timeout_secs must be > 0".into(),
175 ));
176 }
177 if p.accepted_exit_codes.is_empty() {
178 return Err(camel_api::CamelError::Config(
179 "accepted_exit_codes must be non-empty".into(),
180 ));
181 }
182
183 if p.output_mode != OutputMode::Materialized {
185 return Err(camel_api::CamelError::Config(
186 "output_mode: only \"materialized\" supported in v1".into(),
187 ));
188 }
189 if p.sandbox != Sandbox::None {
190 return Err(camel_api::CamelError::Config(
191 "sandbox: only \"none\" supported in v1".into(),
192 ));
193 }
194
195 let resolved = resolve_executable(&p.executable)
197 .map_err(|e| camel_api::CamelError::Config(format!("profile {:?}: {e}", p.name)))?;
198 p.canonical_executable = Some(resolved);
199
200 confine(&canonical_root, &p.working_dir)
202 .map_err(|e| camel_api::CamelError::Config(format!("profile {:?}: {e}", p.name)))?;
203 }
204 Ok(())
205 }
206
207 pub fn profile(&self, name: &str) -> Option<&ExecProfile> {
209 self.profiles.iter().find(|p| p.name == name)
210 }
211}
212
213fn resolve_executable(spec: &str) -> Result<std::path::PathBuf, String> {
215 let p = std::path::Path::new(spec);
216 if p.is_absolute() {
217 return p
218 .canonicalize()
219 .map_err(|e| format!("absolute executable unresolvable: {e}"));
220 }
221 which(spec).ok_or_else(|| format!("executable {spec:?} not found on PATH"))
222}
223
224fn which(name: &str) -> Option<std::path::PathBuf> {
230 let path = std::env::var_os("PATH")?;
231 for dir in std::env::split_paths(&path) {
232 let cand = dir.join(name);
233 if cand.is_file() {
234 return Some(cand);
235 }
236 }
237 None
238}
239
240pub(crate) fn confine(root: &std::path::Path, rel: &str) -> Result<std::path::PathBuf, String> {
246 let p = std::path::Path::new(rel);
247 if p.is_absolute() {
248 return Err(format!("working_dir must be relative, got {rel:?}"));
249 }
250 if p.components()
251 .any(|c| matches!(c, std::path::Component::ParentDir))
252 {
253 return Err(format!(
254 "working_dir escapes workspace root (must not contain '..'): {rel:?}"
255 ));
256 }
257 let joined = root.join(p);
258 let canon = joined
259 .canonicalize()
260 .map_err(|e| format!("working_dir unresolvable: {e}"))?;
261 if !canon.starts_with(root) {
262 return Err(format!(
263 "working_dir escapes workspace root: {}",
264 canon.display()
265 ));
266 }
267 Ok(canon)
268}
269
270fn default_deny_env() -> Vec<String> {
271 vec![
272 "LD_*".into(),
273 "DYLD_*".into(),
274 "PYTHONPATH".into(),
275 "RUSTFLAGS".into(),
276 "GIT_*".into(),
277 "SSH_AUTH_SOCK".into(),
278 "*_TOKEN".into(),
279 "*_KEY".into(),
280 ]
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn gcfg() -> ExecGlobalConfig {
288 toml::from_str(
289 r#"
290workspace_root = "."
291[[profiles]]
292name = "echo"
293executable = "echo"
294args = { allow = "any" }
295working_dir = "."
296"#,
297 )
298 .unwrap()
299 }
300
301 #[test]
302 fn validate_rejects_empty_profiles_fail_closed() {
303 let mut c = ExecGlobalConfig::default();
304 let err = c.validate(&std::env::current_dir().unwrap()).unwrap_err();
305 assert!(err.to_string().contains("no profiles"), "{err}");
306 }
307
308 #[test]
309 fn validate_pins_executable_canonical() {
310 let mut c = gcfg();
311 c.validate(&std::env::current_dir().unwrap()).unwrap();
312 assert!(
313 c.profiles[0].canonical_executable.is_some(),
314 "must be pinned"
315 );
316 }
317
318 #[test]
319 fn validate_rejects_unknown_output_mode_stream() {
320 let mut c = toml::from_str::<ExecGlobalConfig>(
321 r#"
322[[profiles]]
323name = "x"
324executable = "echo"
325output_mode = "stream"
326"#,
327 )
328 .unwrap();
329 let err = c.validate(&std::env::current_dir().unwrap());
330 assert!(err.is_err(), "stream must be rejected at startup in v1");
331 }
332
333 #[test]
334 fn validate_rejects_working_dir_escape() {
335 let mut c = toml::from_str::<ExecGlobalConfig>(
336 r#"
337workspace_root = "."
338[[profiles]]
339name = "x"
340executable = "echo"
341working_dir = "../escape"
342"#,
343 )
344 .unwrap();
345 let err = c.validate(&std::env::current_dir().unwrap()).unwrap_err();
346 assert!(
347 err.to_string().to_lowercase().contains("workspace"),
348 "{err}"
349 );
350 }
351}