1use 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#[async_trait]
38pub trait BoxSecretMaterializer: Send + Sync {
39 async fn materialize(
40 &self,
41 reference: &SecretReference,
42 ) -> Result<BoxSecretMaterial, BoxSecretMaterializationError>;
43
44 async fn materialize_registry_credential(
46 &self,
47 reference: &SecretReference,
48 registry: &str,
49 ) -> Result<BoxRegistryCredential, BoxSecretMaterializationError>;
50}
51
52pub 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
78pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct BoxSecretEnvironmentProjection {
135 pub volumes: Vec<String>,
136 pub manifest: String,
137}
138
139#[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 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 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 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 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, ®istry)
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 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 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, ®istry)
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}