Skip to main content

nucleus/filesystem/
mount.rs

1use super::attestation::{is_immediate_nix_store_object_path, ROOTFS_STORE_PATHS_FILE};
2use crate::error::{NucleusError, Result};
3use nix::fcntl::{open, OFlag};
4use nix::mount::{mount, MsFlags};
5use nix::sys::stat::{fstat, makedev, mknod, Mode, SFlag};
6use nix::unistd::chroot;
7use std::fs::OpenOptions;
8use std::io::Read;
9use std::os::fd::AsRawFd;
10use std::os::unix::fs::OpenOptionsExt;
11use std::path::{Component, Path, PathBuf};
12use tracing::{debug, info, warn};
13
14/// Expected mount flags for audit verification.
15struct ExpectedMount {
16    path: &'static str,
17    required_flags: &'static [&'static str],
18    /// If true, the mount *must* exist in production mode. A missing critical
19    /// mount (e.g. /proc) is treated as a violation rather than silently skipped.
20    critical: bool,
21}
22
23/// Known mount paths and the flags they must carry in production mode.
24const PRODUCTION_MOUNT_EXPECTATIONS: &[ExpectedMount] = &[
25    ExpectedMount {
26        path: "/bin",
27        required_flags: &["ro", "nosuid", "nodev"],
28        critical: true,
29    },
30    ExpectedMount {
31        path: "/usr",
32        required_flags: &["ro", "nosuid", "nodev"],
33        critical: true,
34    },
35    ExpectedMount {
36        path: "/lib",
37        required_flags: &["ro", "nosuid", "nodev"],
38        critical: false, // not all rootfs layouts have /lib
39    },
40    ExpectedMount {
41        path: "/lib64",
42        required_flags: &["ro", "nosuid", "nodev"],
43        critical: false, // not all rootfs layouts have /lib64
44    },
45    ExpectedMount {
46        path: "/etc",
47        required_flags: &["ro", "nosuid", "nodev"],
48        critical: true,
49    },
50    ExpectedMount {
51        path: "/nix",
52        required_flags: &["ro", "nosuid", "nodev"],
53        critical: false, // only present on NixOS-based rootfs
54    },
55    ExpectedMount {
56        path: "/sbin",
57        required_flags: &["ro", "nosuid", "nodev"],
58        critical: false, // not all rootfs layouts have /sbin
59    },
60    ExpectedMount {
61        path: "/proc",
62        required_flags: &["nosuid", "nodev", "noexec"],
63        critical: true,
64    },
65    ExpectedMount {
66        path: "/run/secrets",
67        required_flags: &["nosuid", "nodev", "noexec"],
68        critical: false, // only present when secrets are configured
69    },
70];
71
72/// Normalize an absolute container destination path and reject traversal.
73///
74/// Returns a normalized absolute path containing only `RootDir` and `Normal`
75/// components. `.` segments are ignored; `..` and relative paths are rejected.
76pub fn normalize_container_destination(dest: &Path) -> Result<PathBuf> {
77    if !dest.is_absolute() {
78        return Err(NucleusError::ConfigError(format!(
79            "Container destination must be absolute: {:?}",
80            dest
81        )));
82    }
83
84    let mut normalized = PathBuf::from("/");
85    let mut saw_component = false;
86
87    for component in dest.components() {
88        match component {
89            Component::RootDir => {}
90            Component::CurDir => {}
91            Component::Normal(part) => {
92                normalized.push(part);
93                saw_component = true;
94            }
95            Component::ParentDir => {
96                return Err(NucleusError::ConfigError(format!(
97                    "Container destination must not contain parent traversal: {:?}",
98                    dest
99                )));
100            }
101            Component::Prefix(_) => {
102                return Err(NucleusError::ConfigError(format!(
103                    "Unsupported container destination prefix: {:?}",
104                    dest
105                )));
106            }
107        }
108    }
109
110    if !saw_component {
111        return Err(NucleusError::ConfigError(format!(
112            "Container destination must not be the root directory: {:?}",
113            dest
114        )));
115    }
116
117    Ok(normalized)
118}
119
120/// Resolve a validated container destination under a host-side root directory.
121pub fn resolve_container_destination(root: &Path, dest: &Path) -> Result<PathBuf> {
122    let normalized = normalize_container_destination(dest)?;
123    let relative = normalized.strip_prefix("/").map_err(|_| {
124        NucleusError::ConfigError(format!(
125            "Container destination is not absolute after normalization: {:?}",
126            normalized
127        ))
128    })?;
129    Ok(root.join(relative))
130}
131
132fn validate_rootfs_path_under_store(rootfs_path: &Path, store_root: &Path) -> Result<PathBuf> {
133    if !rootfs_path.is_absolute() {
134        return Err(NucleusError::ConfigError(format!(
135            "Production rootfs path must be absolute: {}",
136            rootfs_path.display()
137        )));
138    }
139
140    for component in rootfs_path.components() {
141        match component {
142            Component::ParentDir => {
143                return Err(NucleusError::ConfigError(format!(
144                    "Production rootfs path must not contain parent traversal: {}",
145                    rootfs_path.display()
146                )));
147            }
148            Component::Prefix(_) => {
149                return Err(NucleusError::ConfigError(format!(
150                    "Unsupported production rootfs path prefix: {}",
151                    rootfs_path.display()
152                )));
153            }
154            Component::RootDir | Component::CurDir | Component::Normal(_) => {}
155        }
156    }
157
158    let canonical = std::fs::canonicalize(rootfs_path).map_err(|e| {
159        NucleusError::ConfigError(format!(
160            "Failed to canonicalize production rootfs path '{}': {}",
161            rootfs_path.display(),
162            e
163        ))
164    })?;
165
166    if !canonical.starts_with(store_root) {
167        return Err(NucleusError::ConfigError(format!(
168            "Production mode requires rootfs path to resolve under {}: {} -> {}",
169            store_root.display(),
170            rootfs_path.display(),
171            canonical.display()
172        )));
173    }
174
175    if !canonical.is_dir() {
176        return Err(NucleusError::ConfigError(format!(
177            "Production rootfs path must resolve to a directory: {}",
178            canonical.display()
179        )));
180    }
181
182    Ok(canonical)
183}
184
185/// Validate a production rootfs path and return the canonical path to use.
186///
187/// Production rootfs paths must not contain parent traversal, and the resolved
188/// target must be a directory under the immutable Nix store.
189pub fn validate_production_rootfs_path(rootfs_path: &Path) -> Result<PathBuf> {
190    validate_rootfs_path_under_store(rootfs_path, Path::new("/nix/store"))
191}
192
193/// Validate an agent toolchain rootfs path and return the canonical path to use.
194///
195/// Agent toolchain rootfs paths are not restricted to `/nix/store` because
196/// local development and tests may build them elsewhere, but they must be
197/// absolute, traversal-free, and resolve to an existing directory.
198pub fn validate_agent_toolchain_rootfs_path(rootfs_path: &Path) -> Result<PathBuf> {
199    if !rootfs_path.is_absolute() {
200        return Err(NucleusError::ConfigError(format!(
201            "Agent toolchain rootfs path must be absolute: {}",
202            rootfs_path.display()
203        )));
204    }
205
206    for component in rootfs_path.components() {
207        match component {
208            Component::ParentDir => {
209                return Err(NucleusError::ConfigError(format!(
210                    "Agent toolchain rootfs path must not contain parent traversal: {}",
211                    rootfs_path.display()
212                )));
213            }
214            Component::Prefix(_) => {
215                return Err(NucleusError::ConfigError(format!(
216                    "Unsupported agent toolchain rootfs path prefix: {}",
217                    rootfs_path.display()
218                )));
219            }
220            Component::RootDir | Component::CurDir | Component::Normal(_) => {}
221        }
222    }
223
224    let canonical = std::fs::canonicalize(rootfs_path).map_err(|e| {
225        NucleusError::ConfigError(format!(
226            "Failed to canonicalize agent toolchain rootfs path '{}': {}",
227            rootfs_path.display(),
228            e
229        ))
230    })?;
231
232    if !canonical.is_dir() {
233        return Err(NucleusError::ConfigError(format!(
234            "Agent toolchain rootfs path must resolve to a directory: {}",
235            canonical.display()
236        )));
237    }
238
239    Ok(canonical)
240}
241
242pub(crate) fn read_regular_file_nofollow(path: &Path) -> Result<Vec<u8>> {
243    let mut file = OpenOptions::new()
244        .read(true)
245        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
246        .open(path)
247        .map_err(|e| {
248            NucleusError::FilesystemError(format!(
249                "Failed to open file {:?} with O_NOFOLLOW: {}",
250                path, e
251            ))
252        })?;
253
254    let metadata = file.metadata().map_err(|e| {
255        NucleusError::FilesystemError(format!("Failed to stat file {:?}: {}", path, e))
256    })?;
257    if !metadata.is_file() {
258        return Err(NucleusError::FilesystemError(format!(
259            "Expected regular file for {:?}, found non-file source",
260            path
261        )));
262    }
263
264    let mut content = Vec::new();
265    file.read_to_end(&mut content).map_err(|e| {
266        NucleusError::FilesystemError(format!("Failed to read file {:?}: {}", path, e))
267    })?;
268    Ok(content)
269}
270
271fn decode_mountinfo_field(field: &str) -> String {
272    let mut decoded = String::with_capacity(field.len());
273    let mut chars = field.chars().peekable();
274
275    while let Some(ch) = chars.next() {
276        if ch == '\\' {
277            let code: String = chars.by_ref().take(3).collect();
278            match code.as_str() {
279                "040" => decoded.push(' '),
280                "011" => decoded.push('\t'),
281                "012" => decoded.push('\n'),
282                "134" => decoded.push('\\'),
283                _ => {
284                    decoded.push('\\');
285                    decoded.push_str(&code);
286                }
287            }
288        } else {
289            decoded.push(ch);
290        }
291    }
292
293    decoded
294}
295
296fn parse_mountinfo_line(line: &str) -> Option<(String, std::collections::HashSet<String>)> {
297    let (left, _) = line.split_once(" - ")?;
298    let fields: Vec<&str> = left.split_whitespace().collect();
299    if fields.len() < 6 {
300        return None;
301    }
302
303    let mount_point = decode_mountinfo_field(fields[4]);
304    let options = fields[5]
305        .split(',')
306        .map(str::trim)
307        .filter(|opt| !opt.is_empty())
308        .map(str::to_string)
309        .collect();
310
311    Some((mount_point, options))
312}
313
314/// Audit all mounts in the container's mount namespace.
315///
316/// Reads `/proc/self/mountinfo` and verifies that each known mount point carries
317/// its expected per-mount flags. In production mode, any missing flag is fatal.
318/// Returns Ok(()) if all checks pass, or a list of violations.
319pub fn audit_mounts(production_mode: bool) -> Result<()> {
320    let mounts_content = std::fs::read_to_string("/proc/self/mountinfo").map_err(|e| {
321        NucleusError::FilesystemError(format!("Failed to read /proc/self/mountinfo: {}", e))
322    })?;
323    let mount_table: std::collections::HashMap<String, std::collections::HashSet<String>> =
324        mounts_content
325            .lines()
326            .filter_map(parse_mountinfo_line)
327            .collect();
328
329    let mut violations = Vec::new();
330
331    for expectation in PRODUCTION_MOUNT_EXPECTATIONS {
332        if let Some(options) = mount_table.get(expectation.path) {
333            for &flag in expectation.required_flags {
334                if !options.contains(flag) {
335                    let rendered = options
336                        .iter()
337                        .map(String::as_str)
338                        .collect::<Vec<_>>()
339                        .join(",");
340                    violations.push(format!(
341                        "Mount {} missing required flag '{}' (has: {})",
342                        expectation.path, flag, rendered
343                    ));
344                }
345            }
346        } else if expectation.critical && production_mode {
347            violations.push(format!(
348                "Critical mount {} is missing from the mount namespace",
349                expectation.path
350            ));
351        }
352    }
353
354    if violations.is_empty() {
355        info!("Mount audit passed: all expected flags verified");
356        Ok(())
357    } else if production_mode {
358        Err(NucleusError::FilesystemError(format!(
359            "Mount audit failed in production mode:\n  {}",
360            violations.join("\n  ")
361        )))
362    } else {
363        for v in &violations {
364            warn!("Mount audit: {}", v);
365        }
366        Ok(())
367    }
368}
369
370/// Create minimal filesystem structure in the new root
371pub fn create_minimal_fs(root: &Path) -> Result<()> {
372    info!("Creating minimal filesystem structure at {:?}", root);
373
374    // Create essential directories
375    let dirs = vec![
376        "dev",
377        "proc",
378        "sys",
379        "tmp",
380        "bin",
381        "sbin",
382        "usr",
383        "lib",
384        "lib64",
385        "etc",
386        "nix",
387        "nix/store",
388        "run",
389        "context",
390        "workspace",
391        "home",
392        "home/agent",
393    ];
394
395    for dir in dirs {
396        let path = root.join(dir);
397        std::fs::create_dir_all(&path).map_err(|e| {
398            NucleusError::FilesystemError(format!("Failed to create directory {:?}: {}", path, e))
399        })?;
400    }
401
402    info!("Created minimal filesystem structure");
403
404    Ok(())
405}
406
407/// Create essential device nodes in /dev
408///
409/// In rootless mode, device node creation will fail gracefully
410pub fn create_dev_nodes(dev_path: &Path, include_tty: bool) -> Result<()> {
411    info!("Creating device nodes at {:?}", dev_path);
412
413    // Device nodes: (name, type, major, minor)
414    let mut devices = vec![
415        ("null", SFlag::S_IFCHR, 1, 3),
416        ("zero", SFlag::S_IFCHR, 1, 5),
417        ("full", SFlag::S_IFCHR, 1, 7),
418        ("random", SFlag::S_IFCHR, 1, 8),
419        ("urandom", SFlag::S_IFCHR, 1, 9),
420    ];
421    if include_tty {
422        devices.push(("tty", SFlag::S_IFCHR, 5, 0));
423    }
424
425    let mut created_count = 0;
426    let mut failed_count = 0;
427
428    for (name, dev_type, major, minor) in devices {
429        let path = dev_path.join(name);
430        let mode = Mode::from_bits_truncate(0o660);
431        let dev = makedev(major, minor);
432
433        match mknod(&path, dev_type, mode, dev) {
434            Ok(_) => {
435                info!("Created device node: {:?}", path);
436                created_count += 1;
437            }
438            Err(e) => {
439                // In rootless mode, mknod fails - this is expected
440                warn!(
441                    "Failed to create device node {:?}: {} (this is normal in rootless mode)",
442                    path, e
443                );
444                failed_count += 1;
445            }
446        }
447    }
448
449    if created_count > 0 {
450        info!("Successfully created {} device nodes", created_count);
451    }
452    if failed_count > 0 {
453        info!("Skipped {} device nodes (rootless mode)", failed_count);
454    }
455
456    Ok(())
457}
458
459/// Bind mount a pre-built rootfs (e.g. a Nix store closure) into the container.
460///
461/// Instead of exposing the full host /bin, /usr, /lib, /lib64, /nix, this mounts
462/// a minimal, purpose-built root filesystem. Suitable for production services.
463pub fn bind_mount_rootfs(root: &Path, rootfs_path: &Path) -> Result<()> {
464    info!(
465        "Bind mounting production rootfs {:?} into container {:?}",
466        rootfs_path, root
467    );
468
469    if std::fs::symlink_metadata(rootfs_path).is_err() {
470        return Err(NucleusError::FilesystemError(format!(
471            "Rootfs path does not exist: {:?}",
472            rootfs_path
473        )));
474    }
475
476    // Bind mount the rootfs contents into the container root.
477    // The rootfs is expected to contain a standard FHS layout (/bin, /lib, /etc, etc.)
478    // produced by a Nix buildEnv or similar.
479    let subdirs = ["bin", "sbin", "lib", "lib64", "usr", "etc", "nix"];
480
481    for subdir in &subdirs {
482        let source = rootfs_path.join(subdir);
483        if !source.exists() {
484            debug!("Rootfs subdir {} not present, skipping", subdir);
485            continue;
486        }
487        let source = std::fs::canonicalize(&source).map_err(|e| {
488            NucleusError::FilesystemError(format!(
489                "Failed to canonicalize rootfs mount source {:?}: {}",
490                source, e
491            ))
492        })?;
493
494        let target = root.join(subdir);
495        std::fs::create_dir_all(&target).map_err(|e| {
496            NucleusError::FilesystemError(format!(
497                "Failed to create mount point {:?}: {}",
498                target, e
499            ))
500        })?;
501
502        mount(
503            Some(&source),
504            &target,
505            None::<&str>,
506            MsFlags::MS_BIND | MsFlags::MS_REC,
507            None::<&str>,
508        )
509        .map_err(|e| {
510            NucleusError::FilesystemError(format!(
511                "Failed to bind mount rootfs {:?} -> {:?}: {}",
512                source, target, e
513            ))
514        })?;
515
516        // Remount read-only
517        mount(
518            None::<&str>,
519            &target,
520            None::<&str>,
521            MsFlags::MS_REMOUNT
522                | MsFlags::MS_BIND
523                | MsFlags::MS_RDONLY
524                | MsFlags::MS_REC
525                | MsFlags::MS_NOSUID
526                | MsFlags::MS_NODEV,
527            None::<&str>,
528        )
529        .map_err(|e| {
530            NucleusError::FilesystemError(format!(
531                "Failed to remount rootfs {:?} read-only: {}",
532                target, e
533            ))
534        })?;
535
536        info!("Mounted rootfs/{} read-only", subdir);
537    }
538
539    bind_mount_rootfs_store_paths(root, rootfs_path)?;
540
541    Ok(())
542}
543
544/// Mount a pre-built rootfs through overlayfs with a persistent writable upperdir.
545///
546/// The lowerdir is the Nix-built rootfs. Individual `/nix/store/<hash>-name`
547/// paths are re-bound read-only after the overlay mount, preserving the same
548/// store-object validation as `bind_mount_rootfs`.
549pub fn overlay_mount_rootfs(
550    root: &Path,
551    rootfs_path: &Path,
552    upperdir: &Path,
553    workdir: &Path,
554) -> Result<()> {
555    info!(
556        "Overlay mounting rootfs {:?} into container {:?} (upper={:?}, work={:?})",
557        rootfs_path, root, upperdir, workdir
558    );
559
560    let lowerdir = validate_overlay_mount_dir(rootfs_path, "rootfs lowerdir")?;
561    let upperdir = validate_overlay_mount_dir(upperdir, "rootfs overlay upperdir")?;
562    let workdir = validate_overlay_mount_dir(workdir, "rootfs overlay workdir")?;
563    validate_overlay_mount_dir(root, "container root")?;
564
565    let options = format!(
566        "lowerdir={},upperdir={},workdir={}",
567        overlay_option_path(&lowerdir, "rootfs lowerdir")?,
568        overlay_option_path(&upperdir, "rootfs overlay upperdir")?,
569        overlay_option_path(&workdir, "rootfs overlay workdir")?
570    );
571
572    mount(
573        Some("overlay"),
574        root,
575        Some("overlay"),
576        MsFlags::MS_NOSUID | MsFlags::MS_NODEV,
577        Some(options.as_str()),
578    )
579    .map_err(|e| {
580        NucleusError::FilesystemError(format!(
581            "Failed to overlay mount rootfs {:?} -> {:?}: {}",
582            lowerdir, root, e
583        ))
584    })?;
585
586    bind_mount_rootfs_store_paths(root, rootfs_path)?;
587
588    Ok(())
589}
590
591fn validate_overlay_mount_dir(path: &Path, label: &str) -> Result<PathBuf> {
592    let metadata = std::fs::symlink_metadata(path).map_err(|e| {
593        NucleusError::FilesystemError(format!("Failed to stat {} {:?}: {}", label, path, e))
594    })?;
595    if metadata.file_type().is_symlink() {
596        return Err(NucleusError::FilesystemError(format!(
597            "{} must not be a symlink: {:?}",
598            label, path
599        )));
600    }
601    if !metadata.is_dir() {
602        return Err(NucleusError::FilesystemError(format!(
603            "{} must be a directory: {:?}",
604            label, path
605        )));
606    }
607    std::fs::canonicalize(path).map_err(|e| {
608        NucleusError::FilesystemError(format!(
609            "Failed to canonicalize {} {:?}: {}",
610            label, path, e
611        ))
612    })
613}
614
615fn overlay_option_path(path: &Path, label: &str) -> Result<String> {
616    let rendered = path.to_str().ok_or_else(|| {
617        NucleusError::FilesystemError(format!("{} path is not valid UTF-8: {:?}", label, path))
618    })?;
619    if rendered.contains(',') || rendered.contains(':') {
620        return Err(NucleusError::FilesystemError(format!(
621            "{} path contains a character unsupported in overlay mount options: {}",
622            label, rendered
623        )));
624    }
625    Ok(rendered.to_string())
626}
627
628fn bind_mount_rootfs_store_paths(root: &Path, rootfs_path: &Path) -> Result<()> {
629    let store_paths_file = rootfs_path.join(ROOTFS_STORE_PATHS_FILE);
630    if !store_paths_file.exists() {
631        return Ok(());
632    }
633
634    let content = std::fs::read_to_string(&store_paths_file).map_err(|e| {
635        NucleusError::FilesystemError(format!(
636            "Failed to read rootfs store paths {:?}: {}",
637            store_paths_file, e
638        ))
639    })?;
640
641    for (line_no, line) in content.lines().enumerate() {
642        let source = line.trim();
643        if source.is_empty() {
644            continue;
645        }
646        let source = Path::new(source);
647        if !is_immediate_nix_store_object_path(source) {
648            return Err(NucleusError::FilesystemError(format!(
649                "Invalid rootfs store path on line {} in {:?}: {} (expected immediate /nix/store/<32-char-hash>-<name> object)",
650                line_no + 1,
651                store_paths_file,
652                source.display()
653            )));
654        }
655        let store_name = source.file_name().ok_or_else(|| {
656            NucleusError::FilesystemError(format!(
657                "Invalid rootfs store path on line {} in {:?}: {}",
658                line_no + 1,
659                store_paths_file,
660                source.display()
661            ))
662        })?;
663        let source = std::fs::canonicalize(source).map_err(|e| {
664            NucleusError::FilesystemError(format!(
665                "Failed to canonicalize rootfs store path {:?}: {}",
666                source, e
667            ))
668        })?;
669        if !is_immediate_nix_store_object_path(&source) {
670            return Err(NucleusError::FilesystemError(format!(
671                "Rootfs store path must resolve to an immediate /nix/store/<32-char-hash>-<name> object: {}",
672                source.display()
673            )));
674        }
675
676        let target = root.join("nix/store").join(store_name);
677        std::fs::create_dir_all(&target).map_err(|e| {
678            NucleusError::FilesystemError(format!(
679                "Failed to create rootfs store mount point {:?}: {}",
680                target, e
681            ))
682        })?;
683
684        mount(
685            Some(&source),
686            &target,
687            None::<&str>,
688            MsFlags::MS_BIND | MsFlags::MS_REC,
689            None::<&str>,
690        )
691        .map_err(|e| {
692            NucleusError::FilesystemError(format!(
693                "Failed to bind mount rootfs store path {:?} -> {:?}: {}",
694                source, target, e
695            ))
696        })?;
697
698        mount(
699            None::<&str>,
700            &target,
701            None::<&str>,
702            MsFlags::MS_REMOUNT
703                | MsFlags::MS_BIND
704                | MsFlags::MS_RDONLY
705                | MsFlags::MS_REC
706                | MsFlags::MS_NOSUID
707                | MsFlags::MS_NODEV,
708            None::<&str>,
709        )
710        .map_err(|e| {
711            NucleusError::FilesystemError(format!(
712                "Failed to remount rootfs store path {:?} read-only: {}",
713                target, e
714            ))
715        })?;
716    }
717
718    Ok(())
719}
720
721/// Bind mount essential host directories into container
722///
723/// This allows host binaries to be accessible inside the container.
724/// Used in agent mode. Production mode should use bind_mount_rootfs() instead.
725pub fn bind_mount_host_paths(root: &Path, best_effort: bool) -> Result<()> {
726    info!("Bind mounting host paths into container");
727
728    // Essential paths to bind mount (read-only)
729    let host_paths = vec![
730        "/bin", "/usr", "/lib", "/lib64", "/nix", // For NixOS
731    ];
732
733    for host_path in host_paths {
734        let host = Path::new(host_path);
735
736        // Only mount if the path exists on the host
737        if !host.exists() {
738            debug!("Skipping {} (not present on host)", host_path);
739            continue;
740        }
741
742        let container_path = root.join(host_path.trim_start_matches('/'));
743
744        // Create mount point
745        if let Err(e) = std::fs::create_dir_all(&container_path) {
746            if best_effort {
747                warn!("Failed to create mount point {:?}: {}", container_path, e);
748                continue;
749            }
750            return Err(NucleusError::FilesystemError(format!(
751                "Failed to create mount point {:?}: {}",
752                container_path, e
753            )));
754        }
755
756        // Attempt bind mount
757        // Note: Linux ignores MS_RDONLY on the initial bind mount call.
758        // A second remount is required to actually enforce read-only.
759        match mount(
760            Some(host),
761            &container_path,
762            None::<&str>,
763            MsFlags::MS_BIND | MsFlags::MS_REC,
764            None::<&str>,
765        ) {
766            Ok(_) => {
767                // Remount as read-only – required because MS_RDONLY is ignored on initial bind
768                mount(
769                    None::<&str>,
770                    &container_path,
771                    None::<&str>,
772                    MsFlags::MS_REMOUNT
773                        | MsFlags::MS_BIND
774                        | MsFlags::MS_RDONLY
775                        | MsFlags::MS_REC
776                        | MsFlags::MS_NOSUID
777                        | MsFlags::MS_NODEV,
778                    None::<&str>,
779                )
780                .map_err(|e| {
781                    NucleusError::FilesystemError(format!(
782                        "Failed to remount {} as read-only: {}",
783                        host_path, e
784                    ))
785                })?;
786                info!(
787                    "Bind mounted {} to {:?} (read-only)",
788                    host_path, container_path
789                );
790            }
791            Err(e) => {
792                if best_effort {
793                    warn!(
794                        "Failed to bind mount {}: {} (continuing anyway)",
795                        host_path, e
796                    );
797                } else {
798                    return Err(NucleusError::FilesystemError(format!(
799                        "Failed to bind mount {}: {}",
800                        host_path, e
801                    )));
802                }
803            }
804        }
805    }
806
807    Ok(())
808}
809
810/// H7: Sensitive host paths that must not be bind-mounted into containers.
811const DENIED_BIND_MOUNT_SOURCES_EXACT: &[&str] = &["/"];
812
813/// Sensitive host subtrees that must not be exposed to a container at all.
814const DENIED_BIND_MOUNT_SOURCE_PREFIXES: &[&str] = &[
815    "/boot", "/dev", "/etc", "/home", "/proc", "/root", "/run", "/sys", "/var/log", "/var/run",
816];
817
818/// Sensitive host paths still denied for explicit provider config mounts.
819///
820/// Provider config mounts intentionally allow specific host-home credential
821/// directories (for example `$HOME/.aws`) that generic volumes reject, but they
822/// must not become a broad host filesystem escape hatch.
823const DENIED_PROVIDER_CONFIG_SOURCE_EXACT: &[&str] = &["/", "/home", "/root"];
824
825const DENIED_PROVIDER_CONFIG_SOURCE_PREFIXES: &[&str] = &[
826    "/boot", "/dev", "/etc", "/proc", "/run", "/sys", "/var/log", "/var/run",
827];
828
829/// Container destinations where user-supplied volumes must not be mounted.
830///
831/// These paths carry trusted runtime/rootfs state. Allowing a volume over them
832/// would let a caller replace attested container contents or pseudo-filesystems
833/// after validation has completed.
834const RESERVED_VOLUME_DESTINATION_PREFIXES: &[&str] = &[
835    "/bin",
836    "/boot",
837    "/dev",
838    "/etc",
839    "/lib",
840    "/lib64",
841    "/nix",
842    "/proc",
843    "/run/secrets",
844    "/sbin",
845    "/sys",
846    "/usr",
847];
848
849fn normalize_bind_mount_source_for_policy(source: &Path) -> Result<PathBuf> {
850    if !source.is_absolute() {
851        return Err(NucleusError::ConfigError(format!(
852            "Bind mount source must be absolute: {:?}",
853            source
854        )));
855    }
856
857    let mut normalized = PathBuf::from("/");
858
859    for component in source.components() {
860        match component {
861            Component::RootDir => {}
862            Component::CurDir => {}
863            Component::Normal(part) => normalized.push(part),
864            Component::ParentDir => {
865                normalized.pop();
866                if normalized.as_os_str().is_empty() {
867                    normalized.push("/");
868                }
869            }
870            Component::Prefix(_) => {
871                return Err(NucleusError::ConfigError(format!(
872                    "Unsupported bind mount source prefix: {:?}",
873                    source
874                )));
875            }
876        }
877    }
878
879    Ok(normalized)
880}
881
882fn reject_denied_bind_mount_source(source: &Path) -> Result<()> {
883    for denied in DENIED_BIND_MOUNT_SOURCES_EXACT {
884        if source == Path::new(denied) {
885            return Err(NucleusError::ConfigError(format!(
886                "Bind mount source '{}' is a sensitive host path and cannot be mounted into containers",
887                source.display()
888            )));
889        }
890    }
891
892    for denied in DENIED_BIND_MOUNT_SOURCE_PREFIXES {
893        let denied_path = Path::new(denied);
894        if source == denied_path || source.starts_with(denied_path) {
895            return Err(NucleusError::ConfigError(format!(
896                "Bind mount source '{}' is under sensitive host path '{}' and cannot be mounted into containers",
897                source.display(),
898                denied
899            )));
900        }
901    }
902
903    Ok(())
904}
905
906fn reject_denied_provider_config_source(source: &Path) -> Result<()> {
907    for denied in DENIED_PROVIDER_CONFIG_SOURCE_EXACT {
908        if source == Path::new(denied) {
909            return Err(NucleusError::ConfigError(format!(
910                "Provider config source '{}' is too broad; mount a specific provider config file or directory",
911                source.display()
912            )));
913        }
914    }
915
916    if source.parent() == Some(Path::new("/home")) {
917        return Err(NucleusError::ConfigError(format!(
918            "Provider config source '{}' is a whole host home directory; mount a specific provider config subdirectory",
919            source.display()
920        )));
921    }
922
923    for denied in DENIED_PROVIDER_CONFIG_SOURCE_PREFIXES {
924        let denied_path = Path::new(denied);
925        if source == denied_path || source.starts_with(denied_path) {
926            return Err(NucleusError::ConfigError(format!(
927                "Provider config source '{}' is under sensitive host path '{}' and cannot be mounted into containers",
928                source.display(),
929                denied
930            )));
931        }
932    }
933
934    Ok(())
935}
936
937/// Validate bind-mount source policy without requiring the source to exist.
938///
939/// Topology persistent volumes use this before creating missing host paths, so
940/// sensitive host locations are rejected before any mkdir/chown side effects.
941pub fn validate_bind_mount_source_policy(source: &Path) -> Result<PathBuf> {
942    let normalized = normalize_bind_mount_source_for_policy(source)?;
943    reject_denied_bind_mount_source(&normalized)?;
944    Ok(normalized)
945}
946
947/// Validate that a bind mount source exists and is not a sensitive host path or subtree.
948pub fn validate_bind_mount_source(source: &Path) -> Result<()> {
949    validate_bind_mount_source_policy(source)?;
950
951    let canonical = std::fs::canonicalize(source).map_err(|e| {
952        NucleusError::ConfigError(format!(
953            "Failed to resolve bind mount source {:?}: {}",
954            source, e
955        ))
956    })?;
957    reject_denied_bind_mount_source(&canonical)
958}
959
960/// Validate that an explicit provider config mount source exists and is narrow.
961pub fn validate_provider_config_source(source: &Path) -> Result<()> {
962    normalize_bind_mount_source_for_policy(source)?;
963
964    let canonical = std::fs::canonicalize(source).map_err(|e| {
965        NucleusError::ConfigError(format!(
966            "Failed to resolve provider config source {:?}: {}",
967            source, e
968        ))
969    })?;
970    let metadata = std::fs::metadata(&canonical).map_err(|e| {
971        NucleusError::ConfigError(format!(
972            "Failed to stat provider config source {:?}: {}",
973            canonical, e
974        ))
975    })?;
976    if !metadata.is_file() && !metadata.is_dir() {
977        return Err(NucleusError::ConfigError(format!(
978            "Provider config source '{}' must be a regular file or directory",
979            canonical.display()
980        )));
981    }
982
983    reject_denied_provider_config_source(&canonical)
984}
985
986fn reject_reserved_volume_destination(dest: &Path) -> Result<()> {
987    for reserved in RESERVED_VOLUME_DESTINATION_PREFIXES {
988        let reserved_path = Path::new(reserved);
989        if dest == reserved_path || dest.starts_with(reserved_path) {
990            return Err(NucleusError::ConfigError(format!(
991                "Volume destination '{}' is reserved for trusted container/runtime paths and cannot be overlaid",
992                dest.display()
993            )));
994        }
995    }
996
997    Ok(())
998}
999
1000/// Normalize and validate a user-supplied volume destination inside the container.
1001pub fn normalize_volume_destination(dest: &Path) -> Result<PathBuf> {
1002    let normalized = normalize_container_destination(dest)?;
1003    reject_reserved_volume_destination(&normalized)?;
1004    Ok(normalized)
1005}
1006
1007/// Normalize a provider config destination under the configured container home.
1008///
1009/// Absolute destinations must already be under `home`. Relative destinations
1010/// are resolved against `home`. The selected destination may not be the home
1011/// root itself; provider config mounts must target a file or subdirectory.
1012pub fn normalize_provider_config_destination(home: &Path, dest: &Path) -> Result<PathBuf> {
1013    let home = normalize_container_destination(home)?;
1014    let candidate = if dest.is_absolute() {
1015        normalize_container_destination(dest)?
1016    } else {
1017        let mut relative = PathBuf::new();
1018        for component in dest.components() {
1019            match component {
1020                Component::CurDir => {}
1021                Component::Normal(part) => relative.push(part),
1022                Component::ParentDir => {
1023                    return Err(NucleusError::ConfigError(format!(
1024                        "Provider config destination must not contain parent traversal: {:?}",
1025                        dest
1026                    )));
1027                }
1028                Component::RootDir | Component::Prefix(_) => {
1029                    return Err(NucleusError::ConfigError(format!(
1030                        "Unsupported provider config destination: {:?}",
1031                        dest
1032                    )));
1033                }
1034            }
1035        }
1036        if relative.as_os_str().is_empty() {
1037            return Err(NucleusError::ConfigError(format!(
1038                "Provider config destination must not be empty: {:?}",
1039                dest
1040            )));
1041        }
1042        normalize_container_destination(&home.join(relative))?
1043    };
1044
1045    if candidate == home || !candidate.starts_with(&home) {
1046        return Err(NucleusError::ConfigError(format!(
1047            "Provider config destination '{}' must be under configured home '{}'",
1048            candidate.display(),
1049            home.display()
1050        )));
1051    }
1052
1053    Ok(candidate)
1054}
1055
1056/// Resolve a validated user-supplied volume destination under a host-side root directory.
1057pub fn resolve_volume_destination(root: &Path, dest: &Path) -> Result<PathBuf> {
1058    let normalized = normalize_volume_destination(dest)?;
1059    let relative = normalized.strip_prefix("/").map_err(|_| {
1060        NucleusError::ConfigError(format!(
1061            "Volume destination is not absolute after normalization: {:?}",
1062            normalized
1063        ))
1064    })?;
1065    Ok(root.join(relative))
1066}
1067
1068/// Mount persistent bind volumes and ephemeral tmpfs volumes into the container root.
1069pub fn mount_volumes(root: &Path, volumes: &[crate::container::VolumeMount]) -> Result<()> {
1070    use crate::container::VolumeSource;
1071
1072    if volumes.is_empty() {
1073        return Ok(());
1074    }
1075
1076    info!("Mounting {} volume(s) into container", volumes.len());
1077
1078    for volume in volumes {
1079        let dest = resolve_volume_destination(root, &volume.dest)?;
1080
1081        match &volume.source {
1082            VolumeSource::Bind { source } => {
1083                // H7: Deny bind-mounting sensitive host paths
1084                validate_bind_mount_source(source)?;
1085
1086                // Use symlink_metadata (lstat) instead of .exists() to avoid
1087                // following symlinks in the existence check (O_NOFOLLOW semantics).
1088                if std::fs::symlink_metadata(source).is_err() {
1089                    return Err(NucleusError::FilesystemError(format!(
1090                        "Volume source does not exist: {:?}",
1091                        source
1092                    )));
1093                }
1094
1095                if let Some(parent) = dest.parent() {
1096                    std::fs::create_dir_all(parent).map_err(|e| {
1097                        NucleusError::FilesystemError(format!(
1098                            "Failed to create volume mount parent {:?}: {}",
1099                            parent, e
1100                        ))
1101                    })?;
1102                }
1103
1104                let recursive = source.is_dir();
1105                if source.is_file() {
1106                    std::fs::write(&dest, "").map_err(|e| {
1107                        NucleusError::FilesystemError(format!(
1108                            "Failed to create volume mount point {:?}: {}",
1109                            dest, e
1110                        ))
1111                    })?;
1112                } else {
1113                    std::fs::create_dir_all(&dest).map_err(|e| {
1114                        NucleusError::FilesystemError(format!(
1115                            "Failed to create volume mount dir {:?}: {}",
1116                            dest, e
1117                        ))
1118                    })?;
1119                }
1120
1121                let initial_flags = if recursive {
1122                    MsFlags::MS_BIND | MsFlags::MS_REC
1123                } else {
1124                    MsFlags::MS_BIND
1125                };
1126                mount(
1127                    Some(source.as_path()),
1128                    &dest,
1129                    None::<&str>,
1130                    initial_flags,
1131                    None::<&str>,
1132                )
1133                .map_err(|e| {
1134                    NucleusError::FilesystemError(format!(
1135                        "Failed to bind mount volume {:?} -> {:?}: {}",
1136                        source, dest, e
1137                    ))
1138                })?;
1139
1140                let mut remount_flags =
1141                    MsFlags::MS_REMOUNT | MsFlags::MS_BIND | MsFlags::MS_NOSUID | MsFlags::MS_NODEV;
1142                if recursive {
1143                    remount_flags |= MsFlags::MS_REC;
1144                }
1145                if volume.read_only {
1146                    remount_flags |= MsFlags::MS_RDONLY;
1147                }
1148
1149                mount(
1150                    None::<&str>,
1151                    &dest,
1152                    None::<&str>,
1153                    remount_flags,
1154                    None::<&str>,
1155                )
1156                .map_err(|e| {
1157                    NucleusError::FilesystemError(format!(
1158                        "Failed to remount volume {:?} with final flags: {}",
1159                        dest, e
1160                    ))
1161                })?;
1162
1163                info!(
1164                    "Mounted bind volume {:?} -> {:?} ({})",
1165                    source,
1166                    volume.dest,
1167                    if volume.read_only { "ro" } else { "rw" }
1168                );
1169            }
1170            VolumeSource::Tmpfs { size } => {
1171                std::fs::create_dir_all(&dest).map_err(|e| {
1172                    NucleusError::FilesystemError(format!(
1173                        "Failed to create tmpfs mount dir {:?}: {}",
1174                        dest, e
1175                    ))
1176                })?;
1177
1178                // M8: Validate size parameter to prevent option injection.
1179                // Only allow digits, optionally followed by K/M/G suffix.
1180                if let Some(value) = size.as_ref() {
1181                    let valid = value
1182                        .chars()
1183                        .all(|c| c.is_ascii_digit() || "kKmMgG".contains(c));
1184                    if !valid || value.is_empty() {
1185                        return Err(NucleusError::FilesystemError(format!(
1186                            "Invalid tmpfs size value '{}': only digits with optional K/M/G suffix allowed",
1187                            value
1188                        )));
1189                    }
1190                }
1191
1192                // M7: Default to 64MB instead of half of physical RAM to
1193                // prevent memory DoS from unbounded tmpfs volumes.
1194                let mount_data = size
1195                    .as_ref()
1196                    .map(|value| format!("size={},mode=0700", value))
1197                    .unwrap_or_else(|| "size=64M,mode=0700".to_string());
1198
1199                let mut flags = MsFlags::MS_NOSUID | MsFlags::MS_NODEV;
1200                if volume.read_only {
1201                    flags |= MsFlags::MS_RDONLY;
1202                }
1203                mount(
1204                    Some("tmpfs"),
1205                    &dest,
1206                    Some("tmpfs"),
1207                    flags,
1208                    Some(mount_data.as_str()),
1209                )
1210                .map_err(|e| {
1211                    NucleusError::FilesystemError(format!(
1212                        "Failed to mount tmpfs volume at {:?}: {}",
1213                        dest, e
1214                    ))
1215                })?;
1216
1217                info!(
1218                    "Mounted tmpfs volume at {:?}{}{}",
1219                    volume.dest,
1220                    size.as_ref()
1221                        .map(|value| format!(" (size={})", value))
1222                        .unwrap_or_default(),
1223                    if volume.read_only { " (ro)" } else { "" }
1224                );
1225            }
1226        }
1227    }
1228
1229    Ok(())
1230}
1231
1232/// Mount the private sandbox home tmpfs.
1233pub fn mount_home_tmpfs(
1234    root: &Path,
1235    home: &Path,
1236    identity: &crate::container::ProcessIdentity,
1237) -> Result<()> {
1238    let dest = resolve_container_destination(root, home)?;
1239    std::fs::create_dir_all(&dest).map_err(|e| {
1240        NucleusError::FilesystemError(format!(
1241            "Failed to create sandbox home mount dir {:?}: {}",
1242            dest, e
1243        ))
1244    })?;
1245
1246    let mount_data = format!(
1247        "size=256M,mode=0700,uid={},gid={}",
1248        identity.uid, identity.gid
1249    );
1250    mount(
1251        Some("tmpfs"),
1252        &dest,
1253        Some("tmpfs"),
1254        MsFlags::MS_NOSUID | MsFlags::MS_NODEV | MsFlags::MS_NOEXEC,
1255        Some(mount_data.as_str()),
1256    )
1257    .map_err(|e| {
1258        NucleusError::FilesystemError(format!(
1259            "Failed to mount sandbox home tmpfs at {:?}: {}",
1260            dest, e
1261        ))
1262    })?;
1263
1264    info!("Mounted sandbox home tmpfs at {:?}", home);
1265    Ok(())
1266}
1267
1268/// Mount explicit provider CLI config paths under the sandbox home.
1269pub fn mount_provider_configs(
1270    root: &Path,
1271    home: &Path,
1272    provider_configs: &[crate::container::ProviderConfigMount],
1273) -> Result<()> {
1274    if provider_configs.is_empty() {
1275        return Ok(());
1276    }
1277
1278    info!(
1279        "Mounting {} provider config path(s) into sandbox home",
1280        provider_configs.len()
1281    );
1282
1283    for provider_config in provider_configs {
1284        validate_provider_config_source(&provider_config.source)?;
1285        let canonical_source = std::fs::canonicalize(&provider_config.source).map_err(|e| {
1286            NucleusError::FilesystemError(format!(
1287                "Failed to canonicalize provider config source {:?}: {}",
1288                provider_config.source, e
1289            ))
1290        })?;
1291        let metadata = std::fs::metadata(&canonical_source).map_err(|e| {
1292            NucleusError::FilesystemError(format!(
1293                "Failed to stat provider config source {:?}: {}",
1294                canonical_source, e
1295            ))
1296        })?;
1297        let container_dest = normalize_provider_config_destination(home, &provider_config.dest)?;
1298        let dest = resolve_container_destination(root, &container_dest)?;
1299
1300        if let Some(parent) = dest.parent() {
1301            std::fs::create_dir_all(parent).map_err(|e| {
1302                NucleusError::FilesystemError(format!(
1303                    "Failed to create provider config parent {:?}: {}",
1304                    parent, e
1305                ))
1306            })?;
1307        }
1308
1309        if metadata.is_file() {
1310            std::fs::write(&dest, "").map_err(|e| {
1311                NucleusError::FilesystemError(format!(
1312                    "Failed to create provider config mount point {:?}: {}",
1313                    dest, e
1314                ))
1315            })?;
1316        } else {
1317            std::fs::create_dir_all(&dest).map_err(|e| {
1318                NucleusError::FilesystemError(format!(
1319                    "Failed to create provider config mount dir {:?}: {}",
1320                    dest, e
1321                ))
1322            })?;
1323        }
1324
1325        let recursive = metadata.is_dir();
1326        let initial_flags = if recursive {
1327            MsFlags::MS_BIND | MsFlags::MS_REC
1328        } else {
1329            MsFlags::MS_BIND
1330        };
1331        mount(
1332            Some(canonical_source.as_path()),
1333            &dest,
1334            None::<&str>,
1335            initial_flags,
1336            None::<&str>,
1337        )
1338        .map_err(|e| {
1339            NucleusError::FilesystemError(format!(
1340                "Failed to bind mount provider config {:?} -> {:?}: {}",
1341                canonical_source, dest, e
1342            ))
1343        })?;
1344
1345        let mut remount_flags = MsFlags::MS_REMOUNT
1346            | MsFlags::MS_BIND
1347            | MsFlags::MS_NOSUID
1348            | MsFlags::MS_NODEV
1349            | MsFlags::MS_NOEXEC;
1350        if recursive {
1351            remount_flags |= MsFlags::MS_REC;
1352        }
1353        if provider_config.read_only {
1354            remount_flags |= MsFlags::MS_RDONLY;
1355        }
1356
1357        mount(
1358            None::<&str>,
1359            &dest,
1360            None::<&str>,
1361            remount_flags,
1362            None::<&str>,
1363        )
1364        .map_err(|e| {
1365            NucleusError::FilesystemError(format!(
1366                "Failed to remount provider config {:?} with final flags: {}",
1367                dest, e
1368            ))
1369        })?;
1370
1371        info!(
1372            "Mounted provider config {:?} -> {:?} ({})",
1373            canonical_source,
1374            container_dest,
1375            if provider_config.read_only {
1376                "ro"
1377            } else {
1378                "rw"
1379            }
1380        );
1381    }
1382
1383    Ok(())
1384}
1385
1386/// Mount the first-class workspace into the container root.
1387pub fn mount_workspace(root: &Path, workspace: &crate::container::WorkspaceConfig) -> Result<()> {
1388    let Some(source) = workspace.effective_host_path() else {
1389        return Ok(());
1390    };
1391
1392    if workspace.mode == crate::container::WorkspaceMode::CopyInOut {
1393        let metadata = std::fs::symlink_metadata(source).map_err(|e| {
1394            NucleusError::FilesystemError(format!(
1395                "Failed to stat workspace staging path {:?}: {}",
1396                source, e
1397            ))
1398        })?;
1399        if metadata.file_type().is_symlink() || !metadata.is_dir() {
1400            return Err(NucleusError::FilesystemError(format!(
1401                "Workspace staging path must be a real directory: {:?}",
1402                source
1403            )));
1404        }
1405    } else {
1406        crate::filesystem::validate_workspace_host_path(source)?;
1407    }
1408    let dest = resolve_container_destination(root, &workspace.container_path)?;
1409    std::fs::create_dir_all(&dest).map_err(|e| {
1410        NucleusError::FilesystemError(format!(
1411            "Failed to create workspace mount point {:?}: {}",
1412            dest, e
1413        ))
1414    })?;
1415
1416    mount(
1417        Some(source.as_path()),
1418        &dest,
1419        None::<&str>,
1420        MsFlags::MS_BIND | MsFlags::MS_REC,
1421        None::<&str>,
1422    )
1423    .map_err(|e| {
1424        NucleusError::FilesystemError(format!(
1425            "Failed to bind mount workspace {:?} -> {:?}: {}",
1426            source, dest, e
1427        ))
1428    })?;
1429
1430    let mut remount_flags = MsFlags::MS_REMOUNT
1431        | MsFlags::MS_BIND
1432        | MsFlags::MS_REC
1433        | MsFlags::MS_NOSUID
1434        | MsFlags::MS_NODEV;
1435    if workspace.is_read_only() {
1436        remount_flags |= MsFlags::MS_RDONLY;
1437    }
1438    if !workspace.allow_execute {
1439        remount_flags |= MsFlags::MS_NOEXEC;
1440    }
1441
1442    mount(
1443        None::<&str>,
1444        &dest,
1445        None::<&str>,
1446        remount_flags,
1447        None::<&str>,
1448    )
1449    .map_err(|e| {
1450        NucleusError::FilesystemError(format!(
1451            "Failed to remount workspace {:?} with final flags: {}",
1452            dest, e
1453        ))
1454    })?;
1455
1456    info!(
1457        "Mounted workspace {:?} -> {:?} (mode={:?}, exec={})",
1458        source, workspace.container_path, workspace.mode, workspace.allow_execute
1459    );
1460
1461    Ok(())
1462}
1463
1464/// Mount procfs at the given path
1465///
1466/// In rootless mode, procfs mounting should work due to user namespace capabilities.
1467/// When `hide_pids` is true, mounts with hidepid=2 so processes cannot enumerate
1468/// other PIDs (production hardening). The best-effort fallback only applies to
1469/// non-hardened procfs mounts; requested hidepid hardening is fail-closed.
1470pub fn mount_procfs(
1471    proc_path: &Path,
1472    best_effort: bool,
1473    read_only: bool,
1474    hide_pids: bool,
1475) -> Result<()> {
1476    info!(
1477        "Mounting procfs at {:?} (hidepid={})",
1478        proc_path,
1479        if hide_pids { "2" } else { "0" }
1480    );
1481
1482    let mount_data: Option<&str> = if hide_pids { Some("hidepid=2") } else { None };
1483
1484    let mounted = match mount(
1485        Some("proc"),
1486        proc_path,
1487        Some("proc"),
1488        MsFlags::MS_NOSUID | MsFlags::MS_NODEV | MsFlags::MS_NOEXEC,
1489        mount_data,
1490    ) {
1491        Ok(_) => true,
1492        Err(e) => handle_procfs_mount_failure(e, best_effort, hide_pids)?,
1493    };
1494
1495    if mounted {
1496        if read_only {
1497            mount(
1498                None::<&str>,
1499                proc_path,
1500                None::<&str>,
1501                MsFlags::MS_REMOUNT
1502                    | MsFlags::MS_RDONLY
1503                    | MsFlags::MS_NOSUID
1504                    | MsFlags::MS_NODEV
1505                    | MsFlags::MS_NOEXEC,
1506                mount_data,
1507            )
1508            .map_err(|e| {
1509                NucleusError::FilesystemError(format!("Failed to remount procfs read-only: {}", e))
1510            })?;
1511            info!("Successfully mounted procfs (read-only)");
1512        } else {
1513            info!("Successfully mounted procfs");
1514        }
1515    }
1516
1517    Ok(())
1518}
1519
1520fn handle_procfs_mount_failure(
1521    e: nix::errno::Errno,
1522    best_effort: bool,
1523    hide_pids: bool,
1524) -> Result<bool> {
1525    if hide_pids {
1526        return Err(NucleusError::FilesystemError(format!(
1527            "Failed to mount procfs with required hidepid=2: {}",
1528            e
1529        )));
1530    }
1531
1532    if best_effort {
1533        warn!("Failed to mount procfs: {} (continuing anyway)", e);
1534        Ok(false)
1535    } else {
1536        Err(NucleusError::FilesystemError(format!(
1537            "Failed to mount procfs: {}",
1538            e
1539        )))
1540    }
1541}
1542
1543/// Paths to mask with /dev/null (files) – matches OCI runtime spec masked paths.
1544/// Exposed for testing; the canonical list of sensitive /proc entries that must
1545/// be hidden from container processes.
1546pub const PROC_NULL_MASKED: &[&str] = &[
1547    "kallsyms",
1548    "kcore",
1549    "sched_debug",
1550    "timer_list",
1551    "timer_stats",
1552    "keys",
1553    "latency_stats",
1554    "config.gz",
1555    "sysrq-trigger",
1556    "kpagecount",
1557    "kpageflags",
1558    "kpagecgroup",
1559];
1560
1561/// Paths to remount read-only – matches OCI runtime spec readonlyPaths.
1562pub const PROC_READONLY_PATHS: &[&str] = &["bus", "fs", "irq", "sys"];
1563
1564/// Paths to mask with empty tmpfs (directories).
1565pub const PROC_TMPFS_MASKED: &[&str] = &["acpi", "scsi"];
1566
1567fn remount_proc_path_readonly(target: &Path) -> Result<()> {
1568    mount(
1569        Some(target),
1570        target,
1571        None::<&str>,
1572        MsFlags::MS_BIND | MsFlags::MS_REC,
1573        None::<&str>,
1574    )
1575    .map_err(|e| {
1576        NucleusError::FilesystemError(format!(
1577            "Failed to bind-mount {:?} onto itself for read-only remount: {}",
1578            target, e
1579        ))
1580    })?;
1581
1582    mount(
1583        None::<&str>,
1584        target,
1585        None::<&str>,
1586        MsFlags::MS_REMOUNT
1587            | MsFlags::MS_BIND
1588            | MsFlags::MS_RDONLY
1589            | MsFlags::MS_NOSUID
1590            | MsFlags::MS_NODEV
1591            | MsFlags::MS_NOEXEC,
1592        None::<&str>,
1593    )
1594    .map_err(|e| {
1595        NucleusError::FilesystemError(format!("Failed to remount {:?} read-only: {}", target, e))
1596    })?;
1597
1598    Ok(())
1599}
1600
1601/// Mask sensitive /proc paths by bind-mounting /dev/null or tmpfs over them
1602///
1603/// This reduces kernel information leakage from the container. Follows OCI runtime
1604/// conventions for masked paths.
1605///
1606/// SEC-06: When `production` is true, failures to mask critical paths
1607/// (kcore, kallsyms, sysrq-trigger) are fatal instead of warn-and-continue.
1608pub fn mask_proc_paths(proc_path: &Path, production: bool) -> Result<()> {
1609    info!("Masking sensitive /proc paths");
1610
1611    const CRITICAL_PROC_PATHS: &[&str] = &["kcore", "kallsyms", "sysrq-trigger"];
1612
1613    for name in PROC_READONLY_PATHS {
1614        let target = proc_path.join(name);
1615        if !target.exists() {
1616            continue;
1617        }
1618        match remount_proc_path_readonly(&target) {
1619            Ok(_) => debug!("Remounted /proc/{} read-only", name),
1620            Err(e) => {
1621                if production {
1622                    return Err(NucleusError::FilesystemError(format!(
1623                        "Failed to remount /proc/{} read-only in production mode: {}",
1624                        name, e
1625                    )));
1626                }
1627                warn!(
1628                    "Failed to remount /proc/{} read-only: {} (continuing)",
1629                    name, e
1630                );
1631            }
1632        }
1633    }
1634
1635    let dev_null = Path::new("/dev/null");
1636
1637    for name in PROC_NULL_MASKED {
1638        let target = proc_path.join(name);
1639        if !target.exists() {
1640            continue;
1641        }
1642        match mount(
1643            Some(dev_null),
1644            &target,
1645            None::<&str>,
1646            MsFlags::MS_BIND,
1647            None::<&str>,
1648        ) {
1649            Ok(_) => {
1650                // Remount read-only: Linux ignores MS_RDONLY on the initial bind mount,
1651                // so a separate MS_REMOUNT|MS_BIND|MS_RDONLY call is required.
1652                if let Err(e) = mount(
1653                    None::<&str>,
1654                    &target,
1655                    None::<&str>,
1656                    MsFlags::MS_REMOUNT | MsFlags::MS_BIND | MsFlags::MS_RDONLY,
1657                    None::<&str>,
1658                ) {
1659                    if production && CRITICAL_PROC_PATHS.contains(name) {
1660                        return Err(NucleusError::FilesystemError(format!(
1661                            "Failed to remount /proc/{} read-only in production mode: {}",
1662                            name, e
1663                        )));
1664                    }
1665                    warn!(
1666                        "Failed to remount /proc/{} read-only: {} (continuing)",
1667                        name, e
1668                    );
1669                }
1670                debug!("Masked /proc/{} (read-only)", name);
1671            }
1672            Err(e) => {
1673                if production && CRITICAL_PROC_PATHS.contains(name) {
1674                    return Err(NucleusError::FilesystemError(format!(
1675                        "Failed to mask critical /proc/{} in production mode: {}",
1676                        name, e
1677                    )));
1678                }
1679                warn!("Failed to mask /proc/{}: {} (continuing)", name, e);
1680            }
1681        }
1682    }
1683
1684    for name in PROC_TMPFS_MASKED {
1685        let target = proc_path.join(name);
1686        if !target.exists() {
1687            continue;
1688        }
1689        match mount(
1690            Some("tmpfs"),
1691            &target,
1692            Some("tmpfs"),
1693            MsFlags::MS_RDONLY | MsFlags::MS_NOSUID | MsFlags::MS_NODEV | MsFlags::MS_NOEXEC,
1694            Some("size=0"),
1695        ) {
1696            Ok(_) => debug!("Masked /proc/{}", name),
1697            Err(e) => {
1698                if production {
1699                    return Err(NucleusError::FilesystemError(format!(
1700                        "Failed to mask /proc/{} in production mode: {}",
1701                        name, e
1702                    )));
1703                }
1704                warn!("Failed to mask /proc/{}: {} (continuing)", name, e);
1705            }
1706        }
1707    }
1708
1709    info!("Finished masking sensitive /proc paths");
1710    Ok(())
1711}
1712
1713/// Switch to new root filesystem using pivot_root or chroot
1714///
1715/// This implements the transition: populated -> pivoted
1716/// Fails closed if root switching cannot be established.
1717pub fn switch_root(new_root: &Path, allow_chroot_fallback: bool) -> Result<()> {
1718    info!("Switching root to {:?}", new_root);
1719
1720    match pivot_root_impl(new_root) {
1721        Ok(()) => {
1722            info!("Successfully switched root using pivot_root");
1723            Ok(())
1724        }
1725        Err(e) => {
1726            if allow_chroot_fallback {
1727                warn!(
1728                    "pivot_root failed ({}), falling back to chroot due to explicit \
1729                     configuration",
1730                    e
1731                );
1732                chroot_impl(new_root)
1733            } else {
1734                Err(NucleusError::PivotRootError(format!(
1735                    "pivot_root failed: {}. chroot fallback is disabled by default; use \
1736                     --allow-chroot-fallback to allow weaker isolation",
1737                    e
1738                )))
1739            }
1740        }
1741    }
1742}
1743
1744/// Implement root switch using pivot_root(2)
1745///
1746/// pivot_root is preferred over chroot because:
1747/// - More secure (old root can be unmounted)
1748/// - Works better with mount namespaces
1749fn pivot_root_impl(new_root: &Path) -> Result<()> {
1750    use nix::unistd::pivot_root;
1751
1752    // pivot_root requires new_root to be a mount point
1753    // and old_root to be under new_root
1754
1755    let old_root = new_root.join(".old_root");
1756    std::fs::create_dir_all(&old_root).map_err(|e| {
1757        NucleusError::PivotRootError(format!("Failed to create old_root directory: {}", e))
1758    })?;
1759
1760    // Perform pivot_root
1761    pivot_root(new_root, &old_root)
1762        .map_err(|e| NucleusError::PivotRootError(format!("pivot_root syscall failed: {}", e)))?;
1763
1764    // Change to new root
1765    std::env::set_current_dir("/")
1766        .map_err(|e| NucleusError::PivotRootError(format!("Failed to chdir to /: {}", e)))?;
1767
1768    // Unmount old root
1769    nix::mount::umount2("/.old_root", nix::mount::MntFlags::MNT_DETACH)
1770        .map_err(|e| NucleusError::PivotRootError(format!("Failed to unmount old root: {}", e)))?;
1771
1772    // Remove old root directory
1773    let _ = std::fs::remove_dir("/.old_root");
1774
1775    Ok(())
1776}
1777
1778/// Implement root switch using chroot(2)
1779///
1780/// chroot is less secure than pivot_root but works in more situations
1781fn chroot_impl(new_root: &Path) -> Result<()> {
1782    fn close_non_stdio_fds_after_chroot() -> Result<()> {
1783        // Any pre-chroot fd can still reach outside the jail, so close every
1784        // non-stdio descriptor before continuing setup inside the fallback root.
1785        let ret = unsafe { libc::syscall(libc::SYS_close_range, 3u32, u32::MAX, 0u32) };
1786        if ret == 0 {
1787            return Ok(());
1788        }
1789
1790        let max_fd = match unsafe { libc::sysconf(libc::_SC_OPEN_MAX) } {
1791            n if n > 3 && n <= i32::MAX as libc::c_long => n as i32,
1792            _ => 1024,
1793        };
1794
1795        for fd in 3..max_fd {
1796            if unsafe { libc::close(fd) } != 0 {
1797                let err = std::io::Error::last_os_error();
1798                if err.raw_os_error() != Some(libc::EBADF) {
1799                    return Err(NucleusError::PivotRootError(format!(
1800                        "Failed to close inherited fd {} after chroot: {}",
1801                        fd, err
1802                    )));
1803                }
1804            }
1805        }
1806
1807        Ok(())
1808    }
1809
1810    chroot(new_root)
1811        .map_err(|e| NucleusError::PivotRootError(format!("chroot syscall failed: {}", e)))?;
1812
1813    // Change to new root
1814    std::env::set_current_dir("/")
1815        .map_err(|e| NucleusError::PivotRootError(format!("Failed to chdir to /: {}", e)))?;
1816
1817    close_non_stdio_fds_after_chroot()?;
1818
1819    // L3: Drop CAP_SYS_CHROOT after chroot to prevent escape via nested chroot.
1820    if let Err(e) = caps::drop(
1821        None,
1822        caps::CapSet::Bounding,
1823        caps::Capability::CAP_SYS_CHROOT,
1824    ) {
1825        debug!(
1826            "Could not drop CAP_SYS_CHROOT after chroot: {} (may not be present)",
1827            e
1828        );
1829    }
1830    if let Err(e) = caps::drop(
1831        None,
1832        caps::CapSet::Effective,
1833        caps::Capability::CAP_SYS_CHROOT,
1834    ) {
1835        debug!(
1836            "Could not drop effective CAP_SYS_CHROOT: {} (may not be present)",
1837            e
1838        );
1839    }
1840    if let Err(e) = caps::drop(
1841        None,
1842        caps::CapSet::Permitted,
1843        caps::Capability::CAP_SYS_CHROOT,
1844    ) {
1845        debug!(
1846            "Could not drop permitted CAP_SYS_CHROOT: {} (may not be present)",
1847            e
1848        );
1849    }
1850
1851    info!("Successfully switched root using chroot (CAP_SYS_CHROOT dropped)");
1852
1853    Ok(())
1854}
1855
1856/// Mount secret files into the container root.
1857///
1858/// Each secret is bind-mounted read-only from its source to the destination
1859/// path inside the container. Intermediate directories are created as needed.
1860pub fn mount_secrets(root: &Path, secrets: &[crate::container::SecretMount]) -> Result<()> {
1861    if secrets.is_empty() {
1862        return Ok(());
1863    }
1864
1865    info!("Mounting {} secret(s) into container", secrets.len());
1866
1867    for secret in secrets {
1868        let source_fd = open(
1869            &secret.source,
1870            OFlag::O_PATH | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC,
1871            Mode::empty(),
1872        )
1873        .map_err(|e| {
1874            NucleusError::FilesystemError(format!(
1875                "Failed to open secret source {:?} with O_NOFOLLOW: {}",
1876                secret.source, e
1877            ))
1878        })?;
1879        let source_stat = fstat(&source_fd).map_err(|e| {
1880            NucleusError::FilesystemError(format!(
1881                "Failed to stat secret source {:?}: {}",
1882                secret.source, e
1883            ))
1884        })?;
1885        let source_kind = SFlag::from_bits_truncate(source_stat.st_mode);
1886        let source_is_file = source_kind == SFlag::S_IFREG;
1887        let source_is_dir = source_kind == SFlag::S_IFDIR;
1888        if !source_is_file && !source_is_dir {
1889            return Err(NucleusError::FilesystemError(format!(
1890                "Secret source {:?} must be a regular file or directory",
1891                secret.source
1892            )));
1893        }
1894        let source_fd_path = PathBuf::from(format!("/proc/self/fd/{}", source_fd.as_raw_fd()));
1895
1896        // Destination inside container root
1897        let dest = resolve_container_destination(root, &secret.dest)?;
1898
1899        // Create parent directories
1900        if let Some(parent) = dest.parent() {
1901            std::fs::create_dir_all(parent).map_err(|e| {
1902                NucleusError::FilesystemError(format!(
1903                    "Failed to create secret mount parent {:?}: {}",
1904                    parent, e
1905                ))
1906            })?;
1907        }
1908
1909        // Create mount point file
1910        if source_is_file {
1911            std::fs::write(&dest, "").map_err(|e| {
1912                NucleusError::FilesystemError(format!(
1913                    "Failed to create secret mount point {:?}: {}",
1914                    dest, e
1915                ))
1916            })?;
1917        } else {
1918            std::fs::create_dir_all(&dest).map_err(|e| {
1919                NucleusError::FilesystemError(format!(
1920                    "Failed to create secret mount dir {:?}: {}",
1921                    dest, e
1922                ))
1923            })?;
1924        }
1925
1926        // Bind mount read-only
1927        mount(
1928            Some(source_fd_path.as_path()),
1929            &dest,
1930            None::<&str>,
1931            MsFlags::MS_BIND,
1932            None::<&str>,
1933        )
1934        .map_err(|e| {
1935            NucleusError::FilesystemError(format!(
1936                "Failed to bind mount secret {:?}: {}",
1937                secret.source, e
1938            ))
1939        })?;
1940
1941        mount(
1942            None::<&str>,
1943            &dest,
1944            None::<&str>,
1945            MsFlags::MS_REMOUNT
1946                | MsFlags::MS_BIND
1947                | MsFlags::MS_RDONLY
1948                | MsFlags::MS_NOSUID
1949                | MsFlags::MS_NODEV
1950                | MsFlags::MS_NOEXEC,
1951            None::<&str>,
1952        )
1953        .map_err(|e| {
1954            NucleusError::FilesystemError(format!(
1955                "Failed to remount secret {:?} read-only: {}",
1956                dest, e
1957            ))
1958        })?;
1959
1960        // Apply configured file permissions on the mount point
1961        if source_is_file {
1962            use std::os::unix::fs::PermissionsExt;
1963            let perms = std::fs::Permissions::from_mode(secret.mode);
1964            if let Err(e) = std::fs::set_permissions(&dest, perms) {
1965                warn!(
1966                    "Failed to set mode {:04o} on secret {:?}: {} (bind mount may override)",
1967                    secret.mode, dest, e
1968                );
1969            }
1970        }
1971
1972        debug!(
1973            "Mounted secret {:?} -> {:?} (mode {:04o})",
1974            secret.source, secret.dest, secret.mode
1975        );
1976    }
1977
1978    Ok(())
1979}
1980
1981/// Mount secrets onto a dedicated in-memory tmpfs instead of bind-mounting host paths.
1982///
1983/// Creates a per-container tmpfs at `<root>/run/secrets` with MS_NOEXEC | MS_NOSUID | MS_NODEV,
1984/// copies secret contents into it, then zeros the read buffer. This ensures secrets
1985/// never reference host-side files after setup and are never persisted to disk.
1986pub fn mount_secrets_inmemory(
1987    root: &Path,
1988    secrets: &[crate::container::SecretMount],
1989    identity: &crate::container::ProcessIdentity,
1990) -> Result<()> {
1991    if secrets.is_empty() {
1992        return Ok(());
1993    }
1994
1995    info!("Mounting {} secret(s) on in-memory tmpfs", secrets.len());
1996
1997    let secrets_dir = root.join("run/secrets");
1998    std::fs::create_dir_all(&secrets_dir).map_err(|e| {
1999        NucleusError::FilesystemError(format!(
2000            "Failed to create secrets dir {:?}: {}",
2001            secrets_dir, e
2002        ))
2003    })?;
2004
2005    // Mount a size-limited tmpfs for secrets (16 MiB max)
2006    if let Err(e) = mount(
2007        Some("tmpfs"),
2008        &secrets_dir,
2009        Some("tmpfs"),
2010        MsFlags::MS_NOSUID | MsFlags::MS_NODEV | MsFlags::MS_NOEXEC,
2011        Some("size=16m,mode=0700"),
2012    ) {
2013        let _ = std::fs::remove_dir_all(&secrets_dir);
2014        return Err(NucleusError::FilesystemError(format!(
2015            "Failed to mount secrets tmpfs at {:?}: {}",
2016            secrets_dir, e
2017        )));
2018    }
2019
2020    if !identity.is_root() {
2021        nix::unistd::chown(
2022            &secrets_dir,
2023            Some(nix::unistd::Uid::from_raw(identity.uid)),
2024            Some(nix::unistd::Gid::from_raw(identity.gid)),
2025        )
2026        .map_err(|e| {
2027            let _ = nix::mount::umount2(&secrets_dir, nix::mount::MntFlags::MNT_DETACH);
2028            let _ = std::fs::remove_dir_all(&secrets_dir);
2029            NucleusError::FilesystemError(format!(
2030                "Failed to set /run/secrets owner to {}:{}: {}",
2031                identity.uid, identity.gid, e
2032            ))
2033        })?;
2034    }
2035
2036    // Rollback: unmount tmpfs and remove dir if any secret fails
2037    let result = mount_secrets_inmemory_inner(&secrets_dir, root, secrets, identity);
2038    if let Err(ref e) = result {
2039        let _ = nix::mount::umount2(&secrets_dir, nix::mount::MntFlags::MNT_DETACH);
2040        let _ = std::fs::remove_dir_all(&secrets_dir);
2041        return Err(NucleusError::FilesystemError(format!(
2042            "Secret mount failed (rolled back): {}",
2043            e
2044        )));
2045    }
2046
2047    info!("All secrets mounted on in-memory tmpfs");
2048    Ok(())
2049}
2050
2051fn mount_secrets_inmemory_inner(
2052    secrets_dir: &Path,
2053    root: &Path,
2054    secrets: &[crate::container::SecretMount],
2055    identity: &crate::container::ProcessIdentity,
2056) -> Result<()> {
2057    for secret in secrets {
2058        let mut content = read_regular_file_nofollow(&secret.source)?;
2059
2060        // Determine destination path inside the secrets tmpfs
2061        let dest = resolve_container_destination(secrets_dir, &secret.dest)?;
2062
2063        // Create parent directories within the tmpfs
2064        if let Some(parent) = dest.parent() {
2065            std::fs::create_dir_all(parent).map_err(|e| {
2066                NucleusError::FilesystemError(format!(
2067                    "Failed to create secret parent dir {:?}: {}",
2068                    parent, e
2069                ))
2070            })?;
2071        }
2072
2073        // Write secret content to tmpfs
2074        std::fs::write(&dest, &content).map_err(|e| {
2075            NucleusError::FilesystemError(format!("Failed to write secret to {:?}: {}", dest, e))
2076        })?;
2077
2078        // Set permissions
2079        {
2080            use std::os::unix::fs::PermissionsExt;
2081            let perms = std::fs::Permissions::from_mode(secret.mode);
2082            std::fs::set_permissions(&dest, perms).map_err(|e| {
2083                NucleusError::FilesystemError(format!(
2084                    "Failed to set permissions on secret {:?}: {}",
2085                    dest, e
2086                ))
2087            })?;
2088        }
2089
2090        if !identity.is_root() {
2091            nix::unistd::chown(
2092                &dest,
2093                Some(nix::unistd::Uid::from_raw(identity.uid)),
2094                Some(nix::unistd::Gid::from_raw(identity.gid)),
2095            )
2096            .map_err(|e| {
2097                NucleusError::FilesystemError(format!(
2098                    "Failed to set permissions owner on secret {:?} to {}:{}: {}",
2099                    dest, identity.uid, identity.gid, e
2100                ))
2101            })?;
2102        }
2103
2104        // Zero the in-memory buffer
2105        zeroize::Zeroize::zeroize(&mut content);
2106        drop(content);
2107
2108        // Also bind-mount the secret to its expected container path for compatibility
2109        let container_dest = resolve_container_destination(root, &secret.dest)?;
2110        if container_dest != dest {
2111            if let Some(parent) = container_dest.parent() {
2112                std::fs::create_dir_all(parent).map_err(|e| {
2113                    NucleusError::FilesystemError(format!(
2114                        "Failed to create secret mount parent {:?}: {}",
2115                        parent, e
2116                    ))
2117                })?;
2118            }
2119
2120            std::fs::write(&container_dest, "").map_err(|e| {
2121                NucleusError::FilesystemError(format!(
2122                    "Failed to create secret mount point {:?}: {}",
2123                    container_dest, e
2124                ))
2125            })?;
2126
2127            mount(
2128                Some(dest.as_path()),
2129                &container_dest,
2130                None::<&str>,
2131                MsFlags::MS_BIND,
2132                None::<&str>,
2133            )
2134            .map_err(|e| {
2135                NucleusError::FilesystemError(format!(
2136                    "Failed to bind mount secret {:?} -> {:?}: {}",
2137                    dest, container_dest, e
2138                ))
2139            })?;
2140
2141            mount(
2142                None::<&str>,
2143                &container_dest,
2144                None::<&str>,
2145                MsFlags::MS_REMOUNT
2146                    | MsFlags::MS_BIND
2147                    | MsFlags::MS_RDONLY
2148                    | MsFlags::MS_NOSUID
2149                    | MsFlags::MS_NODEV
2150                    | MsFlags::MS_NOEXEC,
2151                None::<&str>,
2152            )
2153            .map_err(|e| {
2154                NucleusError::FilesystemError(format!(
2155                    "Failed to remount secret {:?} read-only: {}",
2156                    container_dest, e
2157                ))
2158            })?;
2159        }
2160
2161        debug!(
2162            "Secret {:?} -> {:?} (in-memory tmpfs, mode {:04o})",
2163            secret.source, secret.dest, secret.mode
2164        );
2165    }
2166
2167    Ok(())
2168}
2169
2170#[cfg(test)]
2171mod tests {
2172    use super::*;
2173    use std::os::unix::fs::symlink;
2174
2175    #[test]
2176    fn test_validate_bind_mount_source_rejects_sensitive_subtrees() {
2177        for path in [
2178            "/",
2179            "/boot",
2180            "/dev/kmsg",
2181            "/etc",
2182            "/etc/passwd",
2183            "/home/alice/.ssh",
2184            "/proc/sys",
2185            "/root/.ssh",
2186            "/run/secrets",
2187            "/sys/fs/cgroup",
2188            "/var/log",
2189        ] {
2190            let err = validate_bind_mount_source(Path::new(path)).unwrap_err();
2191            assert!(
2192                err.to_string().contains("sensitive host path"),
2193                "expected sensitive-path rejection for {path}, got: {err}"
2194            );
2195        }
2196    }
2197
2198    #[test]
2199    fn test_validate_bind_mount_source_allows_regular_host_paths() {
2200        let temp = tempfile::TempDir::new().unwrap();
2201        let safe_path = temp.path().join("data");
2202        std::fs::create_dir(&safe_path).unwrap();
2203
2204        validate_bind_mount_source(&safe_path).unwrap();
2205    }
2206
2207    #[test]
2208    fn test_validate_provider_config_source_allows_regular_paths() {
2209        let temp = tempfile::TempDir::new().unwrap();
2210        let safe_path = temp.path().join("config.json");
2211        std::fs::write(&safe_path, "{}").unwrap();
2212
2213        validate_provider_config_source(&safe_path).unwrap();
2214    }
2215
2216    #[test]
2217    fn test_validate_provider_config_source_rejects_sensitive_paths() {
2218        let err = validate_provider_config_source(Path::new("/etc/passwd")).unwrap_err();
2219        assert!(
2220            err.to_string().contains("sensitive host path"),
2221            "expected sensitive-path rejection, got: {err}"
2222        );
2223    }
2224
2225    #[test]
2226    fn test_validate_bind_mount_source_normalizes_parent_components_before_filtering() {
2227        let temp = tempfile::TempDir::new().unwrap();
2228        let safe_path = temp.path().join("data");
2229        std::fs::create_dir(&safe_path).unwrap();
2230
2231        validate_bind_mount_source(&safe_path.join("../data")).unwrap();
2232    }
2233
2234    #[test]
2235    fn test_bind_mount_source_policy_rejects_sensitive_paths_before_creation() {
2236        let err = validate_bind_mount_source_policy(Path::new("/tmp/../../etc/nucleus-volume"))
2237            .unwrap_err();
2238        assert!(
2239            err.to_string().contains("sensitive host path"),
2240            "expected sensitive-path rejection before path creation, got: {err}"
2241        );
2242    }
2243
2244    #[test]
2245    fn test_volume_destinations_reject_reserved_container_paths() {
2246        for path in [
2247            "/bin/tool",
2248            "/dev/null",
2249            "/etc/app",
2250            "/lib64/ld-linux-x86-64.so.2",
2251            "/nix/store/data",
2252            "/proc/sys",
2253            "/run/secrets/token",
2254            "/usr/local/bin",
2255        ] {
2256            let err = normalize_volume_destination(Path::new(path)).unwrap_err();
2257            assert!(
2258                err.to_string().contains("reserved"),
2259                "expected reserved destination rejection for {path}, got: {err}"
2260            );
2261        }
2262    }
2263
2264    #[test]
2265    fn test_volume_destinations_allow_data_paths() {
2266        assert_eq!(
2267            normalize_volume_destination(Path::new("/var/lib/app")).unwrap(),
2268            PathBuf::from("/var/lib/app")
2269        );
2270        assert_eq!(
2271            normalize_volume_destination(Path::new("/opt/app/data")).unwrap(),
2272            PathBuf::from("/opt/app/data")
2273        );
2274    }
2275
2276    #[test]
2277    fn test_provider_config_destination_relative_to_home() {
2278        assert_eq!(
2279            normalize_provider_config_destination(
2280                Path::new("/home/agent"),
2281                Path::new(".config/provider")
2282            )
2283            .unwrap(),
2284            PathBuf::from("/home/agent/.config/provider")
2285        );
2286    }
2287
2288    #[test]
2289    fn test_provider_config_destination_rejects_escape() {
2290        let err =
2291            normalize_provider_config_destination(Path::new("/home/agent"), Path::new("../.ssh"))
2292                .unwrap_err();
2293        assert!(
2294            err.to_string().contains("parent traversal"),
2295            "expected traversal rejection, got: {err}"
2296        );
2297
2298        let err = normalize_provider_config_destination(
2299            Path::new("/home/agent"),
2300            Path::new("/workspace/.aws"),
2301        )
2302        .unwrap_err();
2303        assert!(
2304            err.to_string().contains("must be under configured home"),
2305            "expected home-boundary rejection, got: {err}"
2306        );
2307    }
2308
2309    #[test]
2310    fn test_production_rootfs_path_rejects_parent_traversal() {
2311        let temp = tempfile::TempDir::new().unwrap();
2312        let store = temp.path().join("store");
2313        std::fs::create_dir(&store).unwrap();
2314
2315        let err =
2316            validate_rootfs_path_under_store(&store.join("../outside-rootfs"), &store).unwrap_err();
2317
2318        assert!(
2319            err.to_string().contains("parent traversal"),
2320            "expected parent traversal rejection, got: {err}"
2321        );
2322    }
2323
2324    #[test]
2325    fn test_production_rootfs_path_rejects_symlink_escape() {
2326        let temp = tempfile::TempDir::new().unwrap();
2327        let store = temp.path().join("store");
2328        let outside = temp.path().join("outside-rootfs");
2329        std::fs::create_dir(&store).unwrap();
2330        std::fs::create_dir(&outside).unwrap();
2331        symlink(&outside, store.join("rootfs-link")).unwrap();
2332
2333        let err = validate_rootfs_path_under_store(&store.join("rootfs-link"), &store).unwrap_err();
2334
2335        assert!(
2336            err.to_string().contains("resolve under"),
2337            "expected symlink escape rejection, got: {err}"
2338        );
2339    }
2340
2341    #[test]
2342    fn test_production_rootfs_path_returns_canonical_store_target() {
2343        let temp = tempfile::TempDir::new().unwrap();
2344        let store = temp.path().join("store");
2345        let rootfs = store.join("abcd-rootfs");
2346        std::fs::create_dir(&store).unwrap();
2347        std::fs::create_dir(&rootfs).unwrap();
2348        symlink(&rootfs, store.join("rootfs-link")).unwrap();
2349
2350        let canonical =
2351            validate_rootfs_path_under_store(&store.join("rootfs-link"), &store).unwrap();
2352
2353        assert_eq!(canonical, std::fs::canonicalize(rootfs).unwrap());
2354    }
2355
2356    #[test]
2357    fn test_agent_toolchain_rootfs_path_allows_non_store_directory() {
2358        let temp = tempfile::TempDir::new().unwrap();
2359        let rootfs = temp.path().join("agent-rootfs");
2360        std::fs::create_dir(&rootfs).unwrap();
2361
2362        let canonical = validate_agent_toolchain_rootfs_path(&rootfs).unwrap();
2363
2364        assert_eq!(canonical, std::fs::canonicalize(rootfs).unwrap());
2365    }
2366
2367    #[test]
2368    fn test_agent_toolchain_rootfs_path_rejects_relative_path() {
2369        let err = validate_agent_toolchain_rootfs_path(Path::new("agent-rootfs")).unwrap_err();
2370
2371        assert!(
2372            err.to_string().contains("must be absolute"),
2373            "expected absolute path rejection, got: {err}"
2374        );
2375    }
2376
2377    #[test]
2378    fn test_agent_toolchain_rootfs_path_rejects_parent_traversal() {
2379        let temp = tempfile::TempDir::new().unwrap();
2380        let err =
2381            validate_agent_toolchain_rootfs_path(&temp.path().join("../agent-rootfs")).unwrap_err();
2382
2383        assert!(
2384            err.to_string().contains("parent traversal"),
2385            "expected parent traversal rejection, got: {err}"
2386        );
2387    }
2388
2389    #[test]
2390    fn test_agent_toolchain_rootfs_path_rejects_file() {
2391        let temp = tempfile::TempDir::new().unwrap();
2392        let rootfs = temp.path().join("agent-rootfs");
2393        std::fs::write(&rootfs, "not a directory").unwrap();
2394
2395        let err = validate_agent_toolchain_rootfs_path(&rootfs).unwrap_err();
2396
2397        assert!(
2398            err.to_string().contains("must resolve to a directory"),
2399            "expected directory rejection, got: {err}"
2400        );
2401    }
2402
2403    #[test]
2404    fn test_rootfs_store_path_binds_reject_store_root_before_mounting() {
2405        let temp = tempfile::TempDir::new().unwrap();
2406        let container_root = temp.path().join("container-root");
2407        let rootfs = temp.path().join("rootfs");
2408        std::fs::create_dir_all(&container_root).unwrap();
2409        std::fs::create_dir_all(&rootfs).unwrap();
2410        std::fs::write(rootfs.join(ROOTFS_STORE_PATHS_FILE), "/nix/store\n").unwrap();
2411
2412        let err = bind_mount_rootfs_store_paths(&container_root, &rootfs).unwrap_err();
2413
2414        assert!(
2415            err.to_string().contains("expected immediate"),
2416            "expected immediate-store-object rejection, got: {err}"
2417        );
2418    }
2419
2420    #[test]
2421    fn test_rootfs_store_path_binds_reject_store_subpath_before_mounting() {
2422        let temp = tempfile::TempDir::new().unwrap();
2423        let container_root = temp.path().join("container-root");
2424        let rootfs = temp.path().join("rootfs");
2425        std::fs::create_dir_all(&container_root).unwrap();
2426        std::fs::create_dir_all(&rootfs).unwrap();
2427        std::fs::write(
2428            rootfs.join(ROOTFS_STORE_PATHS_FILE),
2429            "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-hello/bin\n",
2430        )
2431        .unwrap();
2432
2433        let err = bind_mount_rootfs_store_paths(&container_root, &rootfs).unwrap_err();
2434
2435        assert!(
2436            err.to_string().contains("expected immediate"),
2437            "expected immediate-store-object rejection, got: {err}"
2438        );
2439    }
2440
2441    #[test]
2442    fn test_proc_mask_includes_sysrq_trigger() {
2443        assert!(
2444            PROC_NULL_MASKED.contains(&"sysrq-trigger"),
2445            "/proc/sysrq-trigger must be masked to prevent host DoS"
2446        );
2447    }
2448
2449    #[test]
2450    fn test_proc_mask_includes_timer_stats() {
2451        assert!(
2452            PROC_NULL_MASKED.contains(&"timer_stats"),
2453            "/proc/timer_stats must be masked to prevent kernel info leakage"
2454        );
2455    }
2456
2457    #[test]
2458    fn test_proc_mask_includes_kpage_files() {
2459        for path in &["kpagecount", "kpageflags", "kpagecgroup"] {
2460            assert!(
2461                PROC_NULL_MASKED.contains(path),
2462                "/proc/{} must be masked to prevent host memory layout leakage",
2463                path
2464            );
2465        }
2466    }
2467
2468    #[test]
2469    fn test_proc_mask_includes_oci_standard_paths() {
2470        // OCI runtime spec required masked paths
2471        for path in &["kallsyms", "kcore", "sched_debug", "keys", "config.gz"] {
2472            assert!(
2473                PROC_NULL_MASKED.contains(path),
2474                "/proc/{} must be in null-masked list (OCI spec)",
2475                path
2476            );
2477        }
2478        for path in &["acpi", "scsi"] {
2479            assert!(
2480                PROC_TMPFS_MASKED.contains(path),
2481                "/proc/{} must be in tmpfs-masked list (OCI spec)",
2482                path
2483            );
2484        }
2485        for path in &["bus", "fs", "irq", "sys"] {
2486            assert!(
2487                PROC_READONLY_PATHS.contains(path),
2488                "/proc/{} must be in read-only remount list (OCI spec)",
2489                path
2490            );
2491            assert!(
2492                !PROC_TMPFS_MASKED.contains(path),
2493                "/proc/{} must stay visible read-only, not hidden behind tmpfs",
2494                path
2495            );
2496        }
2497    }
2498
2499    #[test]
2500    fn test_procfs_hidepid_failure_fails_closed_even_best_effort() {
2501        let err = handle_procfs_mount_failure(nix::errno::Errno::EINVAL, true, true).unwrap_err();
2502
2503        assert!(
2504            err.to_string().contains("required hidepid=2"),
2505            "hidepid=2 failures must remain fatal in production/rootless paths, got: {err}"
2506        );
2507    }
2508
2509    #[test]
2510    fn test_procfs_best_effort_only_applies_without_hidepid() {
2511        assert!(
2512            !handle_procfs_mount_failure(nix::errno::Errno::EPERM, true, false).unwrap(),
2513            "best-effort procfs mount failures may only continue when hidepid was not requested"
2514        );
2515    }
2516
2517    #[test]
2518    fn test_parse_mountinfo_line_uses_mountinfo_mount_point_and_flags() {
2519        let line =
2520            "36 25 0:32 / /run/secrets rw,nosuid,nodev,noexec,relatime - tmpfs tmpfs rw,size=1024k";
2521        let (mount_point, flags) = parse_mountinfo_line(line).unwrap();
2522
2523        assert_eq!(mount_point, "/run/secrets");
2524        assert!(flags.contains("nosuid"));
2525        assert!(flags.contains("nodev"));
2526        assert!(flags.contains("noexec"));
2527    }
2528
2529    #[test]
2530    fn test_parse_mountinfo_line_decodes_escaped_mount_points() {
2531        let line = "41 25 0:40 / /path\\040with\\040spaces ro,nosuid,nodev - ext4 /dev/root ro";
2532        let (mount_point, flags) = parse_mountinfo_line(line).unwrap();
2533
2534        assert_eq!(mount_point, "/path with spaces");
2535        assert!(flags.contains("ro"));
2536    }
2537
2538    #[test]
2539    fn test_chroot_impl_closes_non_stdio_fds() {
2540        let source = include_str!("mount.rs");
2541        let fn_start = source.find("fn chroot_impl").unwrap();
2542        let after = &source[fn_start..];
2543        let open = after.find('{').unwrap();
2544        let mut depth = 0u32;
2545        let mut fn_end = open;
2546        for (i, ch) in after[open..].char_indices() {
2547            match ch {
2548                '{' => depth += 1,
2549                '}' => {
2550                    depth -= 1;
2551                    if depth == 0 {
2552                        fn_end = open + i + 1;
2553                        break;
2554                    }
2555                }
2556                _ => {}
2557            }
2558        }
2559        let body = &after[..fn_end];
2560        assert!(
2561            body.contains("close_non_stdio_fds_after_chroot()?"),
2562            "chroot fallback must close inherited non-stdio fds before continuing setup"
2563        );
2564    }
2565
2566    #[test]
2567    fn test_read_regular_file_nofollow_reads_regular_file() {
2568        let temp = tempfile::tempdir().unwrap();
2569        let path = temp.path().join("secret.txt");
2570        std::fs::write(&path, "supersecret").unwrap();
2571
2572        let content = read_regular_file_nofollow(&path).unwrap();
2573        assert_eq!(content, b"supersecret");
2574    }
2575
2576    #[test]
2577    fn test_read_regular_file_nofollow_rejects_symlink() {
2578        let temp = tempfile::tempdir().unwrap();
2579        let target = temp.path().join("target.txt");
2580        let link = temp.path().join("secret-link");
2581        std::fs::write(&target, "supersecret").unwrap();
2582        symlink(&target, &link).unwrap();
2583
2584        let err = read_regular_file_nofollow(&link).unwrap_err();
2585        assert!(
2586            err.to_string().contains("O_NOFOLLOW"),
2587            "symlink reads must fail via O_NOFOLLOW"
2588        );
2589    }
2590}