Skip to main content

execgo_runtime/
types.rs

1use std::{
2    collections::{BTreeMap, HashMap},
3    path::{Component, Path, PathBuf},
4};
5
6use chrono::{DateTime, Utc};
7use clap::ValueEnum;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::error::{AppError, AppResult};
12
13const DEFAULT_OUTPUT_INLINE_BYTES: u64 = 4 * 1024 * 1024;
14const DEFAULT_WALL_TIME_MS: u64 = 5 * 60 * 1000;
15const CAPABILITY_SNAPSHOT_VERSION: &str = "v1";
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum TaskStatus {
20    Accepted,
21    Running,
22    Success,
23    Failed,
24    Cancelled,
25}
26
27impl TaskStatus {
28    pub fn is_terminal(&self) -> bool {
29        matches!(self, Self::Success | Self::Failed | Self::Cancelled)
30    }
31}
32
33#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
34#[serde(rename_all = "snake_case")]
35pub enum ErrorCode {
36    InvalidInput,
37    LaunchFailed,
38    Timeout,
39    Cancelled,
40    MemoryLimitExceeded,
41    CpuLimitExceeded,
42    ResourceLimitExceeded,
43    SandboxSetupFailed,
44    ExitNonZero,
45    UnsupportedCapability,
46    InsufficientResources,
47    Internal,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct RuntimeErrorInfo {
52    pub code: ErrorCode,
53    pub message: String,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub details: Option<Value>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "snake_case")]
60pub enum EventType {
61    Submitted,
62    Accepted,
63    Planned,
64    Degraded,
65    ResourceReserved,
66    ResourceReleased,
67    Started,
68    KillRequested,
69    TimeoutTriggered,
70    Finished,
71    Failed,
72    Cancelled,
73    Recovered,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77#[serde(rename_all = "snake_case")]
78pub enum ExecutionKind {
79    Command,
80    Script,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub struct ExecutionSpec {
85    pub kind: ExecutionKind,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub program: Option<String>,
88    #[serde(default)]
89    pub args: Vec<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub script: Option<String>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub interpreter: Option<Vec<String>>,
94    #[serde(default)]
95    pub env: HashMap<String, String>,
96}
97
98impl ExecutionSpec {
99    pub fn validate(&self) -> AppResult<()> {
100        match self.kind {
101            ExecutionKind::Command => {
102                let program = self.program.as_deref().map(str::trim).unwrap_or_default();
103                if program.is_empty() {
104                    return Err(AppError::InvalidInput(
105                        "execution.program is required for command tasks".into(),
106                    ));
107                }
108                if self.script.as_ref().is_some_and(|v| !v.trim().is_empty()) {
109                    return Err(AppError::InvalidInput(
110                        "execution.script must be empty for command tasks".into(),
111                    ));
112                }
113            }
114            ExecutionKind::Script => {
115                let script = self.script.as_deref().map(str::trim).unwrap_or_default();
116                if script.is_empty() {
117                    return Err(AppError::InvalidInput(
118                        "execution.script is required for script tasks".into(),
119                    ));
120                }
121                if self.program.as_ref().is_some_and(|v| !v.trim().is_empty()) {
122                    return Err(AppError::InvalidInput(
123                        "execution.program must be empty for script tasks".into(),
124                    ));
125                }
126                if let Some(interpreter) = &self.interpreter {
127                    if interpreter.is_empty() || interpreter[0].trim().is_empty() {
128                        return Err(AppError::InvalidInput(
129                            "execution.interpreter must contain at least one non-empty value"
130                                .into(),
131                        ));
132                    }
133                }
134            }
135        }
136
137        if self
138            .env
139            .keys()
140            .any(|key| key.trim().is_empty() || key.contains('='))
141        {
142            return Err(AppError::InvalidInput(
143                "execution.env keys must be non-empty and cannot contain '='".into(),
144            ));
145        }
146        Ok(())
147    }
148}
149
150#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)]
151#[serde(rename_all = "snake_case")]
152pub enum CapabilityMode {
153    #[default]
154    Adaptive,
155    Strict,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
159pub struct TaskPolicy {
160    #[serde(default)]
161    pub capability_mode: CapabilityMode,
162}
163
164impl TaskPolicy {
165    pub fn validate(&self) -> AppResult<()> {
166        Ok(())
167    }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
171pub struct ControlContext {
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub control_plane_mode: Option<String>,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub tenant: Option<String>,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub expected_runtime_profile: Option<String>,
178    #[serde(default)]
179    pub requires_strict_sandbox: bool,
180    #[serde(default)]
181    pub requires_resource_reservation: bool,
182    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
183    pub labels: BTreeMap<String, String>,
184}
185
186impl ControlContext {
187    pub fn validate(&self) -> AppResult<()> {
188        if self
189            .labels
190            .keys()
191            .any(|key| key.trim().is_empty() || key.contains('='))
192        {
193            return Err(AppError::InvalidInput(
194                "control_context.labels keys must be non-empty and cannot contain '='".into(),
195            ));
196        }
197
198        for value in [
199            self.control_plane_mode.as_deref(),
200            self.tenant.as_deref(),
201            self.expected_runtime_profile.as_deref(),
202        ] {
203            if value.is_some_and(|item| item.trim().is_empty()) {
204                return Err(AppError::InvalidInput(
205                    "control_context values cannot be empty strings".into(),
206                ));
207            }
208        }
209
210        Ok(())
211    }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
215#[serde(rename_all = "snake_case")]
216pub enum SandboxProfile {
217    #[default]
218    Process,
219    LinuxSandbox,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223pub struct NamespaceConfig {
224    #[serde(default = "default_true")]
225    pub mount: bool,
226    #[serde(default = "default_true")]
227    pub pid: bool,
228    #[serde(default = "default_true")]
229    pub uts: bool,
230    #[serde(default = "default_true")]
231    pub ipc: bool,
232    #[serde(default)]
233    pub net: bool,
234}
235
236impl Default for NamespaceConfig {
237    fn default() -> Self {
238        Self {
239            mount: true,
240            pid: true,
241            uts: true,
242            ipc: true,
243            net: false,
244        }
245    }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
249pub struct SandboxPolicy {
250    #[serde(default)]
251    pub profile: SandboxProfile,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub workspace_subdir: Option<String>,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub rootfs: Option<String>,
256    #[serde(default)]
257    pub chroot: bool,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub namespaces: Option<NamespaceConfig>,
260}
261
262impl Default for SandboxPolicy {
263    fn default() -> Self {
264        Self {
265            profile: SandboxProfile::Process,
266            workspace_subdir: None,
267            rootfs: None,
268            chroot: false,
269            namespaces: None,
270        }
271    }
272}
273
274impl SandboxPolicy {
275    pub fn validate(&self) -> AppResult<()> {
276        if let Some(subdir) = &self.workspace_subdir {
277            validate_relative_workspace_subdir(subdir)?;
278        }
279        if self.chroot
280            && self
281                .rootfs
282                .as_deref()
283                .map(str::trim)
284                .unwrap_or_default()
285                .is_empty()
286        {
287            return Err(AppError::InvalidInput(
288                "sandbox.rootfs is required when sandbox.chroot=true".into(),
289            ));
290        }
291        if matches!(self.profile, SandboxProfile::Process) && self.chroot {
292            return Err(AppError::InvalidInput(
293                "sandbox.chroot requires sandbox.profile=linux_sandbox".into(),
294            ));
295        }
296        Ok(())
297    }
298
299    pub fn effective_namespaces(&self) -> NamespaceConfig {
300        self.namespaces.clone().unwrap_or_default()
301    }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
305pub struct ResourceLimits {
306    #[serde(default = "default_wall_time_ms")]
307    pub wall_time_ms: u64,
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub cpu_time_sec: Option<u64>,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub memory_bytes: Option<u64>,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub pids_max: Option<u64>,
314    #[serde(default = "default_output_inline_bytes")]
315    pub stdout_max_bytes: u64,
316    #[serde(default = "default_output_inline_bytes")]
317    pub stderr_max_bytes: u64,
318}
319
320impl Default for ResourceLimits {
321    fn default() -> Self {
322        Self {
323            wall_time_ms: default_wall_time_ms(),
324            cpu_time_sec: None,
325            memory_bytes: None,
326            pids_max: None,
327            stdout_max_bytes: default_output_inline_bytes(),
328            stderr_max_bytes: default_output_inline_bytes(),
329        }
330    }
331}
332
333impl ResourceLimits {
334    pub fn validate(&self) -> AppResult<()> {
335        if self.wall_time_ms == 0 {
336            return Err(AppError::InvalidInput(
337                "limits.wall_time_ms must be greater than 0".into(),
338            ));
339        }
340        if self.stdout_max_bytes == 0 || self.stderr_max_bytes == 0 {
341            return Err(AppError::InvalidInput(
342                "limits.stdout_max_bytes and limits.stderr_max_bytes must be greater than 0".into(),
343            ));
344        }
345        Ok(())
346    }
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
350pub struct ResourceEnforcementPlan {
351    pub wall_time_ms: u64,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub cpu_time_sec: Option<u64>,
354    #[serde(default)]
355    pub cpu_time_enforced: bool,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub memory_bytes: Option<u64>,
358    #[serde(default)]
359    pub memory_enforced: bool,
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub pids_max: Option<u64>,
362    #[serde(default)]
363    pub pids_enforced: bool,
364    #[serde(default)]
365    pub cgroup_enforced: bool,
366    #[serde(default)]
367    pub oom_detection: bool,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
371pub struct ExecutionPlan {
372    #[serde(default)]
373    pub capability_mode: CapabilityMode,
374    pub requested_sandbox: SandboxPolicy,
375    pub effective_sandbox: SandboxPolicy,
376    pub resource_enforcement: ResourceEnforcementPlan,
377    #[serde(default)]
378    pub degraded: bool,
379    #[serde(default, skip_serializing_if = "Vec::is_empty")]
380    pub fallback_reasons: Vec<String>,
381    #[serde(default, skip_serializing_if = "Vec::is_empty")]
382    pub capability_warnings: Vec<String>,
383}
384
385impl ExecutionPlan {
386    pub fn legacy(sandbox: SandboxPolicy, limits: ResourceLimits) -> Self {
387        let cgroup_enforced = matches!(sandbox.profile, SandboxProfile::LinuxSandbox);
388        Self {
389            capability_mode: CapabilityMode::Adaptive,
390            requested_sandbox: sandbox.clone(),
391            effective_sandbox: sandbox,
392            resource_enforcement: ResourceEnforcementPlan {
393                wall_time_ms: limits.wall_time_ms,
394                cpu_time_sec: limits.cpu_time_sec,
395                cpu_time_enforced: limits.cpu_time_sec.is_some(),
396                memory_bytes: limits.memory_bytes,
397                memory_enforced: limits.memory_bytes.is_some(),
398                pids_max: limits.pids_max,
399                pids_enforced: limits.pids_max.is_some() && cgroup_enforced,
400                cgroup_enforced,
401                oom_detection: limits.memory_bytes.is_some() && cgroup_enforced,
402            },
403            degraded: false,
404            fallback_reasons: Vec::new(),
405            capability_warnings: Vec::new(),
406        }
407    }
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
411pub struct TaskResourceReservation {
412    pub task_slots: u64,
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub memory_bytes: Option<u64>,
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub pids: Option<u64>,
417}
418
419impl TaskResourceReservation {
420    pub fn from_limits(limits: &ResourceLimits) -> Self {
421        Self {
422            task_slots: 1,
423            memory_bytes: limits.memory_bytes,
424            pids: limits.pids_max,
425        }
426    }
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
430pub struct SubmitTaskRequest {
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub task_id: Option<String>,
433    pub execution: ExecutionSpec,
434    #[serde(default)]
435    pub limits: ResourceLimits,
436    #[serde(default)]
437    pub sandbox: SandboxPolicy,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub policy: Option<TaskPolicy>,
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub control_context: Option<ControlContext>,
442    #[serde(default)]
443    pub metadata: BTreeMap<String, String>,
444}
445
446impl SubmitTaskRequest {
447    pub fn validate(&self) -> AppResult<()> {
448        if let Some(task_id) = &self.task_id {
449            validate_task_id(task_id)?;
450        }
451        self.execution.validate()?;
452        self.limits.validate()?;
453        self.sandbox.validate()?;
454        if let Some(policy) = &self.policy {
455            policy.validate()?;
456        }
457        if let Some(control_context) = &self.control_context {
458            control_context.validate()?;
459        }
460        Ok(())
461    }
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
465pub struct SubmitTaskResponse {
466    pub task_id: String,
467    pub handle_id: String,
468    pub status: TaskStatus,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
472pub struct ResourceUsage {
473    pub duration_ms: u64,
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub user_cpu_ms: Option<u64>,
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub system_cpu_ms: Option<u64>,
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub max_rss_bytes: Option<u64>,
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub memory_peak_bytes: Option<u64>,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
485pub struct TaskArtifacts {
486    pub task_dir: String,
487    pub request_path: String,
488    pub result_path: String,
489    pub stdout_path: String,
490    pub stderr_path: String,
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub script_path: Option<String>,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
496pub struct TaskStatusResponse {
497    pub task_id: String,
498    pub handle_id: String,
499    pub status: TaskStatus,
500    pub created_at: DateTime<Utc>,
501    pub updated_at: DateTime<Utc>,
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub started_at: Option<DateTime<Utc>>,
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub finished_at: Option<DateTime<Utc>>,
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub duration_ms: Option<u64>,
508    #[serde(skip_serializing_if = "Option::is_none")]
509    pub shim_pid: Option<u32>,
510    #[serde(skip_serializing_if = "Option::is_none")]
511    pub pid: Option<u32>,
512    #[serde(skip_serializing_if = "Option::is_none")]
513    pub pgid: Option<i32>,
514    #[serde(skip_serializing_if = "Option::is_none")]
515    pub exit_code: Option<i32>,
516    #[serde(skip_serializing_if = "Option::is_none")]
517    pub exit_signal: Option<i32>,
518    pub stdout: String,
519    pub stderr: String,
520    pub stdout_truncated: bool,
521    pub stderr_truncated: bool,
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub error: Option<RuntimeErrorInfo>,
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub usage: Option<ResourceUsage>,
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub execution_plan: Option<ExecutionPlan>,
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub reservation: Option<TaskResourceReservation>,
530    pub artifacts: TaskArtifacts,
531    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
532    pub metadata: BTreeMap<String, String>,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
536pub struct EventRecord {
537    pub seq: i64,
538    pub task_id: String,
539    pub event_type: EventType,
540    pub timestamp: DateTime<Utc>,
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub message: Option<String>,
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub data: Option<Value>,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
548pub struct HealthResponse {
549    pub status: &'static str,
550    pub version: &'static str,
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
554pub struct RuntimePlatform {
555    pub os: String,
556    pub arch: String,
557    pub containerized: bool,
558    pub kubernetes: bool,
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
562pub struct ExecutionCapabilities {
563    pub command: bool,
564    pub script: bool,
565    pub process_group: bool,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
569pub struct NamespaceCapabilities {
570    pub mount: bool,
571    pub pid: bool,
572    pub uts: bool,
573    pub ipc: bool,
574    pub net: bool,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
578pub struct SandboxCapabilities {
579    pub process: bool,
580    pub linux_sandbox: bool,
581    pub chroot: bool,
582    pub namespaces: NamespaceCapabilities,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
586pub struct StorageCapabilities {
587    pub data_dir_writable: bool,
588}
589
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
591pub struct ResourceCapacity {
592    pub task_slots: u64,
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub memory_bytes: Option<u64>,
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub pids: Option<u64>,
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
600pub struct ResourceCapabilities {
601    pub rlimit_cpu: bool,
602    pub rlimit_memory: bool,
603    pub cgroup_v2: bool,
604    pub cgroup_writable: bool,
605    pub memory_limit: bool,
606    pub pids_limit: bool,
607    pub oom_detection: bool,
608    pub cpu_quota: bool,
609    pub ledger: bool,
610    pub capacity: ResourceCapacity,
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
614pub struct RuntimeCapabilities {
615    pub runtime_id: String,
616    pub snapshot_version: String,
617    pub collected_at: DateTime<Utc>,
618    pub platform: RuntimePlatform,
619    pub execution: ExecutionCapabilities,
620    pub sandbox: SandboxCapabilities,
621    pub storage: StorageCapabilities,
622    pub resources: ResourceCapabilities,
623    #[serde(default, skip_serializing_if = "Vec::is_empty")]
624    pub stable_semantics: Vec<String>,
625    #[serde(default, skip_serializing_if = "Vec::is_empty")]
626    pub enhanced_semantics: Vec<String>,
627    #[serde(default, skip_serializing_if = "Vec::is_empty")]
628    pub warnings: Vec<String>,
629    #[serde(default)]
630    pub degraded: bool,
631    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
632    pub overrides: BTreeMap<String, String>,
633}
634
635impl RuntimeCapabilities {
636    pub fn snapshot_version() -> &'static str {
637        CAPABILITY_SNAPSHOT_VERSION
638    }
639}
640
641#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
642pub struct RuntimeInfoResponse {
643    pub runtime_id: String,
644    pub version: String,
645    pub started_at: DateTime<Utc>,
646    pub snapshot_version: String,
647    pub platform: RuntimePlatform,
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
651pub struct RuntimeConfigResponse {
652    pub runtime_id: String,
653    pub listen_addr: String,
654    pub data_dir: String,
655    pub max_running_tasks: usize,
656    pub max_queued_tasks: usize,
657    pub termination_grace_ms: u64,
658    pub result_retention_secs: u64,
659    pub gc_interval_ms: u64,
660    pub dispatch_poll_interval_ms: u64,
661    pub cgroup_root: String,
662    pub default_capability_mode: CapabilityMode,
663    pub cgroup_enabled: bool,
664}
665
666#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
667pub struct ActiveTaskReservation {
668    pub task_id: String,
669    pub status: TaskStatus,
670    pub reservation: TaskResourceReservation,
671    #[serde(skip_serializing_if = "Option::is_none")]
672    pub reserved_at: Option<DateTime<Utc>>,
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
676pub struct RuntimeResourcesResponse {
677    pub runtime_id: String,
678    pub capacity: ResourceCapacity,
679    pub reserved: ResourceCapacity,
680    pub available: ResourceCapacity,
681    #[serde(default)]
682    pub active_reservations: Vec<ActiveTaskReservation>,
683    pub accepted_waiting_tasks: u64,
684}
685
686pub fn validate_task_id(task_id: &str) -> AppResult<()> {
687    let trimmed = task_id.trim();
688    if trimmed.is_empty() {
689        return Err(AppError::InvalidInput("task_id cannot be empty".into()));
690    }
691    if !trimmed
692        .chars()
693        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
694    {
695        return Err(AppError::InvalidInput(
696            "task_id may only contain letters, digits, '-', '_' and '.'".into(),
697        ));
698    }
699    Ok(())
700}
701
702pub fn resolve_workspace_dir(task_dir: &Path, sandbox: &SandboxPolicy) -> AppResult<PathBuf> {
703    let base = task_dir.join("workspace");
704    if let Some(subdir) = &sandbox.workspace_subdir {
705        validate_relative_workspace_subdir(subdir)?;
706        Ok(base.join(subdir))
707    } else {
708        Ok(base)
709    }
710}
711
712pub fn default_output_inline_bytes() -> u64 {
713    DEFAULT_OUTPUT_INLINE_BYTES
714}
715
716pub fn default_wall_time_ms() -> u64 {
717    DEFAULT_WALL_TIME_MS
718}
719
720fn default_true() -> bool {
721    true
722}
723
724fn validate_relative_workspace_subdir(subdir: &str) -> AppResult<()> {
725    let path = Path::new(subdir);
726    if path.is_absolute() {
727        return Err(AppError::InvalidInput(
728            "sandbox.workspace_subdir must be relative".into(),
729        ));
730    }
731    if path.components().any(|component| {
732        matches!(
733            component,
734            Component::ParentDir | Component::RootDir | Component::Prefix(_)
735        )
736    }) {
737        return Err(AppError::InvalidInput(
738            "sandbox.workspace_subdir cannot contain parent traversal".into(),
739        ));
740    }
741    Ok(())
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    #[test]
749    fn validates_command_execution() {
750        let spec = ExecutionSpec {
751            kind: ExecutionKind::Command,
752            program: Some("echo".into()),
753            args: vec!["ok".into()],
754            script: None,
755            interpreter: None,
756            env: HashMap::new(),
757        };
758        assert!(spec.validate().is_ok());
759    }
760
761    #[test]
762    fn rejects_absolute_workspace_subdir() {
763        let sandbox = SandboxPolicy {
764            profile: SandboxProfile::Process,
765            workspace_subdir: Some("/tmp".into()),
766            rootfs: None,
767            chroot: false,
768            namespaces: None,
769        };
770        assert!(sandbox.validate().is_err());
771    }
772
773    #[test]
774    fn default_policy_is_adaptive() {
775        assert_eq!(
776            TaskPolicy::default().capability_mode,
777            CapabilityMode::Adaptive
778        );
779    }
780
781    #[test]
782    fn legacy_execution_plan_keeps_requested_sandbox() {
783        let sandbox = SandboxPolicy {
784            profile: SandboxProfile::LinuxSandbox,
785            workspace_subdir: None,
786            rootfs: None,
787            chroot: false,
788            namespaces: Some(NamespaceConfig::default()),
789        };
790        let plan = ExecutionPlan::legacy(sandbox.clone(), ResourceLimits::default());
791        assert_eq!(plan.requested_sandbox, sandbox);
792        assert_eq!(plan.effective_sandbox.profile, SandboxProfile::LinuxSandbox);
793    }
794}