a3s_box_runtime/a3s_runtime_driver/
artifact.rs1use std::path::{Component, Path, PathBuf};
4use std::sync::Arc;
5
6use a3s_box_core::CreateExecutionRequest;
7use a3s_runtime::contract::{
8 RuntimeMount, RuntimeMountSource, RuntimeOutputArtifact, RuntimeOutputSpec, RuntimeUnitSpec,
9 SecretTarget,
10};
11use a3s_runtime::{RuntimeError, RuntimeResult};
12use async_trait::async_trait;
13
14use crate::BoxRecord;
15
16use super::volume_storage::{
17 cleanup_output_volumes, require_output_volume, reset_output_volumes, resolve_output_volume,
18 resolve_persistent_volume,
19};
20
21#[async_trait]
28pub trait BoxArtifactPort: Send + Sync {
29 async fn mount_path(
31 &self,
32 spec: &RuntimeUnitSpec,
33 mount: &RuntimeMount,
34 ) -> Result<PathBuf, BoxArtifactPortError>;
35
36 async fn capture_output(
38 &self,
39 spec: &RuntimeUnitSpec,
40 output: &RuntimeOutputSpec,
41 source: &Path,
42 ) -> Result<RuntimeOutputArtifact, BoxArtifactPortError>;
43
44 async fn cleanup_spec(&self, spec_digest: &str) -> Result<(), BoxArtifactPortError>;
46}
47
48#[derive(Debug, thiserror::Error)]
50pub enum BoxArtifactPortError {
51 #[error("Artifact request was rejected: {0}")]
52 Rejected(String),
53 #[error("Artifact boundary is temporarily unavailable: {0}")]
54 Unavailable(String),
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub(super) struct RuntimeStoragePlan {
59 spec_digest: String,
60 volumes: Vec<String>,
61 volume_names: Vec<String>,
62}
63
64impl RuntimeStoragePlan {
65 pub(super) fn empty(spec: &RuntimeUnitSpec) -> RuntimeResult<Self> {
66 Ok(Self {
67 spec_digest: spec.digest().map_err(RuntimeError::InvalidRequest)?,
68 volumes: Vec::new(),
69 volume_names: Vec::new(),
70 })
71 }
72
73 pub(super) fn from_request(
74 spec: &RuntimeUnitSpec,
75 request: &CreateExecutionRequest,
76 ) -> RuntimeResult<Self> {
77 let secret_mounts = spec
78 .secrets
79 .iter()
80 .filter(|secret| !matches!(secret.target, SecretTarget::RegistryCredential))
81 .count();
82 let storage_mounts = request
83 .config
84 .volumes
85 .len()
86 .checked_sub(secret_mounts)
87 .ok_or_else(|| {
88 RuntimeError::Protocol("Box creation request omitted a Runtime Secret mount".into())
89 })?;
90 Ok(Self {
91 spec_digest: spec.digest().map_err(RuntimeError::Protocol)?,
92 volumes: request.config.volumes[..storage_mounts].to_vec(),
93 volume_names: request.policy.volume_names.clone(),
94 })
95 }
96
97 pub(super) fn volumes(&self) -> &[String] {
98 &self.volumes
99 }
100
101 pub(super) fn volume_names(&self) -> &[String] {
102 &self.volume_names
103 }
104
105 pub(super) fn validate_for(&self, spec: &RuntimeUnitSpec) -> RuntimeResult<()> {
106 let digest = spec.digest().map_err(RuntimeError::InvalidRequest)?;
107 if self.spec_digest != digest {
108 return Err(RuntimeError::Protocol(
109 "Box Runtime storage plan belongs to another specification".into(),
110 ));
111 }
112 Ok(())
113 }
114}
115
116#[derive(Clone)]
117pub(super) struct ArtifactStorageOwner {
118 home_dir: PathBuf,
119 port: Option<Arc<dyn BoxArtifactPort>>,
120}
121
122impl ArtifactStorageOwner {
123 pub(super) fn new(home_dir: PathBuf, port: Option<Arc<dyn BoxArtifactPort>>) -> Self {
124 Self { home_dir, port }
125 }
126
127 pub(super) fn artifact_configured(&self) -> bool {
128 self.port.is_some()
129 }
130
131 pub(super) fn require_configured_for(&self, spec: &RuntimeUnitSpec) -> RuntimeResult<()> {
132 let needs_artifacts = spec
133 .mounts
134 .iter()
135 .any(|mount| matches!(mount.source, RuntimeMountSource::Artifact { .. }));
136 let mut missing = Vec::new();
137 if needs_artifacts && self.port.is_none() {
138 missing.push("mount_kind:Artifact".into());
139 }
140 if !spec.outputs.is_empty() && self.port.is_none() {
141 missing.push("feature:OutputArtifacts".into());
142 }
143 if missing.is_empty() {
144 Ok(())
145 } else {
146 Err(RuntimeError::UnsupportedCapabilities(missing))
147 }
148 }
149
150 pub(super) async fn prepare_plan(
151 &self,
152 spec: &RuntimeUnitSpec,
153 ) -> RuntimeResult<RuntimeStoragePlan> {
154 self.build_plan(spec, true).await
155 }
156
157 pub(super) async fn require_plan(
158 &self,
159 spec: &RuntimeUnitSpec,
160 ) -> RuntimeResult<RuntimeStoragePlan> {
161 self.build_plan(spec, false).await
162 }
163
164 pub(super) async fn validate_record(
165 &self,
166 spec: &RuntimeUnitSpec,
167 record: &BoxRecord,
168 ) -> RuntimeResult<()> {
169 let request = &record
170 .managed_execution
171 .as_ref()
172 .ok_or_else(|| RuntimeError::Protocol("Box execution lost metadata".into()))?
173 .request;
174 let actual = RuntimeStoragePlan::from_request(spec, request)?;
175 let expected = self.require_plan(spec).await?;
176 if actual != expected {
177 return Err(RuntimeError::Protocol(format!(
178 "Box execution {} storage bindings do not match the Runtime specification",
179 record.id
180 )));
181 }
182 Ok(())
183 }
184
185 pub(super) async fn reset_outputs_for_start(
186 &self,
187 spec: &RuntimeUnitSpec,
188 ) -> RuntimeResult<()> {
189 if spec.outputs.is_empty() {
190 return Ok(());
191 }
192 let home_dir = self.home_dir.clone();
193 let spec = spec.clone();
194 tokio::task::spawn_blocking(move || reset_output_volumes(&home_dir, &spec))
195 .await
196 .map_err(|error| {
197 RuntimeError::ProviderUnavailable(format!(
198 "Box Task-output reset task failed: {error}"
199 ))
200 })?
201 }
202
203 pub(super) async fn capture_outputs(
204 &self,
205 spec: &RuntimeUnitSpec,
206 ) -> RuntimeResult<Vec<RuntimeOutputArtifact>> {
207 if spec.outputs.is_empty() {
208 return Ok(Vec::new());
209 }
210 let port = self.port.as_ref().ok_or_else(|| {
211 RuntimeError::UnsupportedCapabilities(vec!["feature:OutputArtifacts".into()])
212 })?;
213 let home_dir = self.home_dir.clone();
214 let spec_for_paths = spec.clone();
215 let sources = tokio::task::spawn_blocking(move || {
216 spec_for_paths
217 .outputs
218 .iter()
219 .map(|output| require_output_volume(&home_dir, &spec_for_paths, output))
220 .collect::<RuntimeResult<Vec<_>>>()
221 })
222 .await
223 .map_err(|error| {
224 RuntimeError::ProviderUnavailable(format!(
225 "Box Task-output lookup task failed: {error}"
226 ))
227 })??;
228
229 let mut captured = Vec::with_capacity(spec.outputs.len());
230 for (output, source) in spec.outputs.iter().zip(sources) {
231 let artifact = port
232 .capture_output(spec, output, &source)
233 .await
234 .map_err(map_port_error)?;
235 validate_captured_output(output, &artifact)?;
236 captured.push(artifact);
237 }
238 Ok(captured)
239 }
240
241 pub(super) async fn cleanup_spec(&self, spec: &RuntimeUnitSpec) -> RuntimeResult<()> {
242 let digest = spec.digest().map_err(RuntimeError::InvalidRequest)?;
243 self.cleanup_digest(&digest).await
244 }
245
246 pub(super) async fn cleanup_digest(&self, digest: &str) -> RuntimeResult<()> {
247 validate_digest(digest)?;
248 if let Some(port) = &self.port {
249 port.cleanup_spec(digest).await.map_err(map_port_error)?;
250 }
251 let home_dir = self.home_dir.clone();
252 let digest = digest.to_owned();
253 tokio::task::spawn_blocking(move || cleanup_output_volumes(&home_dir, &digest))
254 .await
255 .map_err(|error| {
256 RuntimeError::ProviderUnavailable(format!(
257 "Box Task-output cleanup task failed: {error}"
258 ))
259 })?
260 }
261
262 async fn build_plan(
263 &self,
264 spec: &RuntimeUnitSpec,
265 create_volumes: bool,
266 ) -> RuntimeResult<RuntimeStoragePlan> {
267 spec.validate().map_err(RuntimeError::InvalidRequest)?;
268 self.require_configured_for(spec)?;
269 validate_storage_targets(spec)?;
270
271 let mut plan = RuntimeStoragePlan::empty(spec)?;
272 for mount in &spec.mounts {
273 match &mount.source {
274 RuntimeMountSource::Artifact { .. } => {
275 if !mount.read_only {
276 return Err(RuntimeError::InvalidRequest(
277 "Box Runtime Artifact mounts must be read-only".into(),
278 ));
279 }
280 let port = self.port.as_ref().ok_or_else(|| {
281 RuntimeError::UnsupportedCapabilities(vec!["mount_kind:Artifact".into()])
282 })?;
283 let source = port.mount_path(spec, mount).await.map_err(map_port_error)?;
284 let source = validate_artifact_mount_path(source).await?;
285 plan.volumes.push(bind_mount(&source, &mount.target, true)?);
286 }
287 RuntimeMountSource::Volume { volume_id } => {
288 let home_dir = self.home_dir.clone();
289 let volume_id = volume_id.clone();
290 let resolved = tokio::task::spawn_blocking(move || {
291 resolve_persistent_volume(&home_dir, &volume_id, create_volumes)
292 })
293 .await
294 .map_err(|error| {
295 RuntimeError::ProviderUnavailable(format!(
296 "Box persistent-Volume lookup task failed: {error}"
297 ))
298 })??;
299 plan.volumes
300 .push(bind_mount(&resolved.path, &mount.target, mount.read_only)?);
301 plan.volume_names.push(resolved.name);
302 }
303 RuntimeMountSource::Tmpfs { .. } => {}
304 }
305 }
306
307 for output in &spec.outputs {
308 let home_dir = self.home_dir.clone();
309 let spec = spec.clone();
310 let output = output.clone();
311 let output_for_lookup = output.clone();
312 let resolved = tokio::task::spawn_blocking(move || {
313 resolve_output_volume(&home_dir, &spec, &output_for_lookup, create_volumes)
314 })
315 .await
316 .map_err(|error| {
317 RuntimeError::ProviderUnavailable(format!(
318 "Box Task-output Volume lookup task failed: {error}"
319 ))
320 })??;
321 plan.volumes
322 .push(bind_mount(&resolved.path, &output.path, false)?);
323 plan.volume_names.push(resolved.name);
324 }
325 Ok(plan)
326 }
327}
328
329async fn validate_artifact_mount_path(path: PathBuf) -> RuntimeResult<PathBuf> {
330 let display = path.to_str().ok_or_else(|| {
331 RuntimeError::InvalidRequest("Box Artifact mount path is not UTF-8".into())
332 })?;
333 if !path.is_absolute()
334 || display.contains([':', '\0'])
335 || display.bytes().any(|byte| byte.is_ascii_control())
336 || path.components().any(|component| {
337 matches!(
338 component,
339 Component::CurDir | Component::ParentDir | Component::Prefix(_)
340 )
341 })
342 {
343 return Err(RuntimeError::InvalidRequest(
344 "Box Artifact mount path must be an encodable normalized absolute path".into(),
345 ));
346 }
347 let metadata = tokio::fs::symlink_metadata(&path)
348 .await
349 .map_err(artifact_io_error)?;
350 let canonical = tokio::fs::canonicalize(&path)
351 .await
352 .map_err(artifact_io_error)?;
353 if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() || canonical != path {
354 return Err(RuntimeError::InvalidRequest(
355 "Box Artifact mount source must be a canonical plain directory".into(),
356 ));
357 }
358 Ok(path)
359}
360
361fn bind_mount(source: &Path, target: &str, read_only: bool) -> RuntimeResult<String> {
362 let source = source.to_str().ok_or_else(|| {
363 RuntimeError::InvalidRequest("Box Runtime mount source is not UTF-8".into())
364 })?;
365 if source.contains([':', '\0']) || source.bytes().any(|byte| byte.is_ascii_control()) {
366 return Err(RuntimeError::InvalidRequest(
367 "Box Runtime mount source cannot be encoded".into(),
368 ));
369 }
370 Ok(format!(
371 "{source}:{target}:{}",
372 if read_only { "ro" } else { "rw" }
373 ))
374}
375
376fn validate_storage_targets(spec: &RuntimeUnitSpec) -> RuntimeResult<()> {
377 let mut targets = Vec::new();
378 for mount in &spec.mounts {
379 validate_target(
380 &mount.target,
381 matches!(mount.source, RuntimeMountSource::Tmpfs { .. }),
382 )?;
383 targets.push(PathBuf::from(&mount.target));
384 }
385 for output in &spec.outputs {
386 validate_target(&output.path, false)?;
387 targets.push(PathBuf::from(&output.path));
388 }
389 for secret in &spec.secrets {
390 if let SecretTarget::File { path, .. } = &secret.target {
391 targets.push(PathBuf::from(path));
392 }
393 }
394 for (index, target) in targets.iter().enumerate() {
395 if targets
396 .iter()
397 .skip(index + 1)
398 .any(|other| paths_overlap(target, other))
399 {
400 return Err(RuntimeError::InvalidRequest(
401 "Box Runtime mount, output, and Secret targets must not overlap".into(),
402 ));
403 }
404 }
405 Ok(())
406}
407
408fn validate_target(target: &str, tmpfs: bool) -> RuntimeResult<()> {
409 let path = Path::new(target);
410 let normalized = target.strip_prefix('/').is_some_and(|relative| {
411 !relative.is_empty()
412 && !relative.ends_with('/')
413 && relative
414 .split('/')
415 .all(|segment| !segment.is_empty() && !matches!(segment, "." | ".."))
416 });
417 let is_or_below = |root: &Path| {
418 path == root
419 || path
420 .strip_prefix(root)
421 .is_ok_and(|suffix| !suffix.as_os_str().is_empty())
422 };
423 let protected = path == Path::new("/")
424 || is_or_below(Path::new("/proc"))
425 || is_or_below(Path::new("/sys"))
426 || (is_or_below(Path::new("/dev")) && !(tmpfs && path == Path::new("/dev/shm")))
427 || is_or_below(Path::new("/run/a3s-box"))
428 || is_or_below(Path::new("/.a3s-box-secrets"));
429 if !normalized
430 || target.contains([':', '\0'])
431 || target.bytes().any(|byte| byte.is_ascii_control())
432 || protected
433 {
434 return Err(RuntimeError::InvalidRequest(format!(
435 "Box Runtime mount target must be an encodable normalized unprotected absolute path: {target:?}"
436 )));
437 }
438 Ok(())
439}
440
441fn paths_overlap(left: &Path, right: &Path) -> bool {
442 left == right || left.starts_with(right) || right.starts_with(left)
443}
444
445fn validate_captured_output(
446 expected: &RuntimeOutputSpec,
447 actual: &RuntimeOutputArtifact,
448) -> RuntimeResult<()> {
449 actual.artifact.validate().map_err(RuntimeError::Protocol)?;
450 if actual.name != expected.name
451 || actual.artifact.media_type != expected.media_type
452 || actual.size_bytes == 0
453 || actual.size_bytes > expected.max_bytes
454 {
455 return Err(RuntimeError::Protocol(
456 "Box Artifact port returned an output outside its Runtime declaration".into(),
457 ));
458 }
459 Ok(())
460}
461
462fn validate_digest(digest: &str) -> RuntimeResult<()> {
463 let valid = digest.strip_prefix("sha256:").is_some_and(|hex| {
464 hex.len() == 64
465 && hex
466 .bytes()
467 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
468 });
469 if valid {
470 Ok(())
471 } else {
472 Err(RuntimeError::Protocol(
473 "Box Artifact cleanup requires a lowercase SHA-256 spec digest".into(),
474 ))
475 }
476}
477
478fn map_port_error(error: BoxArtifactPortError) -> RuntimeError {
479 match error {
480 BoxArtifactPortError::Rejected(_) => RuntimeError::InvalidRequest(
481 "Box Artifact request was rejected by the caller boundary".into(),
482 ),
483 BoxArtifactPortError::Unavailable(_) => RuntimeError::ProviderUnavailable(
484 "Box Artifact boundary is temporarily unavailable".into(),
485 ),
486 }
487}
488
489fn artifact_io_error(error: std::io::Error) -> RuntimeError {
490 RuntimeError::ProviderUnavailable(format!("Box Artifact mount I/O failed: {error}"))
491}