Files
OpenShell/crates/openshell-supervisor-network/src/procfs.rs
T
Simon Scatton 1959ea19be build(bazel): establish RFC 0012 Rust reference graph (#2414)
Establish the Phase 1 reference implementation proposed by RFC 0012
while retaining the existing Cargo and Mise workflows during evaluation.

- pin Bazel 9.1.1 and configure Bzlmod, rules_rs, LLVM, protobuf, and
  Rust 1.95 toolchains
- import third-party crates from Cargo metadata and propagate the
  workspace version into Bazel targets
- add library, binary, proc-macro, unit-test, and integration-test
  targets across the supported Rust workspace crates and drivers
- generate protobuf Rust sources and descriptor sets under Bazel while
  preserving Cargo-compatible generated-code imports
- annotate aws-lc-sys and zstd-sys native dependencies, build Z3 4.15.2
  from source, and generate z3-sys bindings
- make CLI and procfs test fixtures available as explicit Bazel inputs
  without relying on fixed host binary paths
- define optimized release targets for Linux x86_64 and aarch64 CLI,
  sandbox, and gateway binaries, plus macOS aarch64 artifacts
- add Bazel, buildifier, and lcov to the Nix development environment

RFC: 0012 (rfc12 branch)
Refs: #2491

Signed-off-by: Simon Scatton <sscatton@nvidia.com>
2026-08-03 09:05:40 +00:00

1057 lines
38 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Linux `/proc` filesystem reading for process identity.
//!
//! Provides functions to resolve binary paths and compute file hashes
//! for process-identity binding in the OPA proxy policy engine.
use miette::Result;
#[cfg(target_os = "linux")]
use std::collections::HashSet;
use std::net::SocketAddr;
#[cfg(target_os = "linux")]
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::path::Path;
#[cfg(target_os = "linux")]
use std::path::PathBuf;
use tracing::debug;
/// Where a socket owner was discovered while scanning `/proc`.
#[cfg(target_os = "linux")]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SocketOwnerSource {
/// Owner was found in the entrypoint process tree at the given BFS depth.
Descendant { depth: usize },
/// Owner was found by scanning all of `/proc` after the descendant scan.
ProcFallback,
}
/// A process with an fd pointing at a target socket inode.
#[cfg(target_os = "linux")]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SocketOwner {
pub pid: u32,
pub source: SocketOwnerSource,
}
/// All process owners for a TCP peer socket.
#[cfg(target_os = "linux")]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TcpPeerSocketOwners {
pub inode: u64,
pub owners: Vec<SocketOwner>,
}
/// TCP endpoints for a workload connection accepted by the sandbox proxy.
///
/// `/proc/<pid>/net/tcp{,6}` reports the socket from the workload side, so its
/// local endpoint should match `workload` and its remote endpoint should match
/// `proxy`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WorkloadProxyTcpConnection {
pub workload: SocketAddr,
pub proxy: SocketAddr,
}
impl WorkloadProxyTcpConnection {
pub fn new(workload: SocketAddr, proxy: SocketAddr) -> Self {
Self { workload, proxy }
}
}
impl std::fmt::Display for WorkloadProxyTcpConnection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} -> {}", self.workload, self.proxy)
}
}
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ProcTcpAddressFamily {
Ipv4,
Ipv6,
}
#[cfg(target_os = "linux")]
impl ProcTcpAddressFamily {
fn table_name(self) -> &'static str {
match self {
Self::Ipv4 => "tcp",
Self::Ipv6 => "tcp6",
}
}
}
#[cfg(target_os = "linux")]
#[derive(Clone, Debug, Eq, PartialEq)]
struct DescendantPid {
pid: u32,
depth: usize,
}
/// Read the binary path of a process via `/proc/{pid}/exe` symlink.
///
/// Returns the canonical path to the executable that the process is running.
/// Fails hard if the exe symlink is not readable — we never fall back to
/// `/proc/{pid}/cmdline` because `argv[0]` is trivially spoofable by any
/// process and must not be used as a trusted identity source.
///
/// ### Unlinked binaries (`(deleted)` suffix)
///
/// When a running binary is unlinked from its filesystem path — the common
/// case is a hot-swap of `/opt/openshell/bin/openshell-sandbox` during a
/// development upgrade — the kernel appends the
/// literal string `" (deleted)"` to the `/proc/<pid>/exe` readlink target.
/// The raw tainted path (e.g. `"/opt/openshell/bin/openshell-sandbox (deleted)"`)
/// is not a real filesystem path: any downstream `stat()` fails with `ENOENT`.
///
/// We strip the suffix so callers see a clean, grep-friendly path suitable
/// for cache keys and log messages. The strip is guarded: we only strip when
/// `stat()` on the raw readlink target reports `NotFound`, so a live executable
/// whose basename literally ends with `" (deleted)"` is returned unchanged.
/// The comparison is done on raw bytes via `OsStrExt`, so filenames that are
/// not valid UTF-8 are still handled correctly. Exactly one kernel-added
/// suffix is stripped.
///
/// This does NOT claim the file at the stripped path is the same binary that
/// the process is executing — the on-disk inode may now be arbitrary. Callers
/// that need to verify the running binary's *contents* (for integrity
/// checking) should read the magic `/proc/<pid>/exe` symlink directly via
/// `File::open`, which procfs resolves to the live in-memory executable even
/// when the original inode has been unlinked.
///
/// If the readlink itself fails, ensure the proxy process has permission
/// to read `/proc/<pid>/exe` (e.g. same user, or `CAP_SYS_PTRACE`).
#[cfg(target_os = "linux")]
pub fn binary_path(pid: i32) -> Result<PathBuf> {
use std::ffi::OsString;
use std::io::ErrorKind;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
const DELETED_SUFFIX: &[u8] = b" (deleted)";
let link = format!("/proc/{pid}/exe");
let target = std::fs::read_link(&link).map_err(|e| {
miette::miette!(
"Failed to read /proc/{pid}/exe: {e}. \
Cannot determine binary identity — denying request. \
Hint: the proxy may need CAP_SYS_PTRACE or to run as the same user."
)
})?;
// Only strip when the raw readlink target cannot be stat'd and its bytes
// end with the kernel-added suffix. This preserves live executables whose
// basename legitimately ends with " (deleted)" and handles non-UTF-8
// filenames correctly.
let raw_target_missing =
matches!(std::fs::metadata(&target), Err(err) if err.kind() == ErrorKind::NotFound);
let bytes = target.as_os_str().as_bytes();
if raw_target_missing && bytes.ends_with(DELETED_SUFFIX) {
let stripped = bytes[..bytes.len() - DELETED_SUFFIX.len()].to_vec();
return Ok(PathBuf::from(OsString::from_vec(stripped)));
}
Ok(target)
}
/// Resolve the binary path of the TCP peer inside a sandbox network namespace.
///
/// Uses `/proc/<entrypoint_pid>/net/tcp` to find the socket inode for the given
/// connection tuple, then scans the entrypoint process tree to find which PID
/// owns that socket, and finally reads `/proc/<pid>/exe` to get the binary path.
#[cfg(target_os = "linux")]
pub fn resolve_tcp_peer_binary(
entrypoint_pid: u32,
connection: WorkloadProxyTcpConnection,
) -> Result<PathBuf> {
let owner = resolve_single_tcp_peer_owner(entrypoint_pid, connection)?;
binary_path(owner.pid.cast_signed())
}
/// Resolve all process owners for the TCP peer inside a sandbox network namespace.
///
/// Multiple processes can legitimately hold the same socket inode after `fork()`
/// or fd passing. Callers that make security decisions must evaluate the full
/// owner set instead of selecting the first PID returned by `/proc` traversal.
#[cfg(target_os = "linux")]
pub fn resolve_tcp_peer_socket_owners(
entrypoint_pid: u32,
connection: WorkloadProxyTcpConnection,
) -> Result<TcpPeerSocketOwners> {
let inode = parse_proc_net_tcp(entrypoint_pid, connection)?;
let owners = find_socket_inode_owners(inode, entrypoint_pid)?;
Ok(TcpPeerSocketOwners { inode, owners })
}
/// Resolve exactly one owner for the TCP peer, failing closed on ambiguity.
#[cfg(target_os = "linux")]
fn resolve_single_tcp_peer_owner(
entrypoint_pid: u32,
connection: WorkloadProxyTcpConnection,
) -> Result<SocketOwner> {
let socket_owners = resolve_tcp_peer_socket_owners(entrypoint_pid, connection)?;
match socket_owners.owners.as_slice() {
[owner] => Ok(owner.clone()),
owners => {
let mut pids: Vec<u32> = owners.iter().map(|owner| owner.pid).collect();
pids.sort_unstable();
Err(miette::miette!(
"Ambiguous socket ownership for inode {}: PIDs [{}] all hold the same socket",
socket_owners.inode,
pids.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ")
))
}
}
}
/// Like `resolve_tcp_peer_binary`, but also returns the PID that owns the socket.
///
/// Needed for the ancestor walk: we must know the PID to walk `/proc/<pid>/status` `PPid` chain.
#[cfg(target_os = "linux")]
pub fn resolve_tcp_peer_identity(
entrypoint_pid: u32,
connection: WorkloadProxyTcpConnection,
) -> Result<(PathBuf, u32)> {
let owner = resolve_single_tcp_peer_owner(entrypoint_pid, connection)?;
let path = binary_path(owner.pid.cast_signed())?;
Ok((path, owner.pid))
}
/// Read the `PPid` (parent PID) from `/proc/<pid>/status`.
#[cfg(target_os = "linux")]
pub fn read_ppid(pid: u32) -> Option<u32> {
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
for line in status.lines() {
if let Some(rest) = line.strip_prefix("PPid:") {
return rest.trim().parse().ok();
}
}
None
}
/// Walk the process tree upward from `pid`, collecting the binary path of each ancestor.
///
/// Stops at PID 1 (init), `stop_pid` (the entrypoint process), or after 64 ancestors
/// as a safety limit. The returned vec does NOT include `pid` itself — only its parents.
#[cfg(target_os = "linux")]
#[allow(clippy::similar_names)]
pub fn collect_ancestor_binaries(pid: u32, stop_pid: u32) -> Vec<PathBuf> {
const MAX_DEPTH: usize = 64;
let mut ancestors = Vec::new();
let mut current = pid;
for _ in 0..MAX_DEPTH {
let ppid = match read_ppid(current) {
Some(p) if p > 0 && p != current => p,
_ => break,
};
if let Ok(path) = binary_path(ppid.cast_signed()) {
ancestors.push(path);
}
// Stop if we've reached the entrypoint or init
if ppid == stop_pid || ppid == 1 {
break;
}
current = ppid;
}
ancestors
}
/// Extract absolute paths from `/proc/<pid>/cmdline`.
///
/// Reads the null-separated cmdline and returns any argv entries that look like
/// absolute paths (starting with `/`). This captures script paths that don't
/// appear in `/proc/<pid>/exe` — e.g. when `#!/usr/bin/env node` runs
/// `/usr/local/bin/claude`, the exe is `/usr/bin/node` but cmdline contains
/// `node\0/usr/local/bin/claude\0...`.
#[cfg(target_os = "linux")]
pub fn cmdline_absolute_paths(pid: u32) -> Vec<PathBuf> {
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
return vec![];
};
cmdline
.split(|&b| b == 0)
.filter(|arg| arg.first() == Some(&b'/'))
.map(|arg| PathBuf::from(String::from_utf8_lossy(arg).into_owned()))
.collect()
}
/// Collect cmdline absolute paths for a PID and its ancestor chain.
///
/// Returns deduplicated absolute paths from `/proc/<pid>/cmdline` for the given
/// PID and each ancestor up to `stop_pid` / PID 1. Paths already present in
/// `exclude` (typically the exe-based paths) are omitted to avoid duplicates.
#[cfg(target_os = "linux")]
#[allow(clippy::similar_names)]
pub fn collect_cmdline_paths(pid: u32, stop_pid: u32, exclude: &[PathBuf]) -> Vec<PathBuf> {
const MAX_DEPTH: usize = 64;
let mut paths = Vec::new();
let mut current = pid;
// Collect from the immediate PID first
for p in cmdline_absolute_paths(current) {
if !exclude.contains(&p) && !paths.contains(&p) {
paths.push(p);
}
}
// Then walk ancestors (same traversal as collect_ancestor_binaries)
for _ in 0..MAX_DEPTH {
let ppid = match read_ppid(current) {
Some(p) if p > 0 && p != current => p,
_ => break,
};
for p in cmdline_absolute_paths(ppid) {
if !exclude.contains(&p) && !paths.contains(&p) {
paths.push(p);
}
}
if ppid == stop_pid || ppid == 1 {
break;
}
current = ppid;
}
paths
}
/// Parse `/proc/<pid>/net/tcp` (and `/proc/<pid>/net/tcp6`) to find the socket
/// inode for a complete local-to-remote TCP tuple.
///
/// Checks both IPv4 and IPv6 tables because some clients (notably gRPC C-core)
/// use `AF_INET6` sockets with IPv4-mapped addresses even for IPv4 connections.
///
/// Format of `/proc/net/tcp`:
/// ```text
/// sl local_address rem_address st tx_queue:rx_queue ... inode
/// 0: 0200C80A:8F4C 0100C80A:0C38 01 00000000:00000000 ... 12345
/// ```
/// - Addresses: hex IP (host byte order) `:` hex port
/// - State `01` = ESTABLISHED
/// - Inode is field index 9 (0-indexed)
#[cfg(target_os = "linux")]
fn parse_proc_net_tcp(pid: u32, connection: WorkloadProxyTcpConnection) -> Result<u64> {
// Check IPv4 first (most common), then IPv6.
for address_family in [ProcTcpAddressFamily::Ipv4, ProcTcpAddressFamily::Ipv6] {
let path = format!("/proc/{pid}/net/{}", address_family.table_name());
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
if let Some(inode) = find_proc_net_tcp_inode(&content, address_family, connection)? {
return Ok(inode);
}
}
Err(miette::miette!(
"No ESTABLISHED TCP connection found for {connection} in /proc/{pid}/net/tcp{{,6}}"
))
}
#[cfg(target_os = "linux")]
fn find_proc_net_tcp_inode(
content: &str,
address_family: ProcTcpAddressFamily,
connection: WorkloadProxyTcpConnection,
) -> Result<Option<u64>> {
for line in content.lines().skip(1) {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 10 || fields[3] != "01" {
continue;
}
let Some(local_addr) = parse_proc_socket_addr(fields[1], address_family) else {
continue;
};
let Some(remote_addr) = parse_proc_socket_addr(fields[2], address_family) else {
continue;
};
if socket_addrs_match(local_addr, connection.workload)
&& socket_addrs_match(remote_addr, connection.proxy)
{
let inode: u64 = fields[9]
.parse()
.map_err(|_| miette::miette!("Failed to parse inode from {}", fields[9]))?;
if inode != 0 {
return Ok(Some(inode));
}
}
}
Ok(None)
}
#[cfg(target_os = "linux")]
fn parse_proc_socket_addr(value: &str, address_family: ProcTcpAddressFamily) -> Option<SocketAddr> {
let (address, port) = value.rsplit_once(':')?;
let port = u16::from_str_radix(port, 16).ok()?;
let ip = match address_family {
ProcTcpAddressFamily::Ipv4 => {
let address = u32::from_str_radix(address, 16).ok()?;
IpAddr::V4(Ipv4Addr::from(address.to_le_bytes()))
}
ProcTcpAddressFamily::Ipv6 => {
if address.len() != 32 {
return None;
}
let mut bytes = [0u8; 16];
for (index, chunk) in address.as_bytes().chunks_exact(8).enumerate() {
let chunk = std::str::from_utf8(chunk).ok()?;
let word = u32::from_str_radix(chunk, 16).ok()?;
bytes[index * 4..index * 4 + 4].copy_from_slice(&word.to_le_bytes());
}
IpAddr::V6(Ipv6Addr::from(bytes))
}
};
Some(SocketAddr::new(ip, port))
}
#[cfg(target_os = "linux")]
fn socket_addrs_match(left: SocketAddr, right: SocketAddr) -> bool {
left.port() == right.port() && normalize_ip(left.ip()) == normalize_ip(right.ip())
}
#[cfg(target_os = "linux")]
fn normalize_ip(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(ipv6) => ipv6.to_ipv4_mapped().map_or(IpAddr::V6(ipv6), IpAddr::V4),
ipv4 @ IpAddr::V4(_) => ipv4,
}
}
/// Scan `/proc` to find every PID that owns a given socket inode.
///
/// First scans descendants of `entrypoint_pid` (most likely owners), then falls
/// back to scanning all of `/proc`. Requires `CAP_SYS_PTRACE` to read
/// `/proc/<pid>/fd/` for processes running as a different user.
#[cfg(target_os = "linux")]
fn find_socket_inode_owners(inode: u64, entrypoint_pid: u32) -> Result<Vec<SocketOwner>> {
let target = format!("socket:[{inode}]");
let mut owners = Vec::new();
let mut checked = HashSet::new();
// First: scan descendants of the entrypoint process
let descendants = collect_descendant_pids_with_depth(entrypoint_pid);
for descendant in &descendants {
checked.insert(descendant.pid);
if check_pid_fds(descendant.pid, &target) {
owners.push(SocketOwner {
pid: descendant.pid,
source: SocketOwnerSource::Descendant {
depth: descendant.depth,
},
});
}
}
// Fallback: scan all of /proc in case the process isn't in the tree
if let Ok(proc_dir) = std::fs::read_dir("/proc") {
let mut proc_pids = Vec::new();
for entry in proc_dir.flatten() {
let name = entry.file_name();
if let Ok(pid) = name.to_string_lossy().parse::<u32>() {
proc_pids.push(pid);
}
}
proc_pids.sort_unstable();
for pid in proc_pids {
if checked.contains(&pid) {
continue;
}
checked.insert(pid);
if check_pid_fds(pid, &target) {
owners.push(SocketOwner {
pid,
source: SocketOwnerSource::ProcFallback,
});
}
}
}
if !owners.is_empty() {
return Ok(owners);
}
Err(miette::miette!(
"No process found owning socket inode {} \
(scanned {} descendants of entrypoint PID {}). \
Hint: the container may need --cap-add=SYS_PTRACE to read /proc/<pid>/fd/ \
for processes running as a different user.",
inode,
descendants.len(),
entrypoint_pid
))
}
/// Check if a PID has an fd pointing to the given socket target string.
#[cfg(target_os = "linux")]
fn check_pid_fds(pid: u32, target: &str) -> bool {
let fd_dir = format!("/proc/{pid}/fd");
let Some(fds) = std::fs::read_dir(&fd_dir).ok() else {
return false;
};
for fd_entry in fds.flatten() {
if let Ok(link) = std::fs::read_link(fd_entry.path())
&& link.to_string_lossy() == target
{
return true;
}
}
false
}
/// Collect all descendant PIDs of a root process using `/proc/<pid>/task/<tid>/children`.
///
/// Performs a BFS walk of the process tree. If `/proc/<pid>/task/<tid>/children`
/// is not available (requires `CONFIG_PROC_CHILDREN`), returns only the root PID.
#[cfg(all(test, target_os = "linux"))]
fn collect_descendant_pids(root_pid: u32) -> Vec<u32> {
collect_descendant_pids_with_depth(root_pid)
.into_iter()
.map(|descendant| descendant.pid)
.collect()
}
/// Collect descendant PIDs with BFS depth, deduping children reported by multiple tasks.
#[cfg(target_os = "linux")]
fn collect_descendant_pids_with_depth(root_pid: u32) -> Vec<DescendantPid> {
let mut pids = vec![DescendantPid {
pid: root_pid,
depth: 0,
}];
let mut seen = HashSet::from([root_pid]);
let mut i = 0;
while i < pids.len() {
let pid = pids[i].pid;
let child_depth = pids[i].depth + 1;
let task_dir = format!("/proc/{pid}/task");
if let Ok(tasks) = std::fs::read_dir(&task_dir) {
for task_entry in tasks.flatten() {
let children_path = task_entry.path().join("children");
if let Ok(children_str) = std::fs::read_to_string(&children_path) {
for child in children_str.split_whitespace() {
if let Ok(child_pid) = child.parse::<u32>()
&& seen.insert(child_pid)
{
pids.push(DescendantPid {
pid: child_pid,
depth: child_depth,
});
}
}
}
}
}
i += 1;
}
pids
}
/// Compute the SHA256 hash of a file, returned as a hex-encoded string.
///
/// Used for binary integrity verification in the trust-on-first-use (TOFU)
/// model: the proxy hashes a binary on first network request and caches the
/// result. Subsequent requests from the same binary path must produce the
/// same hash, or the request is denied.
pub fn file_sha256(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
use std::io::Read;
let start = std::time::Instant::now();
let mut file = std::fs::File::open(path)
.map_err(|e| miette::miette!("Failed to open {}: {e}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 65536].into_boxed_slice();
let mut total_read = 0u64;
loop {
let n = file
.read(&mut buf)
.map_err(|e| miette::miette!("Failed to read {}: {e}", path.display()))?;
if n == 0 {
break;
}
total_read += n as u64;
hasher.update(&buf[..n]);
}
let hash = hasher.finalize();
debug!(
" file_sha256: {}ms size={} path={}",
start.elapsed().as_millis(),
total_read,
path.display()
);
Ok(hex::encode(hash))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[cfg(target_os = "linux")]
use std::os::unix::process::CommandExt as _;
/// Block until `/proc/<pid>/exe` points at `target`. `Command::spawn` returns
/// once the child is scheduled, not once it has completed `exec()`; on
/// contended runners the readlink can still show the parent (test harness)
/// binary for a brief window. Byte-level `starts_with` tolerates the kernel's
/// `" (deleted)"` suffix on unlinked executables.
#[cfg(target_os = "linux")]
fn wait_for_child_exec(pid: i32, target: &Path) {
use std::os::unix::ffi::OsStrExt as _;
let target_bytes = target.as_os_str().as_bytes();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if let Ok(link) = std::fs::read_link(format!("/proc/{pid}/exe"))
&& link.as_os_str().as_bytes().starts_with(target_bytes)
{
return;
}
assert!(
std::time::Instant::now() < deadline,
"child pid {pid} did not exec into {target:?} within 2s"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
/// Retry `Command::spawn` on `ETXTBSY`. The kernel rejects `execve` when
/// `inode->i_writecount > 0`, and the release of that counter after the
/// writer fd is closed isn't synchronous with `close(2)` under contention —
/// so the very-next-instruction `execve` can still race it. Any other error
/// surfaces immediately.
#[cfg(target_os = "linux")]
fn spawn_retrying_on_etxtbsy(cmd: &mut std::process::Command) -> std::process::Child {
let mut attempts = 0;
loop {
match cmd.spawn() {
Ok(child) => return child,
Err(err)
if err.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 20 =>
{
attempts += 1;
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(err) => panic!("spawn failed after {attempts} ETXTBSY retries: {err}"),
}
}
}
#[cfg(target_os = "linux")]
fn sleep_binary() -> PathBuf {
let path = std::env::var_os("PATH").expect("PATH should be set for tests");
std::env::split_paths(&path)
.map(|dir| dir.join("sleep"))
.find(|candidate| candidate.is_file())
.expect("sleep should be available on PATH")
}
#[test]
fn file_sha256_computes_correct_hash() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(b"hello world").unwrap();
tmp.flush().unwrap();
let hash = file_sha256(tmp.path()).unwrap();
// SHA256 of "hello world"
assert_eq!(
hash,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn file_sha256_different_content_different_hash() {
let mut tmp1 = tempfile::NamedTempFile::new().unwrap();
tmp1.write_all(b"content a").unwrap();
tmp1.flush().unwrap();
let mut tmp2 = tempfile::NamedTempFile::new().unwrap();
tmp2.write_all(b"content b").unwrap();
tmp2.flush().unwrap();
let hash1 = file_sha256(tmp1.path()).unwrap();
let hash2 = file_sha256(tmp2.path()).unwrap();
assert_ne!(hash1, hash2);
}
#[cfg(target_os = "linux")]
#[test]
fn binary_path_reads_current_process() {
let pid = std::process::id().cast_signed();
let path = binary_path(pid).unwrap();
// Should resolve to the test runner binary
assert!(path.exists());
}
/// Verify that an unlinked binary's path is returned without the
/// kernel's " (deleted)" suffix. This is the common case during a
/// `docker cp` hot-swap of the supervisor binary — before this strip,
/// callers that `stat()` the returned path get `ENOENT` and the
/// ancestor integrity check in the CONNECT proxy denies every request.
#[cfg(target_os = "linux")]
#[test]
fn binary_path_strips_deleted_suffix() {
use std::os::unix::fs::PermissionsExt;
// Copy sleep to a temp path we control so we can unlink it.
let tmp = tempfile::TempDir::new().unwrap();
let exe_path = tmp.path().join("deleted-sleep");
std::fs::copy(sleep_binary(), &exe_path).unwrap();
std::fs::set_permissions(&exe_path, std::fs::Permissions::from_mode(0o755)).unwrap();
// Spawn a child from the temp binary, then unlink it while the
// child is still running. The child keeps the exec mapping via
// `/proc/<pid>/exe`, but readlink will now return the tainted
// "<path> (deleted)" string.
let mut cmd = std::process::Command::new(&exe_path);
cmd.arg0("sleep");
cmd.arg("5");
let mut child = spawn_retrying_on_etxtbsy(&mut cmd);
let pid: i32 = child.id().cast_signed();
wait_for_child_exec(pid, &exe_path);
std::fs::remove_file(&exe_path).unwrap();
// Sanity check: the raw readlink should contain " (deleted)".
let raw = std::fs::read_link(format!("/proc/{pid}/exe"))
.unwrap()
.to_string_lossy()
.into_owned();
assert!(
raw.ends_with(" (deleted)"),
"kernel should append ' (deleted)' to unlinked exe readlink; got {raw:?}"
);
// The public API should return the stripped path, not the tainted one.
let resolved = binary_path(pid).expect("binary_path should succeed for deleted binary");
assert_eq!(
resolved, exe_path,
"binary_path should strip the ' (deleted)' suffix"
);
let resolved_str = resolved.to_string_lossy();
assert!(
!resolved_str.contains("(deleted)"),
"stripped path must not contain '(deleted)'; got {resolved_str:?}"
);
let _ = child.kill();
let _ = child.wait();
}
/// A live executable whose basename literally ends with `" (deleted)"`
/// must be returned unchanged — we only strip when `stat()` reports
/// the raw readlink target missing. This guards against the trusted
/// identity source misattributing a running binary to a truncated
/// sibling path.
#[cfg(target_os = "linux")]
#[test]
fn binary_path_preserves_live_deleted_basename() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
// Basename literally ends with " (deleted)" while the file is still
// on disk — a pathological but legal filename.
let exe_path = tmp.path().join("sleepy (deleted)");
std::fs::copy(sleep_binary(), &exe_path).unwrap();
std::fs::set_permissions(&exe_path, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut cmd = std::process::Command::new(&exe_path);
cmd.arg0("sleep");
cmd.arg("5");
let mut child = spawn_retrying_on_etxtbsy(&mut cmd);
let pid: i32 = child.id().cast_signed();
wait_for_child_exec(pid, &exe_path);
// File is still linked — binary_path must return the path unchanged,
// suffix and all.
let resolved = binary_path(pid).expect("binary_path should succeed for live binary");
assert_eq!(
resolved, exe_path,
"binary_path must NOT strip ' (deleted)' from a live executable's basename"
);
assert!(
resolved.to_string_lossy().ends_with(" (deleted)"),
"stripped path unexpectedly trimmed a real filename: {resolved:?}"
);
let _ = child.kill();
let _ = child.wait();
}
/// An unlinked executable whose filename contains non-UTF-8 bytes must
/// still strip exactly one kernel-added `" (deleted)"` suffix. We operate
/// on raw bytes via `OsStrExt`, so invalid UTF-8 is not a reason to skip
/// the strip and return a path that downstream `stat()` calls will reject.
#[cfg(target_os = "linux")]
#[test]
fn binary_path_strips_suffix_for_non_utf8_filename() {
use std::ffi::OsString;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
// 0xFF is not valid UTF-8. Build the filename on raw bytes.
let mut raw_name: Vec<u8> = b"badname-".to_vec();
raw_name.push(0xFF);
raw_name.extend_from_slice(b".bin");
let exe_path = tmp.path().join(OsString::from_vec(raw_name));
std::fs::copy(sleep_binary(), &exe_path).unwrap();
std::fs::set_permissions(&exe_path, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut cmd = std::process::Command::new(&exe_path);
cmd.arg0("sleep");
cmd.arg("5");
let mut child = spawn_retrying_on_etxtbsy(&mut cmd);
let pid: i32 = child.id().cast_signed();
wait_for_child_exec(pid, &exe_path);
std::fs::remove_file(&exe_path).unwrap();
// Sanity: raw readlink ends with " (deleted)" and is not valid UTF-8.
let raw = std::fs::read_link(format!("/proc/{pid}/exe")).unwrap();
let raw_bytes = raw.as_os_str().as_bytes();
assert!(
raw_bytes.ends_with(b" (deleted)"),
"kernel should append ' (deleted)' to unlinked exe readlink"
);
assert!(
std::str::from_utf8(raw_bytes).is_err(),
"test precondition: raw readlink must contain non-UTF-8 bytes"
);
let resolved =
binary_path(pid).expect("binary_path should succeed for non-UTF-8 unlinked path");
assert_eq!(
resolved, exe_path,
"binary_path must strip exactly one ' (deleted)' suffix for non-UTF-8 paths"
);
assert!(
!resolved.as_os_str().as_bytes().ends_with(b" (deleted)"),
"stripped path must not end with ' (deleted)'"
);
let _ = child.kill();
let _ = child.wait();
}
#[cfg(target_os = "linux")]
#[test]
fn collect_descendants_includes_self() {
let pid = std::process::id();
let pids = collect_descendant_pids(pid);
assert!(pids.contains(&pid));
}
#[cfg(target_os = "linux")]
#[test]
fn collect_descendants_dedupes_pids() {
let pid = std::process::id();
let pids = collect_descendant_pids(pid);
let unique = pids.iter().copied().collect::<HashSet<_>>();
assert_eq!(pids.len(), unique.len());
}
#[cfg(target_os = "linux")]
#[test]
fn parse_proc_socket_addr_decodes_ipv4_and_mapped_ipv6() {
let ipv4 = parse_proc_socket_addr("0100007F:C350", ProcTcpAddressFamily::Ipv4).unwrap();
let mapped_ipv6 = parse_proc_socket_addr(
"0000000000000000FFFF00000100007F:C350",
ProcTcpAddressFamily::Ipv6,
)
.unwrap();
assert_eq!(ipv4, "127.0.0.1:50000".parse().unwrap());
assert!(socket_addrs_match(ipv4, mapped_ipv6));
}
#[cfg(target_os = "linux")]
#[test]
fn find_proc_net_tcp_inode_matches_complete_tuple() {
let content = "\
sl local_address rem_address st tx_queue:rx_queue tr:tm->when retrnsmt uid timeout inode\n\
0: 0100007F:C350 0100007F:1F91 01 00000000:00000000 00:00000000 00000000 1000 0 11111\n\
1: 0100007F:C350 0100007F:1F90 01 00000000:00000000 00:00000000 00000000 1000 0 22222\n";
let connection = WorkloadProxyTcpConnection::new(
"127.0.0.1:50000".parse().unwrap(),
"127.0.0.1:8080".parse().unwrap(),
);
let inode =
find_proc_net_tcp_inode(content, ProcTcpAddressFamily::Ipv4, connection).unwrap();
assert_eq!(inode, Some(22222));
}
#[cfg(target_os = "linux")]
#[test]
fn resolve_tcp_peer_socket_owners_returns_all_forked_socket_holders() {
use std::net::{TcpListener, TcpStream};
use std::time::{Duration, Instant};
struct ChildGuard(libc::pid_t);
impl Drop for ChildGuard {
fn drop(&mut self) {
#[allow(unsafe_code)]
unsafe {
libc::kill(self.0, libc::SIGKILL);
libc::waitpid(self.0, std::ptr::null_mut(), 0);
}
}
}
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let proxy_addr = listener.local_addr().unwrap();
let stream = TcpStream::connect(proxy_addr).expect("connect");
let workload_addr = stream.local_addr().unwrap();
let connection = WorkloadProxyTcpConnection::new(workload_addr, proxy_addr);
let (_accepted, _) = listener.accept().expect("accept");
// libc/syscall FFI requires unsafe
#[allow(unsafe_code)]
let child_pid = unsafe { libc::fork() };
assert!(child_pid >= 0, "fork failed");
if child_pid == 0 {
// libc/syscall FFI requires unsafe
#[allow(unsafe_code)]
unsafe {
libc::sleep(30);
libc::_exit(0);
}
}
let _guard = ChildGuard(child_pid);
let child_pid_u32 = child_pid.cast_unsigned();
let entrypoint_pid = std::process::id();
let deadline = Instant::now() + Duration::from_secs(5);
let owners = loop {
let owners = resolve_tcp_peer_socket_owners(entrypoint_pid, connection)
.expect("resolve socket owners");
let owner_pids = owners
.owners
.iter()
.map(|owner| owner.pid)
.collect::<HashSet<_>>();
if owner_pids.contains(&entrypoint_pid) && owner_pids.contains(&child_pid_u32) {
break owners;
}
assert!(
Instant::now() < deadline,
"timed out waiting for forked child to appear as a socket owner; got {owner_pids:?}"
);
std::thread::sleep(Duration::from_millis(20));
};
let owner_pids = owners
.owners
.iter()
.map(|owner| owner.pid)
.collect::<HashSet<_>>();
assert!(owner_pids.contains(&entrypoint_pid));
assert!(owner_pids.contains(&child_pid_u32));
}
#[cfg(target_os = "linux")]
#[test]
#[allow(clippy::similar_names)]
fn read_ppid_returns_parent() {
let pid = std::process::id();
let ppid = read_ppid(pid);
assert!(ppid.is_some(), "Should be able to read PPid of self");
assert!(ppid.unwrap() > 0, "PPid should be > 0");
}
#[cfg(target_os = "linux")]
#[test]
fn read_ppid_nonexistent_pid() {
// PID 0 is the kernel scheduler, reading its status should fail or return None
let result = read_ppid(999_999_999);
assert!(result.is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn collect_ancestor_binaries_returns_parents() {
let pid = std::process::id();
// stop_pid=1 means walk all the way up to init
let ancestors = collect_ancestor_binaries(pid, 1);
// We should have at least one ancestor (our parent process)
assert!(
!ancestors.is_empty(),
"Should have at least one ancestor binary"
);
// Each ancestor should be a real path
for path in &ancestors {
assert!(
!path.as_os_str().is_empty(),
"Ancestor path should not be empty"
);
}
}
#[cfg(target_os = "linux")]
#[test]
#[allow(clippy::similar_names)]
fn collect_ancestor_binaries_stops_at_stop_pid() {
let pid = std::process::id();
let ppid = read_ppid(pid).unwrap();
// If we set stop_pid to our direct parent, we should get exactly 1 ancestor
let ancestors = collect_ancestor_binaries(pid, ppid);
assert_eq!(ancestors.len(), 1, "Should stop at stop_pid (our parent)");
}
#[cfg(target_os = "linux")]
#[test]
fn cmdline_absolute_paths_returns_paths() {
let pid = std::process::id();
let paths = cmdline_absolute_paths(pid);
// The test runner binary should appear as an absolute path in cmdline
assert!(
!paths.is_empty(),
"Should find at least one absolute path in cmdline"
);
for p in &paths {
assert!(
p.is_absolute(),
"All returned paths should be absolute: {}",
p.display()
);
}
}
#[cfg(target_os = "linux")]
#[test]
fn cmdline_absolute_paths_nonexistent_pid() {
let paths = cmdline_absolute_paths(999_999_999);
assert!(paths.is_empty());
}
#[cfg(target_os = "linux")]
#[test]
fn collect_cmdline_paths_excludes_known() {
let pid = std::process::id();
let exe = binary_path(pid.cast_signed()).unwrap();
// When we exclude the exe path, it shouldn't appear in cmdline_paths
let paths = collect_cmdline_paths(pid, 1, std::slice::from_ref(&exe));
assert!(
!paths.contains(&exe),
"Should not contain excluded exe path"
);
}
}