Skip to main content

a3s_box_runtime/sandbox/
rootfs.rs

1//! Host-side rootfs ownership preparation for user-namespace execution.
2
3#[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/// Container IDs discovered in the authoritative rootfs metadata manifest.
33#[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/// Persist the exact user-namespace mapping needed to translate a stopped
41/// Sandbox rootfs back to container ownership after an interruption or during
42/// a filesystem Snapshot.
43#[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/// Load a persisted rootfs mapping, rejecting links, oversized artifacts,
76/// malformed JSON, and invalid ranges before it can influence host ownership
77/// translation.
78#[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/// Rebuild one replay manifest from a stopped Sandbox generation.
116///
117/// guest-init consumes the image or previous-generation manifest before it
118/// executes the workload. If the provider is then killed, no terminal manifest
119/// can be published. The persisted mapping remains outside the guest rootfs and
120/// lets Box reverse the stopped host IDs into their exact container identities,
121/// including files and ownership changes created during the interrupted run.
122#[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
170/// Host IDs representing container root for one mapping plan.
171pub 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/// Make an A3S-owned workspace or anonymous volume accessible as container
179/// root without ever changing an arbitrary caller-provided host tree.
180#[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/// Verify that an external bind source and every parent needed to resolve it
195/// are usable by the mapped root identity. The runtime refuses to chown
196/// external host data implicitly.
197#[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/// Verify only the mounted object's own access bits.
227///
228/// Read-only attachment aliases call this with metadata obtained from an open
229/// file descriptor. Their caller-owned parents deliberately remain private;
230/// the Box-owned alias supplies the separately validated resolution path.
231#[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/// Validate and prepare one Runtime-owned Secret file without granting Box
282/// ownership over any other external bind source.
283#[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
420/// Read the terminal persistent manifest when present, otherwise the immutable
421/// image manifest. Fresh image generations select the immutable manifest through
422/// the preference-aware variant below.
423pub 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/// Prepare one per-box rootfs for the exact user-namespace mapping.
452///
453/// A root-run service can translate OCI container ownership to subordinate
454/// host IDs directly. A non-root service leaves ownership replay to PID 1 from
455/// inside the user namespace. Read-only rootfs is rejected for the latter until
456/// an idmapped-mount path can guarantee replay before the read-only transition.
457#[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    // Files written by the runtime after manifest generation (DNS, hostname,
510    // env staging, refreshed init, and the manifests themselves) are not all
511    // represented in the selected generation. Walk without following symlinks:
512    // already-mapped IDs are left untouched, while raw OCI IDs are translated.
513    shift_unlisted_entries(root, root, &authoritative_paths, plan)?;
514
515    // chown clears setuid/setgid bits on regular files. Restore exact manifest
516    // modes deepest-first after every ownership change.
517    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/// Capture authoritative guest-visible metadata for a stopped or quiesced
541/// Sandbox rootfs.
542///
543/// The host sees user-namespace IDs, so every UID/GID is translated back
544/// through the exact OCI mappings before the manifest is stored in a
545/// filesystem Snapshot. The walk never follows symlinks and rejects special
546/// files, preventing a FIFO or device node from entering the copy path.
547#[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    // Layout preparation can refresh Box-owned guest files before an
657    // interrupted generation has published its terminal manifest. Those
658    // writes run as host root, so they legitimately carry raw ID zero rather
659    // than the previous generation's subordinate ID. Accept that identity
660    // only for the exact runtime-managed paths; every guest-controlled path
661    // remains fail-closed below.
662    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        // A freshly composed rootfs must not trust lifecycle markers that an
707        // image layer may have baked into the filesystem.
708        [image, terminal, previous]
709    } else {
710        // The CLI moves the clean-shutdown marker to `previous` before boot.
711        // Retain support for direct runtime callers that have not staged the
712        // legacy terminal marker yet; guest-init performs the one-shot cleanup
713        // only after replay succeeds.
714        [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        // Manifests are produced and consumed on the same host, so the encoded
833        // platform path bytes can be reconstructed losslessly.
834        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(&current)
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}