diff --git a/crates/sandlock-core/src/procfs.rs b/crates/sandlock-core/src/procfs.rs index d55352ce..44c64a57 100644 --- a/crates/sandlock-core/src/procfs.rs +++ b/crates/sandlock-core/src/procfs.rs @@ -30,16 +30,16 @@ pub(crate) use metadata::handle_pinned_metadata; pub(crate) use net_dispatch::{handle_net_directory, handle_net_open}; pub(crate) use net_metadata::handle_net_metadata; -use std::collections::HashSet; use std::ffi::CString; use std::os::unix::io::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd, RawFd}; use std::sync::Arc; use tokio::sync::Mutex; +use crate::seccomp::ctx::SupervisorCtx; use crate::seccomp::notif::{ - content_memfd, inject_open_result, openat2_at, write_child_mem, NotifAction, NotifPolicy, - OpenArgs, OpenRequest, + content_memfd, dup_fd_from_pid, inject_open_result, openat2_at, write_child_mem, NotifAction, + NotifPolicy, OpenArgs, OpenRequest, }; use crate::seccomp::state::ProcessIndex; use crate::sys::structs::{SeccompNotif, EACCES}; @@ -135,6 +135,8 @@ pub(crate) const SHADOWED_ETC_FILES: &[&str] = &["/etc/hostname", "/etc/hosts"]; #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) enum VirtualFile { CpuInfo, + CpuList, + Stat, MemInfo, Uptime, LoadAvg, @@ -152,6 +154,16 @@ pub(crate) enum VirtualFile { pub(crate) fn virtual_file(path: &str, policy: &NotifPolicy) -> Option { Some(match canon_proc_namespace(path).as_ref() { "/proc/cpuinfo" if policy.num_cpus.is_some() => VirtualFile::CpuInfo, + "/proc/stat" if policy.num_cpus.is_some() => VirtualFile::Stat, + // glibc's sysconf(_SC_NPROCESSORS_ONLN/CONF) counts these, not the + // affinity mask, so `make -j$(getconf _NPROCESSORS_ONLN)` sees them. + "/sys/devices/system/cpu/online" + | "/sys/devices/system/cpu/possible" + | "/sys/devices/system/cpu/present" + if policy.num_cpus.is_some() => + { + VirtualFile::CpuList + } "/proc/meminfo" if policy.max_memory_bytes > 0 => VirtualFile::MemInfo, "/proc/uptime" if policy.has_time_start => VirtualFile::Uptime, "/proc/loadavg" => VirtualFile::LoadAvg, @@ -182,6 +194,11 @@ pub(crate) async fn render_virtual_file( }; match file { VirtualFile::CpuInfo => generate_cpuinfo(policy.num_cpus.unwrap_or(1)), + VirtualFile::CpuList => generate_cpu_list(policy.num_cpus.unwrap_or(1)), + VirtualFile::Stat => { + let real = std::fs::read_to_string("/proc/stat").unwrap_or_default(); + filter_proc_stat(&real, policy.num_cpus.unwrap_or(1)) + } VirtualFile::MemInfo => { let rs = resource.lock().await; generate_meminfo(policy.max_memory_bytes, rs.mem_used) @@ -219,6 +236,53 @@ pub(crate) async fn render_virtual_file( } } +/// A sysfs CPU list (`0` or `0-N`) naming the first `num_cpus` CPUs. +pub(crate) fn generate_cpu_list(num_cpus: u32) -> Vec { + match num_cpus { + 0 | 1 => b"0\n".to_vec(), + n => format!("0-{}\n", n - 1).into_bytes(), + } +} + +/// /proc/stat with only the first `num_cpus` per-CPU lines. The aggregate +/// `cpu` line is summed from those, so usage ratios computed from it agree +/// with the per-CPU lines. +pub(crate) fn filter_proc_stat(real: &str, num_cpus: u32) -> Vec { + let kept: Vec<&str> = real + .lines() + .filter(|line| { + line.strip_prefix("cpu") + .and_then(|rest| rest.split_whitespace().next()) + .and_then(|id| id.parse::().ok()) + .is_some_and(|id| id < num_cpus) + }) + .collect(); + let mut total: Vec = Vec::new(); + for line in &kept { + for (i, value) in line.split_whitespace().skip(1).enumerate() { + if total.len() <= i { + total.push(0); + } + total[i] += value.parse::().unwrap_or(0); + } + } + let mut out = String::new(); + for line in real.lines() { + if line.starts_with("cpu ") { + out.push_str("cpu "); + for value in &total { + out.push_str(&format!(" {value}")); + } + out.push('\n'); + out.extend(kept.iter().map(|cpu| format!("{cpu}\n"))); + } else if !line.starts_with("cpu") { + out.push_str(line); + out.push('\n'); + } + } + out.into_bytes() +} + // ============================================================ // /proc/cpuinfo generator // ============================================================ @@ -534,7 +598,7 @@ pub(crate) async fn handle_proc_open( // The /etc shims are handlers of their own, ordered around the chroot // handler so that its grant check comes first. - if path.starts_with("/proc/") { + if path.starts_with("/proc/") || path.starts_with("/sys/") { if let Some(file) = virtual_file(path, policy) { let content = render_virtual_file(file, processes, resource, policy).await; return inject_memfd(&content); @@ -1244,133 +1308,149 @@ pub(crate) fn build_dirent64(d_ino: u64, d_off: i64, d_type: u8, name: &str) -> Some(buf) } -/// Build a filtered list of dirent64 entries for /proc, hiding PIDs not in the sandbox. -fn build_filtered_dirents(sandbox_pids: &HashSet) -> Vec> { - let mut entries = Vec::new(); - let mut d_off: i64 = 0; - - let dir = match std::fs::read_dir("/proc") { - Ok(d) => d, - Err(_) => return entries, +fn read_filtered_dirents( + fd: RawFd, + syscall: i64, + count: usize, + keep: impl Fn(&str) -> bool, +) -> std::io::Result> { + let name_offset = if syscall == libc::SYS_getdents64 { + 19 + } else { + 18 }; - - for entry in dir { - let entry = match entry { - Ok(e) => e, - Err(_) => continue, - }; - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - - // Filter out foreign PID directories. - if let Ok(pid) = name_str.parse::() { - if !sandbox_pids.contains(&pid) { - continue; + let mut data = vec![0; count]; + loop { + let n = unsafe { libc::syscall(syscall, fd, data.as_mut_ptr(), count) }; + if n < 0 { + return Err(std::io::Error::last_os_error()); + } + if n == 0 { + return Ok(Vec::new()); + } + let mut offset = 0; + let mut written = 0; + let mut last_kept = 0; + let mut next = [0; 8]; + while offset < n as usize { + if offset + name_offset >= n as usize { + return Err(std::io::Error::from_raw_os_error(libc::EIO)); + } + let len = + u16::from_ne_bytes(data[offset + 16..offset + 18].try_into().unwrap()) as usize; + if len <= name_offset || offset + len > n as usize { + return Err(std::io::Error::from_raw_os_error(libc::EIO)); + } + next.copy_from_slice(&data[offset + 8..offset + 16]); + let name = data[offset + name_offset..offset + len] + .split(|&b| b == 0) + .next() + .unwrap(); + if keep(&String::from_utf8_lossy(name)) { + data.copy_within(offset..offset + len, written); + last_kept = written; + written += len; } + offset += len; } - - d_off += 1; - - let d_type = match entry.file_type() { - Ok(ft) if ft.is_dir() => DT_DIR, - Ok(ft) if ft.is_symlink() => DT_LNK, - _ => DT_REG, - }; - - let d_ino = { - use std::os::linux::fs::MetadataExt; - entry.metadata().map(|m| m.st_ino()).unwrap_or(0) - }; - - if let Some(rec) = build_dirent64(d_ino, d_off, d_type, &name_str) { - entries.push(rec); + if written > 0 { + // The last seek cookie must advance past trailing hidden entries too. + data[last_kept + 8..last_kept + 16].copy_from_slice(&next); + data.truncate(written); + return Ok(data); } } - entries } // ============================================================ -// handle_getdents — PID filtering +// handle_getdents — PID and CPU filtering // ============================================================ -/// Handle getdents64 for PID filtering when `isolate_pids` is true. -/// -/// Intercepts getdents64 calls on /proc directory fds and returns a filtered -/// set of entries that hides PIDs not belonging to the sandbox. +enum ListingFilter { + Pids, + Cpus(u32), +} + +fn sysfs_cpu_id(name: &str) -> Option { + name.strip_prefix("cpu")?.parse().ok() +} + +/// Filter PID and CPU directory entries while preserving the kernel's stream. pub(crate) async fn handle_getdents( notif: &SeccompNotif, - processes: &Arc, - _policy: &NotifPolicy, + ctx: &SupervisorCtx, notif_fd: RawFd, ) -> NotifAction { - let pid = notif.pid; // u32 - let child_fd = (notif.data.args[0] & 0xFFFF_FFFF) as u32; - let buf_addr = notif.data.args[1]; - let buf_size = (notif.data.args[2] & 0xFFFF_FFFF) as usize; - - // Check if the child's fd points to /proc. - let link_path = format!("/proc/{}/fd/{}", pid, child_fd); - let target = match std::fs::read_link(&link_path) { - Ok(t) => t, - Err(_) => return NotifAction::Continue, + let fd = match dup_fd_from_pid(notif.pid, notif.data.args[0] as i32) { + Ok(fd) => fd, + Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EBADF)), }; - if target.to_str() != Some("/proc") { - return NotifAction::Continue; - } - - let entry = match processes.entry_for(pid as i32) { - Some(e) => e, - None => return NotifAction::Continue, + let target = match std::fs::read_link(format!("/proc/self/fd/{}", fd.as_raw_fd())) { + Ok(t) => t, + Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EIO)), }; - let cache_key = (child_fd, target.to_string_lossy().into_owned()); - let mut perproc = entry.1.lock().await; - - // Build and cache entries on first call for this (fd, target) pair. - if !perproc.procfs_dir_cache.contains_key(&cache_key) { - // Snapshot sandbox PIDs without holding the per-process lock - // any longer than needed — pids_snapshot only takes the - // ProcessIndex read lock briefly. - let snapshot = processes.pids_snapshot(); - let entries = build_filtered_dirents(&snapshot); - perproc.procfs_dir_cache.insert(cache_key.clone(), entries); - } - - let entries = match perproc.procfs_dir_cache.get_mut(&cache_key) { - Some(e) => e, - None => return NotifAction::Continue, + let filter = match (target.to_str(), ctx.policy.num_cpus) { + (Some("/proc"), _) => ListingFilter::Pids, + (Some("/sys/devices/system/cpu"), Some(n)) => ListingFilter::Cpus(n), + _ => return NotifAction::Continue, }; - // Pack as many entries as fit into the child's buffer. - let mut result = Vec::new(); - let mut consumed = 0; - for entry in entries.iter() { - if result.len() + entry.len() > buf_size { - break; - } - result.extend_from_slice(entry); - consumed += 1; - } - - // Empty cache = already fully drained on a prior call → return 0 (EOF). - if entries.is_empty() { - perproc.procfs_dir_cache.remove(&cache_key); - return NotifAction::ReturnValue(0); + let _guard = ctx.procfs.lock().await; + if notif.data.nr as i64 == libc::SYS_lseek { + let offset = unsafe { + libc::lseek( + fd.as_raw_fd(), + notif.data.args[1] as i64, + notif.data.args[2] as i32, + ) + }; + return if offset < 0 { + NotifAction::Errno( + std::io::Error::last_os_error() + .raw_os_error() + .unwrap_or(libc::EIO), + ) + } else { + NotifAction::ReturnValue(offset) + }; } - if consumed > 0 { - entries.drain(..consumed); + let initial_offset = unsafe { libc::lseek(fd.as_raw_fd(), 0, libc::SEEK_CUR) }; + if initial_offset < 0 { + return NotifAction::Errno( + std::io::Error::last_os_error() + .raw_os_error() + .unwrap_or(libc::EIO), + ); } - - drop(perproc); - - // Write the result into the child's buffer and return the byte count. - if !result.is_empty() { - if write_child_mem(notif_fd, notif.id, pid, buf_addr, &result).is_err() { - return NotifAction::Continue; + let pids = ctx.processes.pids_snapshot(); + let count = (notif.data.args[2] as u32 as usize).min(1024 * 1024); + let result = + read_filtered_dirents( + fd.as_raw_fd(), + notif.data.nr as i64, + count, + |name| match filter { + ListingFilter::Pids => name.parse::().map_or(true, |pid| pids.contains(&pid)), + ListingFilter::Cpus(n) => sysfs_cpu_id(name).map_or(true, |id| id < n), + }, + ) + .and_then(|data| { + if !data.is_empty() + && write_child_mem(notif_fd, notif.id, notif.pid, notif.data.args[1], &data) + .is_err() + { + return Err(std::io::Error::from_raw_os_error(libc::EFAULT)); + } + Ok(data.len()) + }); + match result { + Ok(len) => NotifAction::ReturnValue(len as i64), + Err(e) => { + unsafe { libc::lseek(fd.as_raw_fd(), initial_offset, libc::SEEK_SET) }; + NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EIO)) } } - - NotifAction::ReturnValue(result.len() as i64) } // ============================================================ @@ -1636,6 +1716,21 @@ mod tests { ); } + #[test] + fn test_filter_proc_stat() { + let real = "cpu 6 0 9\ncpu0 1 0 2\ncpu1 2 0 3\ncpu2 3 0 4\nintr 7\nctxt 8\n"; + assert_eq!( + String::from_utf8(filter_proc_stat(real, 2)).unwrap(), + "cpu 3 0 5\ncpu0 1 0 2\ncpu1 2 0 3\nintr 7\nctxt 8\n" + ); + } + + #[test] + fn test_generate_cpu_list() { + assert_eq!(generate_cpu_list(1), b"0\n"); + assert_eq!(generate_cpu_list(4), b"0-3\n"); + } + #[test] fn test_generate_cpuinfo_single() { let info = generate_cpuinfo(1); @@ -1920,11 +2015,28 @@ mod tests { } #[test] - fn test_build_filtered_dirents() { - use std::collections::HashSet; - let mut sandbox_pids = HashSet::new(); - sandbox_pids.insert(1_i32); - let entries = build_filtered_dirents(&sandbox_pids); - assert!(!entries.is_empty()); + fn test_read_filtered_dirents_skips_hidden_batches() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("visible"), b"").unwrap(); + for nr in [Some(libc::SYS_getdents64), crate::arch::sys_getdents()] + .into_iter() + .flatten() + { + let fd = std::fs::File::open(dir.path()).unwrap(); + let data = + read_filtered_dirents(fd.as_raw_fd(), nr, 32, |name| name == "visible").unwrap(); + let start = if nr == libc::SYS_getdents64 { 19 } else { 18 }; + assert_eq!(&data[start..start + 8], b"visible\0"); + assert!(read_filtered_dirents(fd.as_raw_fd(), nr, 32, |_| true) + .unwrap() + .is_empty()); + } + } + + #[test] + fn test_sysfs_cpu_id() { + assert_eq!(sysfs_cpu_id("cpu12"), Some(12)); + assert_eq!(sysfs_cpu_id("cpufreq"), None); + assert_eq!(sysfs_cpu_id("online"), None); } } diff --git a/crates/sandlock-core/src/seccomp/dispatch.rs b/crates/sandlock-core/src/seccomp/dispatch.rs index fe953c8f..7b8ec809 100644 --- a/crates/sandlock-core/src/seccomp/dispatch.rs +++ b/crates/sandlock-core/src/seccomp/dispatch.rs @@ -586,22 +586,17 @@ pub(crate) fn build_dispatch_table( // empty rather than leaking the host's /proc entry names (which are not all // synthesized and would dangle on access). // ------------------------------------------------------------------ - let mut getdents_nrs = vec![libc::SYS_getdents64]; + let mut getdents_nrs = vec![libc::SYS_getdents64, libc::SYS_lseek]; if let Some(getdents) = arch::sys_getdents() { getdents_nrs.push(getdents); } for nr in getdents_nrs { - let policy_for_getdents = Arc::clone(policy); - let __sup = Arc::clone(ctx); + let sup = Arc::clone(ctx); table.register(nr, move |cx: &HandlerCtx| { let notif = cx.notif; - let sup = Arc::clone(&__sup); + let sup = Arc::clone(&sup); let notif_fd = cx.notif_fd; - let policy = Arc::clone(&policy_for_getdents); - async move { - let processes = Arc::clone(&sup.processes); - crate::procfs::handle_getdents(¬if, &processes, &policy, notif_fd).await - } + async move { crate::procfs::handle_getdents(¬if, &sup, notif_fd).await } }); } diff --git a/crates/sandlock-core/tests/integration/test_procfs.rs b/crates/sandlock-core/tests/integration/test_procfs.rs index a91af9ef..dbe54337 100644 --- a/crates/sandlock-core/tests/integration/test_procfs.rs +++ b/crates/sandlock-core/tests/integration/test_procfs.rs @@ -11,6 +11,7 @@ async fn test_num_cpus_virtualization() { .fs_read("/bin") .fs_read("/etc") .fs_read("/proc") + .fs_read("/sys") .num_cpus(2) .build() .unwrap(); @@ -30,18 +31,121 @@ async fn test_num_cpus_virtualization() { stdout.trim() ); - // Verify nproc (sched_getaffinity) also reports 2. - let result = policy.clone().run(&["nproc"]).await.unwrap(); - assert!(result.success(), "nproc should succeed"); - let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); - assert_eq!( - stdout.trim(), - "2", - "nproc should report 2 CPUs, got: {:?}", - stdout.trim() + // nproc reads the affinity mask, getconf the sysfs CPU lists. + for cmd in [ + "nproc", + "getconf _NPROCESSORS_ONLN", + "getconf _NPROCESSORS_CONF", + "cat /sys/devices/system/cpu/present", + "grep -c '^cpu[0-9]' /proc/stat", + "ls /sys/devices/system/cpu | grep -c '^cpu[0-9]'", + ] { + let result = policy.clone().run(&["sh", "-c", cmd]).await.unwrap(); + assert!(result.success(), "{cmd} should succeed"); + let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); + let want = if cmd.starts_with("cat") { "0-1" } else { "2" }; + assert_eq!(stdout.trim(), want, "{cmd} should report 2 CPUs"); + } +} + +async fn check_cpu_directory_stream(syscall: i64) { + let mut policy = proc_grant().fs_read("/sys").num_cpus(2).build().unwrap(); + let script = r#" +import ctypes, errno, os, sys +libc = ctypes.CDLL(None, use_errno=True) +nr, name_offset = map(int, sys.argv[1:]) +buf = ctypes.create_string_buffer(4096) +def read(fd, count=4096, addr=None): + n = libc.syscall(nr, fd, ctypes.byref(buf) if addr is None else addr, count) + if n < 0: + return n, ctypes.get_errno() + records = [] + offset = 0 + while offset < n: + length = int.from_bytes(buf.raw[offset+16:offset+18], sys.byteorder) + assert length >= 24 and offset + length <= n, (offset, length, n) + name = buf.raw[offset+name_offset:offset+length].split(b'\0')[0].decode() + cookie = int.from_bytes(buf.raw[offset+8:offset+16], sys.byteorder, signed=True) + kind = buf.raw[offset+18] if name_offset == 19 else buf.raw[offset+length-1] + records.append((name, cookie, kind)) + offset += length + return n, records +def open_dir(): + return os.open('/sys/devices/system/cpu', os.O_RDONLY | os.O_DIRECTORY) +fd = open_dir() +records = [] +for _ in range(1000): + n, batch = read(fd, 64) + assert n >= 0, (n, batch) + if n == 0: + break + records.extend(batch) +else: + raise AssertionError('directory never reached EOF') +cpus = {name: kind for name, _, kind in records if name.startswith('cpu') and name[3:].isdigit()} +assert cpus == {'cpu0': 4, 'cpu1': 4}, cpus +assert read(fd) == (0, []), 'EOF must persist' +assert read(fd) == (0, []), 'EOF must persist across repeated reads' +os.lseek(fd, 0, os.SEEK_SET) +first = read(fd, 64) +assert first[0] > 0, first +other = os.dup(fd) +os.lseek(other, 0, os.SEEK_SET) +assert read(fd, 64) == first, 'rewind through dup must reset the shared cursor' +second = read(other, 64) +assert second[0] > 0, second +assert second[1][0][0] not in {r[0] for r in first[1]}, 'dup must share the cursor' +os.lseek(fd, first[1][-1][1], os.SEEK_SET) +assert read(other, 64) == second, 'd_off must be a usable seek cookie' +os.lseek(fd, 0, os.SEEK_SET) +assert read(fd, 1) == (-1, errno.EINVAL) +assert read(fd, 4096, ctypes.c_void_p(1)) == (-1, errno.EFAULT) +assert read(fd, 64) == first, 'failed reads must preserve the cursor' +os.lseek(fd, 0, os.SEEK_SET) +pid = os.fork() +if pid == 0: + try: + assert read(fd, 64) == first + except BaseException: + os._exit(1) + os._exit(0) +assert os.waitpid(pid, 0)[1] == 0 +assert read(other, 64) == second, 'fork must share the cursor' +os.close(other) +old_fd = fd +os.close(fd) +fd = open_dir() +assert fd == old_fd, (fd, old_fd) +assert read(fd, 64) == first, 'reused fd must start a new stream' +os.close(fd) +"#; + let name_offset = if syscall == libc::SYS_getdents64 { + "19" + } else { + "18" + }; + let result = policy + .run(&["python3", "-c", script, &syscall.to_string(), name_offset]) + .await + .unwrap(); + assert!( + result.success(), + "{}", + String::from_utf8_lossy(result.stderr.as_deref().unwrap_or_default()) ); } +#[tokio::test] +async fn test_cpu_directory_stream() { + check_cpu_directory_stream(libc::SYS_getdents64).await; +} + +#[tokio::test] +#[cfg(target_arch = "x86_64")] +async fn test_cpu_directory_stream_legacy() { + check_cpu_directory_stream(libc::SYS_getdents).await; +} + /// Test that max_memory virtualizes /proc/meminfo. #[tokio::test] async fn test_meminfo_virtualization() {