1#[cfg(target_os = "linux")]
4use std::collections::HashSet;
5use std::io::Read;
6#[cfg(target_os = "linux")]
7use std::io::Write;
8#[cfg(any(target_os = "linux", test))]
9use std::path::Component;
10use std::path::{Path, PathBuf};
11
12use a3s_box_core::error::{BoxError, Result};
13#[cfg(target_os = "linux")]
14use a3s_box_core::rootfs_metadata::runtime_managed_rootfs_mode;
15#[cfg(any(target_os = "linux", test))]
16use a3s_box_core::rootfs_metadata::{RootfsEntryKind, RootfsMetadataEntry};
17use a3s_box_core::rootfs_metadata::{
18 RootfsMetadataManifest, IMAGE_ROOTFS_METADATA_PATH, PREVIOUS_ROOTFS_METADATA_PATH,
19 ROOTFS_METADATA_PATH,
20};
21#[cfg(any(target_os = "linux", test))]
22use base64::Engine;
23
24use super::capability::{validate_id_mapping_plan, IdMapping, SandboxIdMappingPlan};
25
26const MAX_ROOTFS_METADATA_BYTES: u64 = 64 * 1024 * 1024;
27#[cfg(target_os = "linux")]
28const ROOTFS_ID_MAPPING_FILE: &str = "sandbox/rootfs-id-mappings.json";
29#[cfg(target_os = "linux")]
30const MAX_ROOTFS_ID_MAPPING_BYTES: u64 = 64 * 1024;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct RootfsIdentityRequirements {
35 pub maximum_uid: u32,
36 pub maximum_gid: u32,
37 pub manifest_path: PathBuf,
38}
39
40#[cfg(target_os = "linux")]
44pub(crate) fn persist_rootfs_id_mappings(
45 box_dir: &Path,
46 plan: &SandboxIdMappingPlan,
47) -> Result<()> {
48 validate_id_mapping_plan(plan)?;
49 let destination = box_dir.join(ROOTFS_ID_MAPPING_FILE);
50 let parent = destination.parent().ok_or_else(|| {
51 BoxError::ConfigError(format!(
52 "Sandbox rootfs mapping path has no parent: {}",
53 destination.display()
54 ))
55 })?;
56 std::fs::create_dir_all(parent).map_err(BoxError::IoError)?;
57 let mut encoded = serde_json::to_vec_pretty(plan).map_err(|error| {
58 BoxError::SerializationError(format!(
59 "Failed to encode Sandbox rootfs ID mappings: {error}"
60 ))
61 })?;
62 encoded.push(b'\n');
63 let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(BoxError::IoError)?;
64 temporary.write_all(&encoded).map_err(BoxError::IoError)?;
65 temporary.as_file().sync_all().map_err(BoxError::IoError)?;
66 temporary
67 .persist(&destination)
68 .map_err(|error| BoxError::IoError(error.error))?;
69 if let Ok(directory) = std::fs::File::open(parent) {
70 let _ = directory.sync_all();
71 }
72 Ok(())
73}
74
75#[cfg(target_os = "linux")]
79pub(crate) fn load_rootfs_id_mappings(box_dir: &Path) -> Result<Option<SandboxIdMappingPlan>> {
80 let path = box_dir.join(ROOTFS_ID_MAPPING_FILE);
81 let metadata = match std::fs::symlink_metadata(&path) {
82 Ok(metadata) => metadata,
83 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
84 Err(error) => return Err(BoxError::IoError(error)),
85 };
86 if !metadata.file_type().is_file() || metadata.len() > MAX_ROOTFS_ID_MAPPING_BYTES {
87 return Err(BoxError::ConfigError(format!(
88 "Sandbox rootfs ID mapping artifact is not a bounded regular file: {}",
89 path.display()
90 )));
91 }
92 let mut encoded = Vec::with_capacity(metadata.len() as usize);
93 std::fs::File::open(&path)
94 .map_err(BoxError::IoError)?
95 .take(MAX_ROOTFS_ID_MAPPING_BYTES + 1)
96 .read_to_end(&mut encoded)
97 .map_err(BoxError::IoError)?;
98 if encoded.len() as u64 > MAX_ROOTFS_ID_MAPPING_BYTES {
99 return Err(BoxError::ConfigError(format!(
100 "Sandbox rootfs ID mapping artifact exceeds {} bytes: {}",
101 MAX_ROOTFS_ID_MAPPING_BYTES,
102 path.display()
103 )));
104 }
105 let plan: SandboxIdMappingPlan = serde_json::from_slice(&encoded).map_err(|error| {
106 BoxError::SerializationError(format!(
107 "Failed to decode Sandbox rootfs ID mappings {}: {error}",
108 path.display()
109 ))
110 })?;
111 validate_id_mapping_plan(&plan)?;
112 Ok(Some(plan))
113}
114
115#[cfg(target_os = "linux")]
123pub(crate) fn recover_interrupted_rootfs_metadata(box_dir: &Path, root: &Path) -> Result<bool> {
124 for path in [
125 root.join(ROOTFS_METADATA_PATH.trim_start_matches('/')),
126 root.join(PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/')),
127 root.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')),
128 ] {
129 if load_manifest_if_present(&path)?.is_some() {
130 return Ok(false);
131 }
132 }
133
134 let Some(plan) = load_rootfs_id_mappings(box_dir)? else {
135 return Ok(false);
136 };
137 let manifest = capture_rootfs_metadata(root, &plan)?;
138 publish_replay_rootfs_metadata(root, &manifest)?;
139 Ok(true)
140}
141
142#[cfg(target_os = "linux")]
143fn publish_replay_rootfs_metadata(root: &Path, manifest: &RootfsMetadataManifest) -> Result<()> {
144 manifest.validate().map_err(BoxError::OciImageError)?;
145 let encoded = serde_json::to_vec(manifest).map_err(|error| {
146 BoxError::SerializationError(format!(
147 "Failed to encode recovered Sandbox rootfs metadata: {error}"
148 ))
149 })?;
150 if encoded.len() as u64 > MAX_ROOTFS_METADATA_BYTES {
151 return Err(BoxError::OciImageError(format!(
152 "Recovered Sandbox rootfs metadata exceeds the {} byte limit",
153 MAX_ROOTFS_METADATA_BYTES
154 )));
155 }
156
157 let destination = root.join(PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/'));
158 let mut temporary = tempfile::NamedTempFile::new_in(root).map_err(BoxError::IoError)?;
159 temporary.write_all(&encoded).map_err(BoxError::IoError)?;
160 temporary.as_file().sync_all().map_err(BoxError::IoError)?;
161 temporary
162 .persist_noclobber(&destination)
163 .map_err(|error| BoxError::IoError(error.error))?;
164 std::fs::File::open(root)
165 .and_then(|directory| directory.sync_all())
166 .map_err(BoxError::IoError)?;
167 Ok(())
168}
169
170pub fn mapped_root_ids(plan: &SandboxIdMappingPlan) -> Result<(u32, u32)> {
172 Ok((
173 map_container_id(&plan.uid_mappings, 0, "UID")?,
174 map_container_id(&plan.gid_mappings, 0, "GID")?,
175 ))
176}
177
178#[cfg(target_os = "linux")]
181pub fn prepare_managed_mount_source(path: &Path, plan: &SandboxIdMappingPlan) -> Result<()> {
182 ensure_no_nested_mounts(path)?;
183 let (root_uid, root_gid) = mapped_root_ids(plan)?;
184 prepare_managed_tree(path, plan, root_uid, root_gid)
185}
186
187#[cfg(not(target_os = "linux"))]
188pub fn prepare_managed_mount_source(_path: &Path, _plan: &SandboxIdMappingPlan) -> Result<()> {
189 Err(BoxError::ConfigError(
190 "Sandbox mount ownership preparation requires Linux".to_string(),
191 ))
192}
193
194#[cfg(unix)]
198pub fn validate_external_mount_access(
199 path: &Path,
200 plan: &SandboxIdMappingPlan,
201 read_only: bool,
202) -> Result<()> {
203 let metadata = std::fs::metadata(path).map_err(BoxError::IoError)?;
204 validate_external_mount_root_metadata(path, &metadata, plan, read_only)?;
205
206 let (uid, gid) = mapped_root_ids(plan)?;
207 for parent in path.ancestors().skip(1) {
208 let metadata = std::fs::metadata(parent).map_err(|error| {
209 BoxError::ConfigError(format!(
210 "External Sandbox mount {} cannot resolve parent {} for mapped container root {uid}:{gid}: {error}",
211 path.display(),
212 parent.display()
213 ))
214 })?;
215 if !metadata.is_dir() || identity_permission_bits(&metadata, uid, gid) & 0o1 == 0 {
216 return Err(BoxError::ConfigError(format!(
217 "External Sandbox mount {} has a parent {} that is not searchable by mapped container root {uid}:{gid}; use a read-only Box attachment or grant execute-only traversal",
218 path.display(),
219 parent.display()
220 )));
221 }
222 }
223 Ok(())
224}
225
226#[cfg(unix)]
232pub(crate) fn validate_external_mount_root_metadata(
233 path: &Path,
234 metadata: &std::fs::Metadata,
235 plan: &SandboxIdMappingPlan,
236 read_only: bool,
237) -> Result<()> {
238 let (uid, gid) = mapped_root_ids(plan)?;
239 if !metadata.is_dir() && !metadata.is_file() {
240 return Err(BoxError::ConfigError(format!(
241 "External Sandbox mount {} must be a regular file or directory",
242 path.display()
243 )));
244 }
245 let permission_bits = identity_permission_bits(metadata, uid, gid);
246 let required = if metadata.is_dir() {
247 if read_only {
248 0o5
249 } else {
250 0o7
251 }
252 } else if read_only {
253 0o4
254 } else {
255 0o6
256 };
257 if permission_bits & required != required {
258 return Err(BoxError::ConfigError(format!(
259 "External Sandbox mount {} is not {} by mapped container root {uid}:{gid}; adjust host ownership/permissions or use an A3S-managed volume",
260 path.display(),
261 if read_only { "readable" } else { "writable" }
262 )));
263 }
264 Ok(())
265}
266
267#[cfg(unix)]
268fn identity_permission_bits(metadata: &std::fs::Metadata, uid: u32, gid: u32) -> u32 {
269 use std::os::unix::fs::MetadataExt;
270
271 let mode = metadata.mode();
272 if metadata.uid() == uid {
273 (mode >> 6) & 0o7
274 } else if metadata.gid() == gid {
275 (mode >> 3) & 0o7
276 } else {
277 mode & 0o7
278 }
279}
280
281#[cfg(target_os = "linux")]
284pub fn prepare_managed_secret_mount_source(
285 root: &Path,
286 path: &Path,
287 plan: &SandboxIdMappingPlan,
288 read_only: bool,
289) -> Result<()> {
290 use std::os::unix::ffi::OsStrExt;
291 use std::os::unix::fs::{MetadataExt, PermissionsExt};
292
293 if !read_only {
294 return Err(BoxError::ConfigError(
295 "Sandbox Secret material must use a read-only bind mount".into(),
296 ));
297 }
298 let root_metadata = std::fs::symlink_metadata(root).map_err(BoxError::IoError)?;
299 let canonical_root = root.canonicalize().map_err(BoxError::IoError)?;
300 if canonical_root != root
301 || !root_metadata.file_type().is_dir()
302 || root_metadata.file_type().is_symlink()
303 || root_metadata.uid() != unsafe { libc::geteuid() }
304 || !matches!(root_metadata.permissions().mode() & 0o7777, 0o700 | 0o710)
305 {
306 return Err(BoxError::ConfigError(
307 "Sandbox Secret root must be a canonical private provider-owned directory".into(),
308 ));
309 }
310 let root_c = std::ffi::CString::new(root.as_os_str().as_bytes())
311 .map_err(|_| BoxError::ConfigError("Sandbox Secret root contains NUL".into()))?;
312 let mut status = std::mem::MaybeUninit::<libc::statfs>::uninit();
313 if unsafe { libc::statfs(root_c.as_ptr(), status.as_mut_ptr()) } != 0 {
314 return Err(BoxError::IoError(std::io::Error::last_os_error()));
315 }
316 if unsafe { status.assume_init() }.f_type as libc::c_long != 0x0102_1994 {
317 return Err(BoxError::ConfigError(
318 "Sandbox Secret root is not a Linux tmpfs mount".into(),
319 ));
320 }
321
322 let relative = path.strip_prefix(root).map_err(|_| {
323 BoxError::ConfigError("Sandbox Secret file escaped its configured root".into())
324 })?;
325 let components = relative.components().collect::<Vec<_>>();
326 let valid_identity = matches!(components.as_slice(), [Component::Normal(digest), Component::Normal(file)]
327 if digest.as_bytes().len() == 64
328 && digest.as_bytes().iter().all(u8::is_ascii_hexdigit)
329 && file.as_bytes().len() == 10
330 && file.as_bytes()[..3].iter().all(u8::is_ascii_digit)
331 && &file.as_bytes()[3..] == b".secret");
332 if !valid_identity {
333 return Err(BoxError::ConfigError(
334 "Sandbox Secret file has an invalid deterministic identity".into(),
335 ));
336 }
337 let materialization_directory = path.parent().ok_or_else(|| {
338 BoxError::ConfigError("Sandbox Secret file has no materialization directory".into())
339 })?;
340 let directory_metadata =
341 std::fs::symlink_metadata(materialization_directory).map_err(BoxError::IoError)?;
342 if !directory_metadata.file_type().is_dir()
343 || directory_metadata.file_type().is_symlink()
344 || directory_metadata.uid() != unsafe { libc::geteuid() }
345 || !matches!(
346 directory_metadata.permissions().mode() & 0o7777,
347 0o700 | 0o710
348 )
349 {
350 return Err(BoxError::ConfigError(
351 "Sandbox Secret materialization directory is not private and provider-owned".into(),
352 ));
353 }
354 let metadata = std::fs::symlink_metadata(path).map_err(BoxError::IoError)?;
355 if !metadata.file_type().is_file()
356 || metadata.file_type().is_symlink()
357 || metadata.nlink() != 1
358 || metadata.len() == 0
359 || metadata.len() > 1024 * 1024
360 {
361 return Err(BoxError::ConfigError(
362 "Sandbox Secret source is not a bounded regular file".into(),
363 ));
364 }
365 let (root_uid, root_gid) = mapped_root_ids(plan)?;
366 prepare_secret_directory_traversal(root, root_uid, root_gid)?;
367 prepare_secret_directory_traversal(materialization_directory, root_uid, root_gid)?;
368 prepare_managed_mount_source(path, plan)?;
369 let prepared = std::fs::metadata(path).map_err(BoxError::IoError)?;
370 validate_external_mount_root_metadata(path, &prepared, plan, true)
371}
372
373#[cfg(target_os = "linux")]
374fn prepare_secret_directory_traversal(path: &Path, mapped_uid: u32, mapped_gid: u32) -> Result<()> {
375 use std::os::unix::fs::{MetadataExt, PermissionsExt};
376
377 let metadata = std::fs::symlink_metadata(path).map_err(BoxError::IoError)?;
378 if metadata.uid() == mapped_uid {
379 return Ok(());
380 }
381 lchown_if_needed(path, metadata.uid(), mapped_gid)?;
382 let mode = metadata.permissions().mode() & 0o7777;
383 if mode != 0o710 {
384 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o710))
385 .map_err(BoxError::IoError)?;
386 }
387 Ok(())
388}
389
390#[cfg(not(target_os = "linux"))]
391pub fn prepare_managed_secret_mount_source(
392 _root: &Path,
393 _path: &Path,
394 _plan: &SandboxIdMappingPlan,
395 _read_only: bool,
396) -> Result<()> {
397 Err(BoxError::ConfigError(
398 "Sandbox Secret preparation requires Linux".into(),
399 ))
400}
401
402#[cfg(not(unix))]
403pub fn validate_external_mount_access(
404 _path: &Path,
405 _plan: &SandboxIdMappingPlan,
406 _read_only: bool,
407) -> Result<()> {
408 Err(BoxError::ConfigError(
409 "Sandbox bind mount validation requires Linux".to_string(),
410 ))
411}
412
413#[cfg(target_os = "linux")]
414struct DecodedEntry {
415 metadata: RootfsMetadataEntry,
416 relative: PathBuf,
417 target: PathBuf,
418}
419
420pub fn inspect_rootfs_identity_requirements(root: &Path) -> Result<RootfsIdentityRequirements> {
424 inspect_rootfs_identity_requirements_with_preference(root, false)
425}
426
427pub(crate) fn inspect_rootfs_identity_requirements_with_preference(
428 root: &Path,
429 prefer_image_manifest: bool,
430) -> Result<RootfsIdentityRequirements> {
431 let (manifest_path, manifest) = load_authoritative_manifest(root, prefer_image_manifest)?;
432 let mut maximum_uid = 0u32;
433 let mut maximum_gid = 0u32;
434 for entry in manifest.entries {
435 let uid = u32::try_from(entry.uid).map_err(|_| {
436 BoxError::OciImageError("rootfs metadata UID exceeds the Linux range".to_string())
437 })?;
438 let gid = u32::try_from(entry.gid).map_err(|_| {
439 BoxError::OciImageError("rootfs metadata GID exceeds the Linux range".to_string())
440 })?;
441 maximum_uid = maximum_uid.max(uid);
442 maximum_gid = maximum_gid.max(gid);
443 }
444 Ok(RootfsIdentityRequirements {
445 maximum_uid,
446 maximum_gid,
447 manifest_path,
448 })
449}
450
451#[cfg(target_os = "linux")]
458pub fn prepare_rootfs_ownership(
459 root: &Path,
460 plan: &SandboxIdMappingPlan,
461 effective_uid: u32,
462 read_only: bool,
463) -> Result<()> {
464 prepare_rootfs_ownership_with_preference(root, plan, effective_uid, read_only, false)
465}
466
467#[cfg(target_os = "linux")]
468pub(crate) fn prepare_rootfs_ownership_with_preference(
469 root: &Path,
470 plan: &SandboxIdMappingPlan,
471 effective_uid: u32,
472 read_only: bool,
473 prefer_image_manifest: bool,
474) -> Result<()> {
475 if effective_uid != 0 {
476 if read_only {
477 return Err(BoxError::ConfigError(
478 "Sandbox read-only rootfs requires a root-run service until idmapped rootfs preparation is available"
479 .to_string(),
480 ));
481 }
482 return Ok(());
483 }
484
485 ensure_no_nested_mounts(root)?;
486 let (_, manifest) = load_authoritative_manifest(root, prefer_image_manifest)?;
487 let entries = decode_and_validate_entries(root, manifest)?;
488 let authoritative_paths: HashSet<PathBuf> =
489 entries.iter().map(|entry| entry.relative.clone()).collect();
490
491 for entry in &entries {
492 let uid = map_container_id(
493 &plan.uid_mappings,
494 u32::try_from(entry.metadata.uid).map_err(|_| {
495 BoxError::OciImageError("rootfs metadata UID exceeds the Linux range".to_string())
496 })?,
497 "UID",
498 )?;
499 let gid = map_container_id(
500 &plan.gid_mappings,
501 u32::try_from(entry.metadata.gid).map_err(|_| {
502 BoxError::OciImageError("rootfs metadata GID exceeds the Linux range".to_string())
503 })?,
504 "GID",
505 )?;
506 lchown_if_needed(&entry.target, uid, gid)?;
507 }
508
509 shift_unlisted_entries(root, root, &authoritative_paths, plan)?;
514
515 let mut modes: Vec<_> = entries
518 .iter()
519 .filter(|entry| entry.metadata.kind != RootfsEntryKind::Symlink)
520 .collect();
521 modes.sort_by_key(|entry| std::cmp::Reverse(entry.relative.components().count()));
522 for entry in modes {
523 use std::os::unix::fs::PermissionsExt;
524 let mode =
525 runtime_managed_rootfs_mode(&entry.relative).unwrap_or(entry.metadata.mode & 0o7777);
526 std::fs::set_permissions(&entry.target, std::fs::Permissions::from_mode(mode)).map_err(
527 |error| BoxError::BoxBootError {
528 message: format!(
529 "Failed to restore Sandbox rootfs mode at {}: {error}",
530 entry.target.display()
531 ),
532 hint: None,
533 },
534 )?;
535 }
536
537 Ok(())
538}
539
540#[cfg(target_os = "linux")]
548pub(crate) fn capture_rootfs_metadata(
549 root: &Path,
550 plan: &SandboxIdMappingPlan,
551) -> Result<RootfsMetadataManifest> {
552 ensure_no_nested_mounts(root)?;
553 let mut entries = Vec::new();
554 collect_snapshot_rootfs_metadata(root, root, Path::new("."), plan, &mut entries)?;
555 entries.sort_by(|left, right| left.path_base64.cmp(&right.path_base64));
556 Ok(RootfsMetadataManifest::new(entries))
557}
558
559#[cfg(target_os = "linux")]
560fn collect_snapshot_rootfs_metadata(
561 root: &Path,
562 source: &Path,
563 manifest_path: &Path,
564 plan: &SandboxIdMappingPlan,
565 entries: &mut Vec<RootfsMetadataEntry>,
566) -> Result<()> {
567 use std::os::unix::ffi::OsStrExt;
568 use std::os::unix::fs::{FileTypeExt, MetadataExt};
569
570 let relative = source
571 .strip_prefix(root)
572 .map_err(|_| BoxError::OciImageError("Sandbox Snapshot walk escaped its root".into()))?;
573 if a3s_box_core::rootfs_metadata::is_runtime_internal_rootfs_path(relative) {
574 return Ok(());
575 }
576
577 let metadata = std::fs::symlink_metadata(source).map_err(BoxError::IoError)?;
578 let file_type = metadata.file_type();
579 let (kind, link_target_base64) = if file_type.is_dir() {
580 (RootfsEntryKind::Directory, None)
581 } else if file_type.is_file() {
582 (RootfsEntryKind::Regular, None)
583 } else if file_type.is_symlink() {
584 let target = std::fs::read_link(source).map_err(BoxError::IoError)?;
585 (
586 RootfsEntryKind::Symlink,
587 Some(base64::engine::general_purpose::STANDARD.encode(target.as_os_str().as_bytes())),
588 )
589 } else {
590 let kind = if file_type.is_fifo() {
591 "fifo"
592 } else if file_type.is_socket() {
593 "socket"
594 } else if file_type.is_char_device() {
595 "character device"
596 } else if file_type.is_block_device() {
597 "block device"
598 } else {
599 "unknown"
600 };
601 return Err(BoxError::OciImageError(format!(
602 "Sandbox Snapshot rootfs contains unsupported special file {} ({kind})",
603 source.display()
604 )));
605 };
606 entries.push(RootfsMetadataEntry {
607 path_base64: base64::engine::general_purpose::STANDARD
608 .encode(manifest_path.as_os_str().as_bytes()),
609 kind,
610 mode: metadata.mode(),
611 uid: unmap_host_id(&plan.uid_mappings, metadata.uid(), "UID", manifest_path)? as u64,
612 gid: unmap_host_id(&plan.gid_mappings, metadata.gid(), "GID", manifest_path)? as u64,
613 mtime: metadata.mtime().max(0) as u64,
614 size: metadata.size(),
615 link_target_base64,
616 });
617
618 if file_type.is_dir() {
619 let mut children: Vec<_> = std::fs::read_dir(source)
620 .map_err(BoxError::IoError)?
621 .collect::<std::result::Result<_, _>>()
622 .map_err(BoxError::IoError)?;
623 children.sort_by_key(std::fs::DirEntry::file_name);
624 for child in children {
625 collect_snapshot_rootfs_metadata(
626 root,
627 &child.path(),
628 &manifest_path.join(child.file_name()),
629 plan,
630 entries,
631 )?;
632 }
633 }
634 Ok(())
635}
636
637#[cfg(target_os = "linux")]
638fn unmap_host_id(mappings: &[IdMapping], id: u32, kind: &str, path: &Path) -> Result<u32> {
639 for mapping in mappings {
640 let Some(end) = mapping.host_id.checked_add(mapping.size) else {
641 continue;
642 };
643 if mapping.host_id <= id && id < end {
644 return mapping
645 .container_id
646 .checked_add(id - mapping.host_id)
647 .ok_or_else(|| {
648 BoxError::ConfigError(format!(
649 "Sandbox rootfs capture {kind} reverse mapping overflows u32 at {}",
650 path.display()
651 ))
652 });
653 }
654 }
655
656 if id == 0 {
663 let relative = safe_relative_path(path)?;
664 if runtime_managed_rootfs_mode(&relative).is_some() {
665 return Ok(0);
666 }
667 }
668
669 Err(BoxError::ConfigError(format!(
670 "Sandbox rootfs capture host {kind} {id} at {} is outside the OCI mappings",
671 path.display()
672 )))
673}
674
675#[cfg(not(target_os = "linux"))]
676pub fn prepare_rootfs_ownership(
677 _root: &Path,
678 _plan: &SandboxIdMappingPlan,
679 _effective_uid: u32,
680 _read_only: bool,
681) -> Result<()> {
682 Err(BoxError::ConfigError(
683 "Sandbox rootfs ownership preparation requires Linux".to_string(),
684 ))
685}
686
687#[cfg(not(target_os = "linux"))]
688pub(crate) fn prepare_rootfs_ownership_with_preference(
689 root: &Path,
690 plan: &SandboxIdMappingPlan,
691 effective_uid: u32,
692 read_only: bool,
693 _prefer_image_manifest: bool,
694) -> Result<()> {
695 prepare_rootfs_ownership(root, plan, effective_uid, read_only)
696}
697
698fn load_authoritative_manifest(
699 root: &Path,
700 prefer_image_manifest: bool,
701) -> Result<(PathBuf, RootfsMetadataManifest)> {
702 let terminal = root.join(ROOTFS_METADATA_PATH.trim_start_matches('/'));
703 let previous = root.join(PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/'));
704 let image = root.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/'));
705 let candidates = if prefer_image_manifest {
706 [image, terminal, previous]
709 } else {
710 [terminal, previous, image]
715 };
716
717 for candidate in &candidates {
718 if let Some(manifest) = load_manifest_if_present(candidate)? {
719 return Ok((candidate.clone(), manifest));
720 }
721 }
722
723 Err(BoxError::BoxBootError {
724 message: format!(
725 "Sandbox rootfs metadata is unavailable at {}, {}, or {}",
726 candidates[0].display(),
727 candidates[1].display(),
728 candidates[2].display()
729 ),
730 hint: Some("Rebuild the per-box rootfs from its OCI image".to_string()),
731 })
732}
733
734fn load_manifest_if_present(path: &Path) -> Result<Option<RootfsMetadataManifest>> {
735 let mut file = match open_regular_file_no_follow(path) {
736 Ok(file) => file,
737 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
738 Err(error) => {
739 return Err(BoxError::BoxBootError {
740 message: format!(
741 "Sandbox rootfs metadata is not a safe regular file at {}: {error}",
742 path.display()
743 ),
744 hint: Some("Rebuild the per-box rootfs from its OCI image".to_string()),
745 });
746 }
747 };
748 let length = file.metadata().map_err(BoxError::IoError)?.len();
749 if length > MAX_ROOTFS_METADATA_BYTES {
750 return Err(BoxError::OciImageError(format!(
751 "Sandbox rootfs metadata {} exceeds the {} byte limit",
752 path.display(),
753 MAX_ROOTFS_METADATA_BYTES
754 )));
755 }
756
757 let mut bytes = Vec::with_capacity(length as usize);
758 Read::by_ref(&mut file)
759 .take(MAX_ROOTFS_METADATA_BYTES + 1)
760 .read_to_end(&mut bytes)
761 .map_err(|error| BoxError::BoxBootError {
762 message: format!(
763 "Failed to read Sandbox rootfs metadata {}: {error}",
764 path.display()
765 ),
766 hint: Some("Rebuild the per-box rootfs from its OCI image".to_string()),
767 })?;
768 if bytes.len() as u64 > MAX_ROOTFS_METADATA_BYTES {
769 return Err(BoxError::OciImageError(format!(
770 "Sandbox rootfs metadata {} exceeds the {} byte limit",
771 path.display(),
772 MAX_ROOTFS_METADATA_BYTES
773 )));
774 }
775 let manifest: RootfsMetadataManifest = serde_json::from_slice(&bytes).map_err(|error| {
776 BoxError::OciImageError(format!(
777 "Invalid Sandbox rootfs metadata {}: {error}",
778 path.display()
779 ))
780 })?;
781 manifest.validate().map_err(BoxError::OciImageError)?;
782 Ok(Some(manifest))
783}
784
785#[cfg(unix)]
786fn open_regular_file_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
787 use std::os::unix::fs::OpenOptionsExt;
788
789 let file = std::fs::OpenOptions::new()
790 .read(true)
791 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
792 .open(path)?;
793 if !file.metadata()?.file_type().is_file() {
794 return Err(std::io::Error::new(
795 std::io::ErrorKind::InvalidData,
796 "rootfs metadata path is not a regular file",
797 ));
798 }
799 Ok(file)
800}
801
802#[cfg(windows)]
803fn open_regular_file_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
804 a3s_box_core::windows_file::open_regular_file(path, None).map(|(file, _)| file)
805}
806
807#[cfg(not(any(unix, windows)))]
808fn open_regular_file_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
809 let metadata = std::fs::symlink_metadata(path)?;
810 if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
811 return Err(std::io::Error::new(
812 std::io::ErrorKind::InvalidData,
813 "rootfs metadata path is not a regular file",
814 ));
815 }
816 std::fs::File::open(path)
817}
818
819#[cfg(target_os = "linux")]
820fn decode_and_validate_entries(
821 root: &Path,
822 manifest: RootfsMetadataManifest,
823) -> Result<Vec<DecodedEntry>> {
824 let mut decoded = Vec::with_capacity(manifest.entries.len());
825 let mut unique = HashSet::with_capacity(manifest.entries.len());
826 for metadata in manifest.entries {
827 let raw = base64::engine::general_purpose::STANDARD
828 .decode(&metadata.path_base64)
829 .map_err(|error| {
830 BoxError::OciImageError(format!("Invalid rootfs metadata path: {error}"))
831 })?;
832 let encoded = unsafe { std::ffi::OsString::from_encoded_bytes_unchecked(raw) };
835 let relative = safe_relative_path(Path::new(&encoded))?;
836 if a3s_box_core::rootfs_metadata::is_runtime_internal_rootfs_path(&relative)
837 || !unique.insert(relative.clone())
838 {
839 return Err(BoxError::OciImageError(
840 "Duplicate or reserved Sandbox rootfs metadata path".to_string(),
841 ));
842 }
843 let target = resolve_without_symlink_parent(root, &relative)?;
844 let filesystem =
845 std::fs::symlink_metadata(&target).map_err(|error| BoxError::BoxBootError {
846 message: format!(
847 "Sandbox rootfs metadata target {} is unavailable: {error}",
848 target.display()
849 ),
850 hint: None,
851 })?;
852 let actual_kind = if filesystem.file_type().is_dir() {
853 RootfsEntryKind::Directory
854 } else if filesystem.file_type().is_file() {
855 RootfsEntryKind::Regular
856 } else if filesystem.file_type().is_symlink() {
857 RootfsEntryKind::Symlink
858 } else {
859 return Err(BoxError::OciImageError(format!(
860 "Unsupported rootfs entry at {}",
861 target.display()
862 )));
863 };
864 if actual_kind != metadata.kind {
865 return Err(BoxError::OciImageError(format!(
866 "Sandbox rootfs metadata type mismatch at {}",
867 target.display()
868 )));
869 }
870 if actual_kind == RootfsEntryKind::Symlink {
871 let expected = metadata.link_target_base64.as_ref().ok_or_else(|| {
872 BoxError::OciImageError("Symlink metadata is missing its target".to_string())
873 })?;
874 let expected = base64::engine::general_purpose::STANDARD
875 .decode(expected)
876 .map_err(|error| {
877 BoxError::OciImageError(format!("Invalid symlink target metadata: {error}"))
878 })?;
879 if std::fs::read_link(&target)
880 .map_err(BoxError::IoError)?
881 .as_os_str()
882 .as_encoded_bytes()
883 != expected
884 {
885 return Err(BoxError::OciImageError(format!(
886 "Sandbox rootfs symlink mismatch at {}",
887 target.display()
888 )));
889 }
890 }
891 decoded.push(DecodedEntry {
892 metadata,
893 relative,
894 target,
895 });
896 }
897 Ok(decoded)
898}
899
900#[cfg(any(target_os = "linux", test))]
901fn safe_relative_path(path: &Path) -> Result<PathBuf> {
902 let mut result = PathBuf::new();
903 for component in path.components() {
904 match component {
905 Component::CurDir => {}
906 Component::Normal(name) => result.push(name),
907 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
908 return Err(BoxError::OciImageError(
909 "Unsafe Sandbox rootfs metadata path".to_string(),
910 ))
911 }
912 }
913 }
914 Ok(result)
915}
916
917#[cfg(target_os = "linux")]
918fn resolve_without_symlink_parent(root: &Path, relative: &Path) -> Result<PathBuf> {
919 let mut current = root.to_path_buf();
920 let components: Vec<_> = relative.components().collect();
921 for (index, component) in components.iter().enumerate() {
922 let Component::Normal(name) = component else {
923 continue;
924 };
925 current.push(name);
926 if index + 1 < components.len()
927 && std::fs::symlink_metadata(¤t)
928 .map_err(BoxError::IoError)?
929 .file_type()
930 .is_symlink()
931 {
932 return Err(BoxError::OciImageError(format!(
933 "Symlink parent in Sandbox rootfs metadata path: {}",
934 current.display()
935 )));
936 }
937 }
938 Ok(current)
939}
940
941#[cfg(target_os = "linux")]
942fn shift_unlisted_entries(
943 root: &Path,
944 source: &Path,
945 authoritative: &HashSet<PathBuf>,
946 plan: &SandboxIdMappingPlan,
947) -> Result<()> {
948 use std::os::unix::fs::MetadataExt;
949
950 let relative = source
951 .strip_prefix(root)
952 .map_err(|_| BoxError::OciImageError("Sandbox rootfs walk escaped its root".to_string()))?;
953 let metadata = std::fs::symlink_metadata(source).map_err(BoxError::IoError)?;
954 if !authoritative.contains(relative) {
955 let uid = map_current_or_container_id(&plan.uid_mappings, metadata.uid(), "UID")?;
956 let gid = map_current_or_container_id(&plan.gid_mappings, metadata.gid(), "GID")?;
957 lchown_if_needed(source, uid, gid)?;
958 }
959 if metadata.file_type().is_dir() {
960 for child in std::fs::read_dir(source).map_err(BoxError::IoError)? {
961 shift_unlisted_entries(
962 root,
963 &child.map_err(BoxError::IoError)?.path(),
964 authoritative,
965 plan,
966 )?;
967 }
968 }
969 Ok(())
970}
971
972#[cfg(target_os = "linux")]
973fn prepare_managed_tree(
974 path: &Path,
975 plan: &SandboxIdMappingPlan,
976 root_uid: u32,
977 root_gid: u32,
978) -> Result<()> {
979 use std::os::unix::fs::{MetadataExt, PermissionsExt};
980
981 let metadata = std::fs::symlink_metadata(path).map_err(BoxError::IoError)?;
982 let uid = if id_is_mapped(&plan.uid_mappings, metadata.uid()) {
983 metadata.uid()
984 } else {
985 root_uid
986 };
987 let gid = if id_is_mapped(&plan.gid_mappings, metadata.gid()) {
988 metadata.gid()
989 } else {
990 root_gid
991 };
992 let mode = metadata.mode() & 0o7777;
993 lchown_if_needed(path, uid, gid)?;
994 if !metadata.file_type().is_symlink() {
995 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
996 .map_err(BoxError::IoError)?;
997 }
998 if metadata.file_type().is_dir() {
999 for child in std::fs::read_dir(path).map_err(BoxError::IoError)? {
1000 prepare_managed_tree(
1001 &child.map_err(BoxError::IoError)?.path(),
1002 plan,
1003 root_uid,
1004 root_gid,
1005 )?;
1006 }
1007 }
1008 Ok(())
1009}
1010
1011#[cfg(target_os = "linux")]
1012fn id_is_mapped(mappings: &[IdMapping], id: u32) -> bool {
1013 mappings.iter().any(|mapping| {
1014 mapping
1015 .host_id
1016 .checked_add(mapping.size)
1017 .is_some_and(|end| mapping.host_id <= id && id < end)
1018 })
1019}
1020
1021#[cfg(target_os = "linux")]
1022fn ensure_no_nested_mounts(root: &Path) -> Result<()> {
1023 let root = root.canonicalize().map_err(BoxError::IoError)?;
1024 let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").map_err(BoxError::IoError)?;
1025 for mount in mountinfo
1026 .lines()
1027 .filter_map(|line| line.split_whitespace().nth(4))
1028 .map(decode_mountinfo_path)
1029 .map(PathBuf::from)
1030 {
1031 if mount != root && mount.starts_with(&root) {
1032 return Err(BoxError::BoxBootError {
1033 message: format!(
1034 "Refusing Sandbox ownership preparation across nested mount {} under {}",
1035 mount.display(),
1036 root.display()
1037 ),
1038 hint: Some("Reconcile the stale mount before restarting the Sandbox".to_string()),
1039 });
1040 }
1041 }
1042 Ok(())
1043}
1044
1045#[cfg(target_os = "linux")]
1046fn decode_mountinfo_path(value: &str) -> String {
1047 value
1048 .replace("\\040", " ")
1049 .replace("\\011", "\t")
1050 .replace("\\012", "\n")
1051 .replace("\\134", "\\")
1052}
1053
1054#[cfg(target_os = "linux")]
1055fn map_current_or_container_id(mappings: &[IdMapping], id: u32, kind: &str) -> Result<u32> {
1056 if mappings.iter().any(|mapping| {
1057 mapping
1058 .host_id
1059 .checked_add(mapping.size)
1060 .is_some_and(|end| mapping.host_id <= id && id < end)
1061 }) {
1062 return Ok(id);
1063 }
1064 map_container_id(mappings, id, kind)
1065}
1066
1067fn map_container_id(mappings: &[IdMapping], id: u32, kind: &str) -> Result<u32> {
1068 for mapping in mappings {
1069 let Some(end) = mapping.container_id.checked_add(mapping.size) else {
1070 continue;
1071 };
1072 if mapping.container_id <= id && id < end {
1073 return mapping
1074 .host_id
1075 .checked_add(id - mapping.container_id)
1076 .ok_or_else(|| {
1077 BoxError::ConfigError(format!("Sandbox {kind} mapping overflows u32"))
1078 });
1079 }
1080 }
1081 Err(BoxError::ConfigError(format!(
1082 "Sandbox {kind} mappings do not cover container ID {id}"
1083 )))
1084}
1085
1086#[cfg(target_os = "linux")]
1087fn lchown_if_needed(path: &Path, uid: u32, gid: u32) -> Result<()> {
1088 use std::os::unix::ffi::OsStrExt;
1089 use std::os::unix::fs::MetadataExt;
1090
1091 let current = std::fs::symlink_metadata(path).map_err(BoxError::IoError)?;
1092 if current.uid() == uid && current.gid() == gid {
1093 return Ok(());
1094 }
1095 let path_bytes = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| {
1096 BoxError::OciImageError(format!(
1097 "NUL byte in Sandbox rootfs path {}",
1098 path.display()
1099 ))
1100 })?;
1101 if unsafe { libc::lchown(path_bytes.as_ptr(), uid, gid) } != 0 {
1102 return Err(BoxError::BoxBootError {
1103 message: format!(
1104 "Failed to map Sandbox rootfs ownership at {} to {uid}:{gid}: {}",
1105 path.display(),
1106 std::io::Error::last_os_error()
1107 ),
1108 hint: None,
1109 });
1110 }
1111 Ok(())
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use super::*;
1117 use a3s_box_core::rootfs_metadata::ROOTFS_METADATA_SCHEMA;
1118
1119 fn test_manifest(uid: u64, gid: u64) -> RootfsMetadataManifest {
1120 RootfsMetadataManifest {
1121 schema: ROOTFS_METADATA_SCHEMA.to_string(),
1122 entries: vec![RootfsMetadataEntry {
1123 path_base64: base64::engine::general_purpose::STANDARD.encode("."),
1124 kind: RootfsEntryKind::Directory,
1125 mode: 0o755,
1126 uid,
1127 gid,
1128 mtime: 0,
1129 size: 0,
1130 link_target_base64: None,
1131 }],
1132 }
1133 }
1134
1135 #[cfg(target_os = "linux")]
1136 #[test]
1137 fn interrupted_generation_recovers_exact_replay_metadata_from_persisted_mappings() {
1138 use std::os::unix::ffi::OsStrExt;
1139 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1140
1141 let home = tempfile::tempdir().unwrap();
1142 let box_dir = home.path().join("boxes/interrupted");
1143 let root = box_dir.join("merged");
1144 std::fs::create_dir_all(&root).unwrap();
1145 let state = root.join("state.txt");
1146 std::fs::write(&state, "changed while running\n").unwrap();
1147 std::fs::set_permissions(&state, std::fs::Permissions::from_mode(0o620)).unwrap();
1148
1149 let effective_uid = unsafe { libc::geteuid() };
1150 let effective_gid = unsafe { libc::getegid() };
1151 let (host_uid, host_gid, mapping_size, state_container_id) = if effective_uid == 0
1152 && effective_gid == 0
1153 {
1154 let host_uid = 100_000;
1155 let host_gid = 100_000;
1156 for (path, offset) in [(&root, 0), (&state, 7)] {
1157 let path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
1158 assert_eq!(
1159 unsafe { libc::lchown(path.as_ptr(), host_uid + offset, host_gid + offset) },
1160 0,
1161 "failed to prepare mapped rootfs ownership: {}",
1162 std::io::Error::last_os_error()
1163 );
1164 }
1165 (host_uid, host_gid, 8, 7)
1166 } else {
1167 (effective_uid, effective_gid, 1, 0)
1168 };
1169 let plan = SandboxIdMappingPlan {
1170 uid_mappings: vec![IdMapping {
1171 container_id: 0,
1172 host_id: host_uid,
1173 size: mapping_size,
1174 }],
1175 gid_mappings: vec![IdMapping {
1176 container_id: 0,
1177 host_id: host_gid,
1178 size: mapping_size,
1179 }],
1180 maximum_container_uid: mapping_size - 1,
1181 maximum_container_gid: mapping_size - 1,
1182 };
1183 persist_rootfs_id_mappings(&box_dir, &plan).unwrap();
1184
1185 assert!(recover_interrupted_rootfs_metadata(&box_dir, &root).unwrap());
1186 let previous = root.join(PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/'));
1187 let recovered: RootfsMetadataManifest =
1188 serde_json::from_slice(&std::fs::read(&previous).unwrap()).unwrap();
1189 let state_entry = recovered
1190 .entries
1191 .iter()
1192 .find(|entry| entry.kind == RootfsEntryKind::Regular)
1193 .unwrap();
1194 assert_eq!(state_entry.uid, u64::from(state_container_id));
1195 assert_eq!(state_entry.gid, u64::from(state_container_id));
1196 assert_eq!(state_entry.mode & 0o7777, 0o620);
1197 assert_eq!(state_entry.size, 22);
1198
1199 let requirements = inspect_rootfs_identity_requirements(&root).unwrap();
1200 assert_eq!(requirements.maximum_uid, state_container_id);
1201 assert_eq!(requirements.maximum_gid, state_container_id);
1202 assert_eq!(requirements.manifest_path, previous);
1203 assert!(!recover_interrupted_rootfs_metadata(&box_dir, &root).unwrap());
1204
1205 prepare_rootfs_ownership(&root, &plan, 0, false).unwrap();
1206 let state_metadata = std::fs::symlink_metadata(&state).unwrap();
1207 assert_eq!(state_metadata.uid(), host_uid + state_container_id);
1208 assert_eq!(state_metadata.gid(), host_gid + state_container_id);
1209 assert_eq!(state_metadata.permissions().mode() & 0o7777, 0o620);
1210 }
1211
1212 #[test]
1213 fn mapping_translation_is_complete_and_exact() {
1214 let mappings = vec![
1215 IdMapping {
1216 container_id: 0,
1217 host_id: 100_000,
1218 size: 10,
1219 },
1220 IdMapping {
1221 container_id: 10,
1222 host_id: 200_000,
1223 size: 6,
1224 },
1225 ];
1226 assert_eq!(map_container_id(&mappings, 0, "UID").unwrap(), 100_000);
1227 assert_eq!(map_container_id(&mappings, 12, "UID").unwrap(), 200_002);
1228 assert!(map_container_id(&mappings, 16, "UID").is_err());
1229 }
1230
1231 #[cfg(target_os = "linux")]
1232 #[test]
1233 fn reverse_mapping_failure_names_the_exact_rootfs_path() {
1234 let mappings = vec![IdMapping {
1235 container_id: 0,
1236 host_id: 100_000,
1237 size: 1,
1238 }];
1239 let error = unmap_host_id(&mappings, 0, "UID", Path::new("etc/passwd")).unwrap_err();
1240 assert!(error.to_string().contains("etc/passwd"));
1241 }
1242
1243 #[cfg(target_os = "linux")]
1244 #[test]
1245 fn reverse_mapping_accepts_only_host_root_runtime_managed_writes() {
1246 let mappings = vec![IdMapping {
1247 container_id: 0,
1248 host_id: 100_000,
1249 size: 1,
1250 }];
1251
1252 assert_eq!(
1253 unmap_host_id(&mappings, 0, "UID", Path::new("./usr/sbin/init")).unwrap(),
1254 0
1255 );
1256 assert_eq!(
1257 unmap_host_id(&mappings, 0, "GID", Path::new("etc/hostname")).unwrap(),
1258 0
1259 );
1260 assert!(unmap_host_id(&mappings, 1, "UID", Path::new("usr/sbin/init")).is_err());
1261 assert!(unmap_host_id(&mappings, 0, "UID", Path::new("usr/bin/unmanaged")).is_err());
1262 }
1263
1264 #[cfg(unix)]
1265 #[test]
1266 fn external_mount_validation_rejects_an_unsearchable_parent() {
1267 use std::os::unix::fs::PermissionsExt;
1268
1269 let fixture = tempfile::tempdir().unwrap();
1270 std::fs::set_permissions(fixture.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
1271 let private = fixture.path().join("private");
1272 let source = private.join("artifact");
1273 std::fs::create_dir_all(&source).unwrap();
1274 std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700)).unwrap();
1275 std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap();
1276 let plan = SandboxIdMappingPlan {
1277 uid_mappings: vec![IdMapping {
1278 container_id: 0,
1279 host_id: unsafe { libc::geteuid() }.saturating_add(100_000),
1280 size: 1,
1281 }],
1282 gid_mappings: vec![IdMapping {
1283 container_id: 0,
1284 host_id: unsafe { libc::getegid() }.saturating_add(100_000),
1285 size: 1,
1286 }],
1287 maximum_container_uid: 0,
1288 maximum_container_gid: 0,
1289 };
1290
1291 let metadata = std::fs::metadata(&source).unwrap();
1292 validate_external_mount_root_metadata(&source, &metadata, &plan, true).unwrap();
1293 let error = validate_external_mount_access(&source, &plan, true).unwrap_err();
1294 assert!(error.to_string().contains("not searchable"));
1295 assert_eq!(
1296 std::fs::metadata(private).unwrap().permissions().mode() & 0o7777,
1297 0o700
1298 );
1299 }
1300
1301 #[test]
1302 fn terminal_manifest_takes_precedence_for_identity_planning() {
1303 let directory = tempfile::tempdir().unwrap();
1304 std::fs::write(
1305 directory
1306 .path()
1307 .join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')),
1308 serde_json::to_vec(&test_manifest(1, 2)).unwrap(),
1309 )
1310 .unwrap();
1311 std::fs::write(
1312 directory
1313 .path()
1314 .join(ROOTFS_METADATA_PATH.trim_start_matches('/')),
1315 serde_json::to_vec(&test_manifest(42, 43)).unwrap(),
1316 )
1317 .unwrap();
1318
1319 let requirements = inspect_rootfs_identity_requirements(directory.path()).unwrap();
1320 assert_eq!(requirements.maximum_uid, 42);
1321 assert_eq!(requirements.maximum_gid, 43);
1322 assert!(requirements
1323 .manifest_path
1324 .ends_with(".a3s_rootfs_metadata_v1.json"));
1325 }
1326
1327 #[test]
1328 fn staged_previous_manifest_takes_precedence_over_image() {
1329 let directory = tempfile::tempdir().unwrap();
1330 std::fs::write(
1331 directory
1332 .path()
1333 .join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')),
1334 serde_json::to_vec(&test_manifest(1, 2)).unwrap(),
1335 )
1336 .unwrap();
1337 std::fs::write(
1338 directory
1339 .path()
1340 .join(PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/')),
1341 serde_json::to_vec(&test_manifest(42, 43)).unwrap(),
1342 )
1343 .unwrap();
1344
1345 let requirements = inspect_rootfs_identity_requirements(directory.path()).unwrap();
1346 assert_eq!(requirements.maximum_uid, 42);
1347 assert_eq!(requirements.maximum_gid, 43);
1348 assert!(requirements
1349 .manifest_path
1350 .ends_with(".a3s_rootfs_metadata_v1.previous.json"));
1351 }
1352
1353 #[test]
1354 fn fresh_rootfs_prefers_image_manifest_for_identity_planning() {
1355 let directory = tempfile::tempdir().unwrap();
1356 std::fs::write(
1357 directory
1358 .path()
1359 .join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')),
1360 serde_json::to_vec(&test_manifest(7, 8)).unwrap(),
1361 )
1362 .unwrap();
1363 std::fs::write(
1364 directory
1365 .path()
1366 .join(ROOTFS_METADATA_PATH.trim_start_matches('/')),
1367 serde_json::to_vec(&test_manifest(42, 43)).unwrap(),
1368 )
1369 .unwrap();
1370 std::fs::write(
1371 directory
1372 .path()
1373 .join(PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/')),
1374 serde_json::to_vec(&test_manifest(99, 100)).unwrap(),
1375 )
1376 .unwrap();
1377
1378 let requirements =
1379 inspect_rootfs_identity_requirements_with_preference(directory.path(), true).unwrap();
1380 assert_eq!(requirements.maximum_uid, 7);
1381 assert_eq!(requirements.maximum_gid, 8);
1382 assert!(requirements
1383 .manifest_path
1384 .ends_with(".a3s_image_metadata_v1.json"));
1385 }
1386
1387 #[cfg(unix)]
1388 #[test]
1389 fn terminal_manifest_symlink_is_rejected_without_following() {
1390 use std::os::unix::fs::symlink;
1391
1392 let directory = tempfile::tempdir().unwrap();
1393 let outside = directory.path().join("outside.json");
1394 let outside_bytes = serde_json::to_vec(&test_manifest(99, 100)).unwrap();
1395 std::fs::write(&outside, &outside_bytes).unwrap();
1396 symlink(
1397 &outside,
1398 directory
1399 .path()
1400 .join(ROOTFS_METADATA_PATH.trim_start_matches('/')),
1401 )
1402 .unwrap();
1403
1404 let error = inspect_rootfs_identity_requirements(directory.path()).unwrap_err();
1405 assert!(error.to_string().contains("not a safe regular file"));
1406 assert_eq!(std::fs::read(outside).unwrap(), outside_bytes);
1407 }
1408
1409 #[cfg(unix)]
1410 #[test]
1411 fn fresh_rootfs_ignores_baked_terminal_manifest_symlink() {
1412 use std::os::unix::fs::symlink;
1413
1414 let directory = tempfile::tempdir().unwrap();
1415 std::fs::write(
1416 directory
1417 .path()
1418 .join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')),
1419 serde_json::to_vec(&test_manifest(7, 8)).unwrap(),
1420 )
1421 .unwrap();
1422 let outside = directory.path().join("outside.json");
1423 std::fs::write(
1424 &outside,
1425 serde_json::to_vec(&test_manifest(99, 100)).unwrap(),
1426 )
1427 .unwrap();
1428 symlink(
1429 &outside,
1430 directory
1431 .path()
1432 .join(ROOTFS_METADATA_PATH.trim_start_matches('/')),
1433 )
1434 .unwrap();
1435
1436 let requirements =
1437 inspect_rootfs_identity_requirements_with_preference(directory.path(), true).unwrap();
1438 assert_eq!(requirements.maximum_uid, 7);
1439 assert_eq!(requirements.maximum_gid, 8);
1440 assert!(requirements
1441 .manifest_path
1442 .ends_with(".a3s_image_metadata_v1.json"));
1443 }
1444
1445 #[test]
1446 fn oversized_rootfs_manifest_is_rejected_before_reading() {
1447 let directory = tempfile::tempdir().unwrap();
1448 let path = directory
1449 .path()
1450 .join(ROOTFS_METADATA_PATH.trim_start_matches('/'));
1451 let file = std::fs::File::create(path).unwrap();
1452 file.set_len(MAX_ROOTFS_METADATA_BYTES + 1).unwrap();
1453
1454 let error = inspect_rootfs_identity_requirements(directory.path()).unwrap_err();
1455 assert!(error
1456 .to_string()
1457 .contains("exceeds the 67108864 byte limit"));
1458 }
1459
1460 #[test]
1461 fn unsafe_manifest_path_is_rejected() {
1462 assert!(safe_relative_path(Path::new("../escape")).is_err());
1463 assert!(safe_relative_path(Path::new("/host")).is_err());
1464 }
1465
1466 #[cfg(target_os = "linux")]
1467 #[test]
1468 fn ownership_preparation_keeps_runtime_managed_files_readable() {
1469 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1470
1471 let directory = tempfile::tempdir().unwrap();
1472 let etc = directory.path().join("etc");
1473 std::fs::create_dir(&etc).unwrap();
1474 let hosts = etc.join("hosts");
1475 let probe = etc.join("probe");
1476 let init = directory.path().join("usr/sbin/init");
1477 std::fs::create_dir_all(init.parent().unwrap()).unwrap();
1478 std::fs::write(&hosts, "127.0.0.1 localhost\n").unwrap();
1479 std::fs::write(&probe, "probe\n").unwrap();
1480 for path in [&hosts, &probe] {
1481 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).unwrap();
1482 }
1483 std::fs::write(&init, "guest init\n").unwrap();
1484 std::fs::set_permissions(&init, std::fs::Permissions::from_mode(0o755)).unwrap();
1485
1486 let owner = std::fs::metadata(directory.path()).unwrap();
1487 let entry = |path: &str, size: u64| RootfsMetadataEntry {
1488 path_base64: base64::engine::general_purpose::STANDARD.encode(path),
1489 kind: RootfsEntryKind::Regular,
1490 mode: 0o100600,
1491 uid: 0,
1492 gid: 0,
1493 mtime: 0,
1494 size,
1495 link_target_base64: None,
1496 };
1497 let manifest = RootfsMetadataManifest {
1498 schema: ROOTFS_METADATA_SCHEMA.to_string(),
1499 entries: vec![
1500 entry("./etc/hosts", 20),
1501 entry("./etc/probe", 6),
1502 entry("./usr/sbin/init", 1),
1503 ],
1504 };
1505 std::fs::write(
1506 directory
1507 .path()
1508 .join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')),
1509 serde_json::to_vec(&manifest).unwrap(),
1510 )
1511 .unwrap();
1512 let plan = SandboxIdMappingPlan {
1513 uid_mappings: vec![IdMapping {
1514 container_id: 0,
1515 host_id: owner.uid(),
1516 size: 1,
1517 }],
1518 gid_mappings: vec![IdMapping {
1519 container_id: 0,
1520 host_id: owner.gid(),
1521 size: 1,
1522 }],
1523 maximum_container_uid: 0,
1524 maximum_container_gid: 0,
1525 };
1526
1527 prepare_rootfs_ownership(directory.path(), &plan, 0, false).unwrap();
1528
1529 assert_eq!(
1530 std::fs::metadata(hosts).unwrap().permissions().mode() & 0o7777,
1531 0o644
1532 );
1533 assert_eq!(
1534 std::fs::metadata(probe).unwrap().permissions().mode() & 0o7777,
1535 0o600
1536 );
1537 assert_eq!(
1538 std::fs::metadata(init).unwrap().permissions().mode() & 0o7777,
1539 0o755
1540 );
1541 }
1542}