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 Ok(())
209 }
210
211 pub fn profile(&self, name: &str) -> Option<&ExecProfile> {
213 self.profiles.iter().find(|p| p.name == name)
214 }
215}
216
217fn 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
231fn 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
247pub(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}