Skip to main content

a3s_box_runtime/a3s_runtime_driver/
artifact.rs

1//! Runtime Artifact, persistent Volume, and Task-output storage bindings.
2
3use 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/// Caller-owned Artifact boundary composed into the shared Box Runtime driver.
22///
23/// Box accepts only shared A3S Runtime contract types. The caller remains the
24/// authority for authenticated Artifact transport, admission, hashing, and
25/// publication. Box owns only local mount wiring, execution fencing, and the
26/// lifecycle of its existing VolumeStore entries.
27#[async_trait]
28pub trait BoxArtifactPort: Send + Sync {
29    /// Return the already materialized, read-only host directory for one input.
30    async fn mount_path(
31        &self,
32        spec: &RuntimeUnitSpec,
33        mount: &RuntimeMount,
34    ) -> Result<PathBuf, BoxArtifactPortError>;
35
36    /// Admit one quiescent Task-output directory into the caller's Artifact store.
37    async fn capture_output(
38        &self,
39        spec: &RuntimeUnitSpec,
40        output: &RuntimeOutputSpec,
41        source: &Path,
42    ) -> Result<RuntimeOutputArtifact, BoxArtifactPortError>;
43
44    /// Release caller-owned materialization views for one immutable spec.
45    async fn cleanup_spec(&self, spec_digest: &str) -> Result<(), BoxArtifactPortError>;
46}
47
48/// Stable, non-sensitive failure categories for caller-provided Artifact ports.
49#[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}