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}