Skip to main content

a3s_box_runtime/a3s_runtime_driver/
secret.rs

1//! Transient Runtime Secret material owned by the Box provider boundary.
2
3use std::collections::BTreeSet;
4use std::fmt;
5use std::os::unix::ffi::OsStrExt;
6use std::os::unix::fs::{MetadataExt, PermissionsExt};
7use std::path::{Component, Path, PathBuf};
8use std::sync::Arc;
9
10use a3s_runtime::contract::{RuntimeUnitSpec, SecretReference, SecretTarget};
11use a3s_runtime::{RuntimeError, RuntimeResult};
12use async_trait::async_trait;
13use tokio::io::AsyncWriteExt;
14use zeroize::{Zeroize, Zeroizing};
15
16use a3s_box_core::secret::{
17    validate_environment_variable_name, SecretEnvironmentBinding, SECRET_ENVIRONMENT_MANIFEST,
18    SECRET_GUEST_ROOT,
19};
20
21use crate::local_execution::{TransientRegistryAuthBroker, TransientRegistryAuthLease};
22use crate::{BoxRecord, ImagePuller, ImageReference, ImageStore, RegistryAuth, VmManager};
23
24const MAX_SECRET_BYTES: usize = 1024 * 1024;
25const MAX_REGISTRY_USERNAME_BYTES: usize = 255;
26const MAX_REGISTRY_PASSWORD_BYTES: usize = 16 * 1024;
27const TMPFS_MAGIC: libc::c_long = 0x0102_1994;
28
29/// Provider-neutral Secret resolver supplied by the authenticated caller.
30///
31/// Box deliberately accepts the shared Runtime [`SecretReference`] rather than
32/// a Cloud type. The caller owns authorization and remote transport; Box owns
33/// only transient node-local materialization and cleanup. A reference must
34/// resolve to the same bytes for the lifetime of one Runtime specification;
35/// rotation uses a new reference and therefore a new specification digest.
36#[async_trait]
37pub trait BoxSecretMaterializer: Send + Sync {
38    async fn materialize(
39        &self,
40        reference: &SecretReference,
41    ) -> Result<BoxSecretMaterial, BoxSecretMaterializationError>;
42
43    /// Resolve one registry credential immediately before an uncached pull.
44    async fn materialize_registry_credential(
45        &self,
46        reference: &SecretReference,
47        registry: &str,
48    ) -> Result<BoxRegistryCredential, BoxSecretMaterializationError>;
49}
50
51/// Zeroizing Secret bytes returned across the Box materialization port.
52pub struct BoxSecretMaterial(Zeroizing<Vec<u8>>);
53
54impl BoxSecretMaterial {
55    pub fn new(value: impl Into<Vec<u8>>) -> Result<Self, BoxSecretMaterializationError> {
56        let mut value = value.into();
57        if value.is_empty() || value.len() > MAX_SECRET_BYTES {
58            value.zeroize();
59            return Err(BoxSecretMaterializationError::Rejected(
60                "Secret material must contain between 1 byte and 1 MiB".into(),
61            ));
62        }
63        Ok(Self(Zeroizing::new(value)))
64    }
65
66    pub fn as_bytes(&self) -> &[u8] {
67        self.0.as_slice()
68    }
69}
70
71impl fmt::Debug for BoxSecretMaterial {
72    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73        formatter.write_str("<redacted-box-secret-material>")
74    }
75}
76
77/// Zeroizing Basic-auth material used only for one registry pull boundary.
78pub struct BoxRegistryCredential {
79    username: Zeroizing<String>,
80    password: Zeroizing<String>,
81}
82
83impl BoxRegistryCredential {
84    pub fn new(
85        username: impl Into<String>,
86        password: impl Into<String>,
87    ) -> Result<Self, BoxSecretMaterializationError> {
88        let mut username = username.into();
89        let mut password = password.into();
90        let valid_username =
91            valid_registry_field(&username, MAX_REGISTRY_USERNAME_BYTES) && !username.contains(':');
92        let valid_password = valid_registry_field(&password, MAX_REGISTRY_PASSWORD_BYTES);
93        if !valid_username || !valid_password {
94            username.zeroize();
95            password.zeroize();
96            return Err(BoxSecretMaterializationError::Rejected(
97                "Registry credential material is invalid".into(),
98            ));
99        }
100        Ok(Self {
101            username: Zeroizing::new(username),
102            password: Zeroizing::new(password),
103        })
104    }
105
106    pub fn username(&self) -> &str {
107        self.username.as_str()
108    }
109
110    pub fn password(&self) -> &str {
111        self.password.as_str()
112    }
113}
114
115impl fmt::Debug for BoxRegistryCredential {
116    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117        formatter.write_str("<redacted-box-registry-credential>")
118    }
119}
120
121/// Stable, non-sensitive failure categories for caller-provided resolvers.
122#[derive(Debug, thiserror::Error)]
123pub enum BoxSecretMaterializationError {
124    #[error("Secret reference was rejected: {0}")]
125    Rejected(String),
126    #[error("Secret material is temporarily unavailable: {0}")]
127    Unavailable(String),
128}
129
130/// Non-sensitive mount and guest-init metadata for one transient environment
131/// Secret set. Secret bytes are materialized separately under a private tmpfs.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct BoxSecretEnvironmentProjection {
134    pub volumes: Vec<String>,
135    pub manifest: String,
136}
137
138/// The sole Box-owned transient Secret filesystem boundary.
139///
140/// Callers may create private scopes below one pre-mounted Linux tmpfs, but
141/// Box never creates that backing mount or falls back to disk storage.
142#[derive(Debug, Clone)]
143pub struct BoxTransientSecretStore {
144    root: PathBuf,
145}
146
147impl BoxTransientSecretStore {
148    pub fn new(root: impl Into<PathBuf>) -> Self {
149        Self { root: root.into() }
150    }
151
152    pub fn root(&self) -> &Path {
153        &self.root
154    }
155
156    pub async fn require_ready(&self) -> RuntimeResult<()> {
157        let root = self.root.clone();
158        tokio::task::spawn_blocking(move || validate_secret_root(&root))
159            .await
160            .map_err(|error| {
161                RuntimeError::ProviderUnavailable(format!(
162                    "Box Secret-root validation task failed: {error}"
163                ))
164            })?
165    }
166
167    /// Create or reopen one private namespace inside the same validated tmpfs.
168    pub async fn private_scope(&self, scope: &str) -> RuntimeResult<Self> {
169        if scope.is_empty()
170            || scope.len() > 64
171            || !scope
172                .bytes()
173                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
174        {
175            return Err(RuntimeError::InvalidRequest(
176                "Box Secret scope name is invalid".into(),
177            ));
178        }
179        self.require_ready().await?;
180        let scoped = Self::new(self.root.join(scope));
181        ensure_private_directory(scoped.root()).await?;
182        scoped.require_ready().await?;
183        Ok(scoped)
184    }
185
186    /// Materialize caller-provided environment values and return only the
187    /// non-sensitive mount/manifest projection used by BoxConfig.
188    pub async fn materialize_environment(
189        &self,
190        identity: &str,
191        bindings: Vec<(String, BoxSecretMaterial)>,
192    ) -> RuntimeResult<BoxSecretEnvironmentProjection> {
193        if bindings.is_empty() || bindings.len() > 128 {
194            return Err(RuntimeError::InvalidRequest(
195                "Box transient Secret environment requires between 1 and 128 bindings".into(),
196            ));
197        }
198        let component = digest_component(identity)?;
199        self.require_ready().await?;
200        let directory = self.root.join(component);
201        ensure_private_directory(&directory).await?;
202
203        let mut variables = BTreeSet::new();
204        let mut volumes = Vec::with_capacity(bindings.len());
205        let mut manifest = Vec::with_capacity(bindings.len());
206        for (index, (variable, material)) in bindings.into_iter().enumerate() {
207            if variable == SECRET_ENVIRONMENT_MANIFEST
208                || !variables.insert(variable.clone())
209                || validate_environment_variable_name(&variable).is_err()
210            {
211                self.cleanup_directory(&directory).await?;
212                return Err(RuntimeError::InvalidRequest(
213                    "Box transient Secret environment contains an invalid, duplicate, or reserved target"
214                        .into(),
215                ));
216            }
217            if let Err(error) = validate_environment_material(material.as_bytes()) {
218                self.cleanup_directory(&directory).await?;
219                return Err(error);
220            }
221            let host = directory.join(format!("{index:03}.secret"));
222            if let Err(error) = write_secret_atomically(&host, material.as_bytes(), 0o400).await {
223                self.cleanup_directory(&directory).await?;
224                return Err(error);
225            }
226            let host = match encode_bind_source(&host) {
227                Ok(host) => host,
228                Err(error) => {
229                    self.cleanup_directory(&directory).await?;
230                    return Err(error);
231                }
232            };
233            let guest = format!("{SECRET_GUEST_ROOT}/{component}/{index:03}.secret");
234            let binding = SecretEnvironmentBinding {
235                variable,
236                path: guest.clone(),
237            };
238            if let Err(error) = binding.validate() {
239                self.cleanup_directory(&directory).await?;
240                return Err(RuntimeError::Protocol(error));
241            }
242            volumes.push(format!("{host}:{guest}:ro"));
243            manifest.push(binding);
244        }
245
246        let manifest = match serde_json::to_string(&manifest) {
247            Ok(manifest) => manifest,
248            Err(error) => {
249                self.cleanup_directory(&directory).await?;
250                return Err(RuntimeError::Protocol(format!(
251                    "Box could not encode the non-secret environment binding manifest: {error}"
252                )));
253            }
254        };
255        Ok(BoxSecretEnvironmentProjection { volumes, manifest })
256    }
257
258    /// Mark this already-validated tmpfs as the only source whose single-file
259    /// MicroVM mounts may be staged inside tmpfs rather than copied to disk.
260    pub fn configure_vm(&self, manager: &mut VmManager) -> RuntimeResult<()> {
261        validate_secret_root(&self.root)?;
262        manager.managed_secret_root = Some(self.root.clone());
263        Ok(())
264    }
265
266    pub async fn cleanup_identity(&self, identity: &str) -> RuntimeResult<()> {
267        let directory = self.root.join(digest_component(identity)?);
268        self.cleanup_directory(&directory).await
269    }
270
271    pub fn cleanup_identity_sync(&self, identity: &str) -> RuntimeResult<()> {
272        let directory = self.root.join(digest_component(identity)?);
273        match std::fs::symlink_metadata(&self.root) {
274            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
275            Err(error) => return Err(secret_io_error(error)),
276            Ok(_) => validate_secret_root(&self.root)?,
277        }
278        remove_secret_directory(&self.root, &directory)
279    }
280
281    async fn cleanup_directory(&self, directory: &Path) -> RuntimeResult<()> {
282        let root = self.root.clone();
283        let directory = directory.to_path_buf();
284        tokio::task::spawn_blocking(move || remove_secret_directory(&root, &directory))
285            .await
286            .map_err(|error| {
287                RuntimeError::ProviderUnavailable(format!(
288                    "Box Secret cleanup task failed: {error}"
289                ))
290            })?
291    }
292}
293
294#[derive(Clone)]
295pub(super) struct SecretMaterializationOwner {
296    root: PathBuf,
297    materializer: Option<Arc<dyn BoxSecretMaterializer>>,
298}
299
300impl SecretMaterializationOwner {
301    pub(super) fn new(root: PathBuf, materializer: Option<Arc<dyn BoxSecretMaterializer>>) -> Self {
302        Self { root, materializer }
303    }
304
305    pub(super) fn configured(&self) -> bool {
306        self.materializer.is_some()
307    }
308
309    pub(super) fn require_configured_for(&self, spec: &RuntimeUnitSpec) -> RuntimeResult<()> {
310        if !spec.secrets.is_empty() && self.materializer.is_none() {
311            return Err(RuntimeError::UnsupportedCapabilities(vec![
312                "feature:SecretReferences".into(),
313            ]));
314        }
315        Ok(())
316    }
317
318    pub(super) async fn require_ready(&self) -> RuntimeResult<()> {
319        BoxTransientSecretStore::new(self.root.clone())
320            .require_ready()
321            .await
322    }
323
324    pub(super) async fn materialize_for_start(&self, spec: &RuntimeUnitSpec) -> RuntimeResult<()> {
325        let container_secrets = spec
326            .secrets
327            .iter()
328            .enumerate()
329            .filter(|(_, reference)| !matches!(reference.target, SecretTarget::RegistryCredential))
330            .collect::<Vec<_>>();
331        if container_secrets.is_empty() {
332            return Ok(());
333        }
334        let materializer = self.materializer.as_ref().ok_or_else(|| {
335            RuntimeError::UnsupportedCapabilities(vec!["feature:SecretReferences".into()])
336        })?;
337        self.require_ready().await?;
338        let directory = secret_directory(&self.root, spec)?;
339        ensure_private_directory(&directory).await?;
340
341        for (index, reference) in container_secrets {
342            let material = match materializer.materialize(reference).await {
343                Ok(material) => material,
344                Err(error) => {
345                    self.cleanup_directory(&directory).await?;
346                    return Err(map_materialization_error(error));
347                }
348            };
349            if let Err(error) = validate_material_for_target(material.as_bytes(), &reference.target)
350            {
351                self.cleanup_directory(&directory).await?;
352                return Err(error);
353            }
354            let path = secret_file(&self.root, spec, index)?;
355            if let Err(error) =
356                write_secret_atomically(&path, material.as_bytes(), secret_mode(&reference.target))
357                    .await
358            {
359                self.cleanup_directory(&directory).await?;
360                return Err(error);
361            }
362        }
363        Ok(())
364    }
365
366    pub(super) async fn require_materialized(&self, spec: &RuntimeUnitSpec) -> RuntimeResult<()> {
367        if !spec
368            .secrets
369            .iter()
370            .any(|reference| !matches!(reference.target, SecretTarget::RegistryCredential))
371        {
372            return Ok(());
373        }
374        self.require_ready().await?;
375        for (index, reference) in spec.secrets.iter().enumerate() {
376            if matches!(reference.target, SecretTarget::RegistryCredential) {
377                continue;
378            }
379            let path = secret_file(&self.root, spec, index)?;
380            let expected_mode = secret_mode(&reference.target);
381            tokio::task::spawn_blocking(move || validate_materialized_file(&path, expected_mode))
382                .await
383                .map_err(|error| {
384                    RuntimeError::ProviderUnavailable(format!(
385                        "Box Secret-file validation task failed: {error}"
386                    ))
387                })??;
388        }
389        Ok(())
390    }
391
392    pub(super) async fn resolve_for_redaction(
393        &self,
394        spec: &RuntimeUnitSpec,
395    ) -> RuntimeResult<Vec<BoxSecretMaterial>> {
396        if spec.secrets.is_empty() {
397            return Ok(Vec::new());
398        }
399        let mut materials = Vec::with_capacity(spec.secrets.len());
400        let materializer = self.materializer.as_ref().ok_or_else(|| {
401            RuntimeError::UnsupportedCapabilities(vec!["feature:SecretReferences".into()])
402        })?;
403        for reference in &spec.secrets {
404            if matches!(reference.target, SecretTarget::RegistryCredential) {
405                continue;
406            }
407            let material = materializer
408                .materialize(reference)
409                .await
410                .map_err(map_materialization_error)?;
411            validate_material_for_target(material.as_bytes(), &reference.target)?;
412            materials.push(material);
413        }
414        Ok(materials)
415    }
416
417    pub(super) async fn prepare_registry_auth_for_start(
418        &self,
419        spec: &RuntimeUnitSpec,
420        record: &BoxRecord,
421        home_dir: &Path,
422        broker: Option<&TransientRegistryAuthBroker>,
423    ) -> RuntimeResult<Option<TransientRegistryAuthLease>> {
424        let registry_reference = registry_reference(spec)?;
425        let Some(broker) = broker else {
426            if registry_reference.is_some() {
427                return Err(RuntimeError::UnsupportedCapabilities(vec![
428                    "feature:RegistryCredentials".into(),
429                ]));
430            }
431            return Ok(None);
432        };
433        let metadata = record.managed_execution.as_ref().ok_or_else(|| {
434            RuntimeError::Protocol("Box execution lost managed creation metadata".into())
435        })?;
436        let image = &metadata.request.config.image;
437        let auth = match registry_reference {
438            Some(reference) if !image_is_cached(home_dir, image).await? => {
439                let registry = ImageReference::parse(image)
440                    .map_err(|_| {
441                        RuntimeError::Protocol(
442                            "Box managed artifact has an invalid registry identity".into(),
443                        )
444                    })?
445                    .registry;
446                let materializer = self.materializer.as_ref().ok_or_else(|| {
447                    RuntimeError::UnsupportedCapabilities(vec!["feature:SecretReferences".into()])
448                })?;
449                let credential = materializer
450                    .materialize_registry_credential(reference, &registry)
451                    .await
452                    .map_err(map_materialization_error)?;
453                RegistryAuth::basic(credential.username(), credential.password())
454            }
455            Some(_) | None => RegistryAuth::anonymous(),
456        };
457        broker.bind(&record.id, auth).map(Some).map_err(|error| {
458            RuntimeError::ProviderUnavailable(format!(
459                "Box transient registry credential handoff failed: {error}"
460            ))
461        })
462    }
463
464    pub(super) async fn cleanup_spec(&self, spec: &RuntimeUnitSpec) -> RuntimeResult<()> {
465        let directory = secret_directory(&self.root, spec)?;
466        self.cleanup_directory(&directory).await
467    }
468
469    pub(super) async fn cleanup_digest(&self, digest: &str) -> RuntimeResult<()> {
470        let directory = self.root.join(digest_component(digest)?);
471        self.cleanup_directory(&directory).await
472    }
473
474    async fn cleanup_directory(&self, directory: &Path) -> RuntimeResult<()> {
475        BoxTransientSecretStore::new(self.root.clone())
476            .cleanup_directory(directory)
477            .await
478    }
479}
480
481pub(super) fn secret_file(
482    root: &Path,
483    spec: &RuntimeUnitSpec,
484    index: usize,
485) -> RuntimeResult<PathBuf> {
486    if index >= spec.secrets.len() {
487        return Err(RuntimeError::Protocol(
488            "Box Secret-file index is outside the Runtime specification".into(),
489        ));
490    }
491    Ok(secret_directory(root, spec)?.join(format!("{index:03}.secret")))
492}
493
494pub(super) fn secret_directory(root: &Path, spec: &RuntimeUnitSpec) -> RuntimeResult<PathBuf> {
495    let digest = spec.digest().map_err(RuntimeError::InvalidRequest)?;
496    Ok(root.join(digest_component(&digest)?))
497}
498
499fn digest_component(digest: &str) -> RuntimeResult<&str> {
500    let component = digest.strip_prefix("sha256:").ok_or_else(|| {
501        RuntimeError::Protocol("Box Secret identity requires a SHA-256 specification digest".into())
502    })?;
503    if component.len() != 64 || !component.bytes().all(|byte| byte.is_ascii_hexdigit()) {
504        return Err(RuntimeError::Protocol(
505            "Box Secret identity contains an invalid specification digest".into(),
506        ));
507    }
508    Ok(component)
509}
510
511fn secret_mode(target: &SecretTarget) -> u32 {
512    match target {
513        SecretTarget::File { mode, .. } => *mode,
514        SecretTarget::Environment { .. } | SecretTarget::RegistryCredential => 0o400,
515    }
516}
517
518fn validate_material_for_target(bytes: &[u8], target: &SecretTarget) -> RuntimeResult<()> {
519    if matches!(target, SecretTarget::Environment { .. }) {
520        validate_environment_material(bytes)?;
521    }
522    Ok(())
523}
524
525fn validate_environment_material(bytes: &[u8]) -> RuntimeResult<()> {
526    if std::str::from_utf8(bytes).is_err() || bytes.contains(&0) {
527        return Err(RuntimeError::InvalidRequest(
528            "Box Secret environment material must be non-empty UTF-8 without NUL bytes".into(),
529        ));
530    }
531    Ok(())
532}
533
534fn encode_bind_source(path: &Path) -> RuntimeResult<&str> {
535    path.to_str()
536        .filter(|value| {
537            !value.contains([':', '\0']) && !value.bytes().any(|byte| byte.is_ascii_control())
538        })
539        .ok_or_else(|| {
540            RuntimeError::InvalidRequest(
541                "Box Secret root cannot be encoded as a bind-mount source".into(),
542            )
543        })
544}
545
546fn valid_registry_field(value: &str, maximum: usize) -> bool {
547    !value.is_empty() && value.len() <= maximum && !value.chars().any(char::is_control)
548}
549
550fn registry_reference(spec: &RuntimeUnitSpec) -> RuntimeResult<Option<&SecretReference>> {
551    let mut references = spec
552        .secrets
553        .iter()
554        .filter(|reference| matches!(reference.target, SecretTarget::RegistryCredential));
555    let first = references.next();
556    if references.next().is_some() {
557        return Err(RuntimeError::InvalidRequest(
558            "Box Runtime specification has multiple registry credential Secrets".into(),
559        ));
560    }
561    Ok(first)
562}
563
564async fn image_is_cached(home_dir: &Path, reference: &str) -> RuntimeResult<bool> {
565    let images = home_dir.join("images");
566    let store = ImageStore::new(&images, crate::DEFAULT_IMAGE_CACHE_SIZE).map_err(|error| {
567        RuntimeError::ProviderUnavailable(format!(
568            "Box image cache could not be inspected before registry authorization: {error}"
569        ))
570    })?;
571    Ok(ImagePuller::new(Arc::new(store), RegistryAuth::anonymous())
572        .is_cached(reference)
573        .await)
574}
575
576fn map_materialization_error(error: BoxSecretMaterializationError) -> RuntimeError {
577    match error {
578        BoxSecretMaterializationError::Rejected(_) => RuntimeError::InvalidRequest(
579            "Box Secret reference was rejected by the caller materializer".into(),
580        ),
581        BoxSecretMaterializationError::Unavailable(_) => RuntimeError::ProviderUnavailable(
582            "Box Secret materializer is temporarily unavailable".into(),
583        ),
584    }
585}
586
587async fn ensure_private_directory(path: &Path) -> RuntimeResult<()> {
588    match tokio::fs::create_dir(path).await {
589        Ok(()) => {
590            tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
591                .await
592                .map_err(secret_io_error)?;
593        }
594        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
595        Err(error) => return Err(secret_io_error(error)),
596    }
597    let path = path.to_path_buf();
598    tokio::task::spawn_blocking(move || validate_private_directory(&path))
599        .await
600        .map_err(|error| {
601            RuntimeError::ProviderUnavailable(format!(
602                "Box Secret-directory validation task failed: {error}"
603            ))
604        })?
605}
606
607async fn write_secret_atomically(path: &Path, bytes: &[u8], mode: u32) -> RuntimeResult<()> {
608    let parent = path.parent().ok_or_else(|| {
609        RuntimeError::Protocol("Box Secret file has no materialization directory".into())
610    })?;
611    let temporary = parent.join(format!(
612        ".{}.{}.tmp",
613        path.file_name()
614            .and_then(|value| value.to_str())
615            .unwrap_or("secret"),
616        uuid::Uuid::new_v4().simple()
617    ));
618    let mut file = tokio::fs::OpenOptions::new()
619        .write(true)
620        .create_new(true)
621        .open(&temporary)
622        .await
623        .map_err(secret_io_error)?;
624    let result = async {
625        file.set_permissions(std::fs::Permissions::from_mode(0o600))
626            .await
627            .map_err(secret_io_error)?;
628        file.write_all(bytes).await.map_err(secret_io_error)?;
629        file.flush().await.map_err(secret_io_error)?;
630        file.sync_all().await.map_err(secret_io_error)?;
631        file.set_permissions(std::fs::Permissions::from_mode(mode))
632            .await
633            .map_err(secret_io_error)?;
634        drop(file);
635        tokio::fs::rename(&temporary, path)
636            .await
637            .map_err(secret_io_error)?;
638        sync_directory(parent).await
639    }
640    .await;
641    if result.is_err() {
642        let _ = tokio::fs::remove_file(&temporary).await;
643    }
644    result
645}
646
647async fn sync_directory(path: &Path) -> RuntimeResult<()> {
648    let path = path.to_path_buf();
649    tokio::task::spawn_blocking(move || {
650        std::fs::File::open(path)
651            .and_then(|directory| directory.sync_all())
652            .map_err(secret_io_error)
653    })
654    .await
655    .map_err(|error| {
656        RuntimeError::ProviderUnavailable(format!("Box Secret sync task failed: {error}"))
657    })?
658}
659
660fn validate_secret_root(root: &Path) -> RuntimeResult<()> {
661    validate_absolute_normalized(root, "Secret root")?;
662    let metadata = std::fs::symlink_metadata(root).map_err(secret_io_error)?;
663    if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
664        return Err(RuntimeError::ProviderUnavailable(
665            "Box Secret root is not a plain directory".into(),
666        ));
667    }
668    let canonical = root.canonicalize().map_err(secret_io_error)?;
669    if canonical != root {
670        return Err(RuntimeError::ProviderUnavailable(
671            "Box Secret root must already be canonical and contain no links".into(),
672        ));
673    }
674    if metadata.uid() != unsafe { libc::geteuid() }
675        || !matches!(metadata.mode() & 0o7777, 0o700 | 0o710)
676    {
677        return Err(RuntimeError::ProviderUnavailable(
678            "Box Secret root must be provider-owned, non-listable, and inaccessible to other users"
679                .into(),
680        ));
681    }
682    let path = std::ffi::CString::new(root.as_os_str().as_bytes())
683        .map_err(|_| RuntimeError::InvalidRequest("Box Secret root contains a NUL byte".into()))?;
684    let mut status = std::mem::MaybeUninit::<libc::statfs>::uninit();
685    if unsafe { libc::statfs(path.as_ptr(), status.as_mut_ptr()) } != 0 {
686        return Err(secret_io_error(std::io::Error::last_os_error()));
687    }
688    let status = unsafe { status.assume_init() };
689    if status.f_type as libc::c_long != TMPFS_MAGIC {
690        return Err(RuntimeError::ProviderUnavailable(
691            "Box Secret root must be a Linux tmpfs mount".into(),
692        ));
693    }
694    Ok(())
695}
696
697fn validate_private_directory(path: &Path) -> RuntimeResult<()> {
698    let metadata = std::fs::symlink_metadata(path).map_err(secret_io_error)?;
699    if !metadata.file_type().is_dir()
700        || metadata.file_type().is_symlink()
701        || metadata.uid() != unsafe { libc::geteuid() }
702        || !matches!(metadata.mode() & 0o7777, 0o700 | 0o710)
703    {
704        return Err(RuntimeError::ProviderUnavailable(
705            "Box Secret materialization directory is not a private provider-owned directory".into(),
706        ));
707    }
708    Ok(())
709}
710
711fn validate_materialized_file(path: &Path, expected_mode: u32) -> RuntimeResult<()> {
712    let metadata = std::fs::symlink_metadata(path).map_err(secret_io_error)?;
713    if !metadata.file_type().is_file()
714        || metadata.file_type().is_symlink()
715        || metadata.nlink() != 1
716        || metadata.len() == 0
717        || metadata.len() > MAX_SECRET_BYTES as u64
718        || metadata.mode() & 0o777 != expected_mode
719    {
720        return Err(RuntimeError::ProviderUnavailable(
721            "Box Secret material is missing or violates its regular-file, size, or mode contract"
722                .into(),
723        ));
724    }
725    Ok(())
726}
727
728fn remove_secret_directory(root: &Path, directory: &Path) -> RuntimeResult<()> {
729    validate_absolute_normalized(root, "Secret root")?;
730    if directory.parent() != Some(root) {
731        return Err(RuntimeError::Protocol(
732            "Box Secret cleanup target escaped its configured root".into(),
733        ));
734    }
735    match std::fs::symlink_metadata(directory) {
736        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
737        Err(error) => return Err(secret_io_error(error)),
738        Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => {}
739        Ok(_) => {
740            return Err(RuntimeError::ProviderUnavailable(
741                "Box Secret cleanup target is not a plain directory".into(),
742            ))
743        }
744    }
745    std::fs::remove_dir_all(directory).map_err(secret_io_error)?;
746    std::fs::File::open(root)
747        .and_then(|directory| directory.sync_all())
748        .map_err(secret_io_error)
749}
750
751fn validate_absolute_normalized(path: &Path, label: &str) -> RuntimeResult<()> {
752    if !path.is_absolute()
753        || path.components().any(|component| {
754            matches!(
755                component,
756                Component::CurDir | Component::ParentDir | Component::Prefix(_)
757            )
758        })
759    {
760        return Err(RuntimeError::InvalidRequest(format!(
761            "Box {label} must be an absolute normalized Linux path"
762        )));
763    }
764    Ok(())
765}
766
767fn secret_io_error(error: std::io::Error) -> RuntimeError {
768    RuntimeError::ProviderUnavailable(format!("Box Secret filesystem operation failed: {error}"))
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    #[cfg(unix)]
776    use std::os::unix::fs::PermissionsExt;
777
778    #[test]
779    fn material_debug_output_never_contains_plaintext() {
780        let material = BoxSecretMaterial::new(b"box-secret-fixture".to_vec()).unwrap();
781        assert_eq!(format!("{material:?}"), "<redacted-box-secret-material>");
782    }
783
784    #[test]
785    fn registry_credential_is_bounded_and_redacted() {
786        let credential = BoxRegistryCredential::new("registry-user", "registry-password").unwrap();
787        assert_eq!(credential.username(), "registry-user");
788        assert_eq!(credential.password(), "registry-password");
789        assert_eq!(
790            format!("{credential:?}"),
791            "<redacted-box-registry-credential>"
792        );
793
794        for (username, password) in [
795            ("", "password"),
796            ("user:name", "password"),
797            ("username", ""),
798            ("username", "password\nleak"),
799            ("username", "password\tleak"),
800        ] {
801            assert!(BoxRegistryCredential::new(username, password).is_err());
802        }
803    }
804
805    #[tokio::test]
806    async fn transient_store_projects_metadata_without_persisting_plaintext() {
807        let mount = tempfile::tempdir_in("/dev/shm")
808            .expect("Linux Secret tests require the standard /dev/shm tmpfs");
809        let root = mount.path().join("runtime-secrets");
810        std::fs::create_dir(&root).unwrap();
811        std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
812        let store = BoxTransientSecretStore::new(root)
813            .private_scope("compose")
814            .await
815            .unwrap();
816        let identity = format!("sha256:{}", "a".repeat(64));
817        let plaintext = "box-compose-secret-fixture";
818
819        let projection = store
820            .materialize_environment(
821                &identity,
822                vec![(
823                    "DATABASE_URL".into(),
824                    BoxSecretMaterial::new(plaintext.as_bytes().to_vec()).unwrap(),
825                )],
826            )
827            .await
828            .unwrap();
829
830        assert!(!format!("{projection:?}").contains(plaintext));
831        assert!(!projection.manifest.contains(plaintext));
832        let bindings: Vec<SecretEnvironmentBinding> =
833            serde_json::from_str(&projection.manifest).unwrap();
834        assert_eq!(bindings[0].variable, "DATABASE_URL");
835        let file = store.root().join("a".repeat(64)).join("000.secret");
836        assert_eq!(std::fs::read(&file).unwrap(), plaintext.as_bytes());
837        assert_eq!(std::fs::metadata(&file).unwrap().mode() & 0o777, 0o400);
838
839        store.cleanup_identity(&identity).await.unwrap();
840        assert!(!file.exists());
841    }
842
843    #[cfg(unix)]
844    #[test]
845    fn private_directory_allows_only_sandbox_search_access() {
846        let directory = tempfile::tempdir().unwrap();
847        for mode in [0o700, 0o710] {
848            std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(mode))
849                .unwrap();
850            validate_private_directory(directory.path()).unwrap();
851        }
852
853        for mode in [0o711, 0o720, 0o740, 0o770] {
854            std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(mode))
855                .unwrap();
856            assert!(validate_private_directory(directory.path()).is_err());
857        }
858    }
859}