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
14struct ExpectedMount {
16 path: &'static str,
17 required_flags: &'static [&'static str],
18 critical: bool,
21}
22
23const 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, },
40 ExpectedMount {
41 path: "/lib64",
42 required_flags: &["ro", "nosuid", "nodev"],
43 critical: false, },
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, },
55 ExpectedMount {
56 path: "/sbin",
57 required_flags: &["ro", "nosuid", "nodev"],
58 critical: false, },
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, },
70];
71
72pub 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
120pub 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
185pub fn validate_production_rootfs_path(rootfs_path: &Path) -> Result<PathBuf> {
190 validate_rootfs_path_under_store(rootfs_path, Path::new("/nix/store"))
191}
192
193pub 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
314pub 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
370pub fn create_minimal_fs(root: &Path) -> Result<()> {
372 info!("Creating minimal filesystem structure at {:?}", root);
373
374 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
407pub fn create_dev_nodes(dev_path: &Path, include_tty: bool) -> Result<()> {
411 info!("Creating device nodes at {:?}", dev_path);
412
413 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 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
459pub 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 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 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
544pub 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
721pub fn bind_mount_host_paths(root: &Path, best_effort: bool) -> Result<()> {
726 info!("Bind mounting host paths into container");
727
728 let host_paths = vec![
730 "/bin", "/usr", "/lib", "/lib64", "/nix", ];
732
733 for host_path in host_paths {
734 let host = Path::new(host_path);
735
736 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 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 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 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
810const DENIED_BIND_MOUNT_SOURCES_EXACT: &[&str] = &["/"];
812
813const DENIED_BIND_MOUNT_SOURCE_PREFIXES: &[&str] = &[
815 "/boot", "/dev", "/etc", "/home", "/proc", "/root", "/run", "/sys", "/var/log", "/var/run",
816];
817
818const 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
829const 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
937pub 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
947pub 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
960pub 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
1000pub 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
1007pub 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
1056pub 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
1068pub 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 validate_bind_mount_source(source)?;
1085
1086 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 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 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
1232pub 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
1268pub 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
1386pub 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
1464pub 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
1543pub 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
1561pub const PROC_READONLY_PATHS: &[&str] = &["bus", "fs", "irq", "sys"];
1563
1564pub 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
1601pub 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 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
1713pub 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
1744fn pivot_root_impl(new_root: &Path) -> Result<()> {
1750 use nix::unistd::pivot_root;
1751
1752 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 pivot_root(new_root, &old_root)
1762 .map_err(|e| NucleusError::PivotRootError(format!("pivot_root syscall failed: {}", e)))?;
1763
1764 std::env::set_current_dir("/")
1766 .map_err(|e| NucleusError::PivotRootError(format!("Failed to chdir to /: {}", e)))?;
1767
1768 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 let _ = std::fs::remove_dir("/.old_root");
1774
1775 Ok(())
1776}
1777
1778fn chroot_impl(new_root: &Path) -> Result<()> {
1782 fn close_non_stdio_fds_after_chroot() -> Result<()> {
1783 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 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 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
1856pub 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 let dest = resolve_container_destination(root, &secret.dest)?;
1898
1899 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 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 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 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
1981pub 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 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 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 let dest = resolve_container_destination(secrets_dir, &secret.dest)?;
2062
2063 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 std::fs::write(&dest, &content).map_err(|e| {
2075 NucleusError::FilesystemError(format!("Failed to write secret to {:?}: {}", dest, e))
2076 })?;
2077
2078 {
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 zeroize::Zeroize::zeroize(&mut content);
2106 drop(content);
2107
2108 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 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}