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