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