From be490debf9dec383ae25975a1eaef9a09d0a2f54 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Mon, 24 Aug 2026 16:51:48 +0100 Subject: [PATCH 1/2] fix(supervisor-network): distinguish absent policy binary from filesystem-access failure Signed-off-by: Artem Lytvyn --- .../openshell-supervisor-network/src/opa.rs | 408 +++++++++++++++--- 1 file changed, 345 insertions(+), 63 deletions(-) diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 35cef601d8..53e29f2ac6 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -1654,10 +1654,55 @@ fn normalize_path(path: &Path) -> PathBuf { result } +// Only the Linux resolver constructs the non-`Literal` variants; on other +// platforms the stub returns `Literal`, so the rest look dead there. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[derive(Debug, PartialEq, Eq)] +enum BinaryResolution { + /// Symlink resolved to a different canonical target — add it to policy. + Resolved(String), + /// Nothing to add: glob, pid 0, not a symlink, or already canonical. + Literal, + /// Candidate absent under an accessible process root — expected, quiet. + Absent, + /// The process root `/proc//root` itself is unreachable (pid gone or + /// denied) — actionable, and `CAP_SYS_PTRACE` guidance is relevant. + Inaccessible(std::io::ErrorKind), + /// The process root is reachable but the candidate path itself failed for a + /// non-NotFound reason (a parent component is permission-denied, or the path + /// forms a symlink loop) — actionable, but a target-path problem rather than + /// a process-root one, so `CAP_SYS_PTRACE` guidance does not apply. + CandidateInaccessible(std::io::ErrorKind), + /// A component mid symlink-chain failed to resolve. + ChainBroken(std::io::ErrorKind), +} + +impl BinaryResolution { + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + const fn classify_first_probe_error(kind: std::io::ErrorKind) -> Self { + // Callers reach this only after confirming the process root is + // reachable, so a non-NotFound failure here is specific to the + // candidate path, not the process root. + match kind { + std::io::ErrorKind::NotFound => Self::Absent, + _ => Self::CandidateInaccessible(kind), + } + } +} + #[cfg(target_os = "linux")] -fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option { +fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> BinaryResolution { if policy_path.contains('*') || entrypoint_pid == 0 { - return None; + return BinaryResolution::Literal; + } + + // Confirm the process root itself is reachable before probing candidates. + // A leaf `ENOENT` (absent candidate) and an unreachable process root + // (pid gone -> `ENOENT`, or denied -> `EACCES`) are indistinguishable at + // the leaf path, so check the root first. Any failure here is an access + // problem, not an absent candidate, and must stay actionable. + if let Err(e) = std::fs::metadata(format!("/proc/{entrypoint_pid}/root")) { + return BinaryResolution::Inaccessible(e.kind()); } // Walk the symlink chain inside the container filesystem using @@ -1667,6 +1712,11 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option // the specified symlink, keeping us in the container's namespace. let mut resolved = PathBuf::from(policy_path); + // Set once the walk reaches a real (non-symlink) target or a terminal + // dead end. If the iteration cap is hit while every component is still a + // symlink, this stays false and the chain is treated as broken. + let mut reached_target = false; + // Linux SYMLOOP_MAX is 40; stop before infinite loops for _ in 0..40 { let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); @@ -1678,76 +1728,81 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option let meta = match std::fs::symlink_metadata(&container_path) { Ok(m) => m, Err(e) => { - // Only warn on the first iteration (the original policy path). - // On subsequent iterations, the intermediate target may - // legitimately not exist (broken symlink chain). + // First iteration is the original policy path: an absent + // candidate (NotFound) is expected, any other error means the + // process root itself is unreachable. Later iterations are + // chain components, so a failure there is a broken symlink + // chain. Classify without logging; the caller emits the log at + // the appropriate level. if resolved.as_os_str() == policy_path { - tracing::warn!( - "Cannot access container filesystem for symlink resolution: \ - path={policy_path} container_path={container_path} pid={entrypoint_pid} \ - error={e}. Binary paths in policy will be matched literally. \ - If this binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \ - use the canonical path instead, or run with CAP_SYS_PTRACE." - ); - } else { - tracing::warn!( - "Symlink chain broken during resolution: \ - original={policy_path} current={} pid={entrypoint_pid} error={e}. \ - Binary will be matched by original path only.", - resolved.display() - ); + // The up-front root check and this probe are separate + // syscalls; if the process exited in between, the whole + // `/proc/` tree is gone and the leaf probe also + // reports NotFound. Re-check the root so a vanished process + // stays classified as Inaccessible (actionable) rather than + // being masked as an absent candidate (quiet). + if e.kind() == std::io::ErrorKind::NotFound + && let Err(root_error) = + std::fs::metadata(format!("/proc/{entrypoint_pid}/root")) + { + return BinaryResolution::Inaccessible(root_error.kind()); + } + return BinaryResolution::classify_first_probe_error(e.kind()); } - return None; + return BinaryResolution::ChainBroken(e.kind()); } }; if !meta.file_type().is_symlink() { // Reached a non-symlink — this is the final resolved target + reached_target = true; break; } let target = match std::fs::read_link(&container_path) { Ok(t) => t, - Err(e) => { - tracing::warn!( - "Symlink detected but read_link failed: \ - path={policy_path} current={} pid={entrypoint_pid} error={e}. \ - Binary will be matched by original path only.", - resolved.display() - ); - return None; - } + // A symlink whose target can't be read is a broken chain; the + // caller logs it. + Err(e) => return BinaryResolution::ChainBroken(e.kind()), }; if target.is_absolute() { resolved = target; - } else { + } else if let Some(parent) = resolved.parent() { // Relative symlink: resolve against the containing directory // e.g., /usr/bin/python3 -> python3.11 becomes /usr/bin/python3.11 - if let Some(parent) = resolved.parent() { - resolved = normalize_path(&parent.join(&target)); - } else { - break; - } + resolved = normalize_path(&parent.join(&target)); + } else { + // No parent to resolve a relative target against — terminal. + reached_target = true; + break; } } + if !reached_target { + // The cap was exhausted while every component was still a symlink: a + // cycle such as a -> b -> a. read_link resolves one hop at a time, so + // the kernel never surfaces ELOOP; without this the current mid-cycle + // path would be accepted as Resolved/Literal with no warning. Treat it + // as a broken chain so the caller logs it and matches literally only. + return BinaryResolution::ChainBroken( + std::io::Error::from_raw_os_error(libc::ELOOP).kind(), + ); + } + let resolved_str = resolved.to_string_lossy().into_owned(); if resolved_str == policy_path { - None + BinaryResolution::Literal } else { - tracing::info!( - "Resolved policy binary symlink via container filesystem: \ - original={policy_path} resolved={resolved_str} pid={entrypoint_pid}" - ); - Some(resolved_str) + // The caller logs the resolution at info level. + BinaryResolution::Resolved(resolved_str) } } #[cfg(not(target_os = "linux"))] -fn resolve_binary_in_container(_policy_path: &str, _entrypoint_pid: u32) -> Option { - None +fn resolve_binary_in_container(_policy_path: &str, _entrypoint_pid: u32) -> BinaryResolution { + BinaryResolution::Literal } fn l7_matchers_to_json( @@ -2015,8 +2070,46 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St entry }; let mut entries = vec![binary_entry(&b.path)]; - if let Some(resolved) = resolve_binary_in_container(&b.path, entrypoint_pid) { - entries.push(binary_entry(&resolved)); + match resolve_binary_in_container(&b.path, entrypoint_pid) { + BinaryResolution::Resolved(resolved) => { + tracing::info!( + "Resolved policy binary symlink: original={} resolved={resolved} pid={entrypoint_pid}", + b.path + ); + entries.push(binary_entry(&resolved)); + } + BinaryResolution::Absent => { + tracing::debug!( + "Policy binary candidate not present in container: path={} pid={entrypoint_pid}. Matched literally.", + b.path + ); + } + BinaryResolution::Inaccessible(kind) => { + tracing::warn!( + "Cannot access container filesystem for symlink resolution: path={} pid={entrypoint_pid} \ + error_kind={kind:?}. Binary paths in policy will be matched literally. \ + If this binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \ + use the canonical path instead, or run with CAP_SYS_PTRACE.", + b.path + ); + } + BinaryResolution::CandidateInaccessible(kind) => { + tracing::warn!( + "Cannot access policy binary candidate path: path={} pid={entrypoint_pid} \ + error_kind={kind:?}. Binary path will be matched literally. \ + A parent component is permission-denied or the path forms a symlink loop; \ + the process root itself is reachable.", + b.path + ); + } + BinaryResolution::ChainBroken(kind) => { + tracing::warn!( + "Symlink chain broken during resolution: path={} pid={entrypoint_pid} error_kind={kind:?}. \ + Matched by original path only.", + b.path + ); + } + BinaryResolution::Literal => {}, } entries }) @@ -7189,22 +7282,36 @@ process: #[test] fn resolve_binary_skips_glob_paths() { // Glob patterns should never be resolved — they're matched differently - assert!(resolve_binary_in_container("/usr/bin/*", 1).is_none()); - assert!(resolve_binary_in_container("/usr/local/bin/**", 1).is_none()); + assert_eq!( + resolve_binary_in_container("/usr/bin/*", 1), + BinaryResolution::Literal + ); + assert_eq!( + resolve_binary_in_container("/usr/local/bin/**", 1), + BinaryResolution::Literal + ); } #[test] fn resolve_binary_skips_pid_zero() { // pid=0 means the container hasn't started yet - assert!(resolve_binary_in_container("/usr/bin/python3", 0).is_none()); + assert_eq!( + resolve_binary_in_container("/usr/bin/python3", 0), + BinaryResolution::Literal + ); } #[test] - fn resolve_binary_returns_none_for_nonexistent_path() { - // A path that doesn't exist in any container should gracefully return None + fn resolve_binary_does_not_resolve_nonexistent_path() { + // A path that doesn't exist should never yield a Resolved target. The + // exact non-Resolved variant depends on platform/privilege (Absent when + // the process root is readable, Inaccessible when it is not, Literal on + // the non-Linux stub), so assert only that nothing is resolved. + let result = + resolve_binary_in_container("/nonexistent/binary/path/that/will/never/exist", 1); assert!( - resolve_binary_in_container("/nonexistent/binary/path/that/will/never/exist", 1) - .is_none() + !matches!(result, BinaryResolution::Resolved(_)), + "nonexistent path must not resolve, got: {result:?}" ); } @@ -7412,6 +7519,178 @@ network_policies: ); } + #[test] + fn classify_not_found_is_absent() { + // A missing candidate under an accessible root is expected, not an error. + assert_eq!( + BinaryResolution::classify_first_probe_error(std::io::ErrorKind::NotFound), + BinaryResolution::Absent + ); + } + + #[test] + fn classify_permission_denied_is_candidate_inaccessible() { + // A non-NotFound failure at the candidate probe (root already confirmed + // reachable) is a target-path problem, not a process-root one, so it + // must not emit the CAP_SYS_PTRACE process-root guidance. + assert_eq!( + BinaryResolution::classify_first_probe_error(std::io::ErrorKind::PermissionDenied), + BinaryResolution::CandidateInaccessible(std::io::ErrorKind::PermissionDenied) + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn absent_candidate_resolves_to_absent() { + // Regression for #2883: a candidate path that does not exist under an + // accessible /proc//root must classify as Absent (quiet), not as a + // container-filesystem-access failure. + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + // Use a path guaranteed absent: a real temp dir with an uncreated + // child. A hard-coded path like /app/.venv/bin/python could exist in a + // valid environment and make the resolver return Resolved/Literal. + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-python"); + let pid = std::process::id(); // our own live, accessible root + assert_eq!( + resolve_binary_in_container(missing.to_str().unwrap(), pid), + BinaryResolution::Absent + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn unreachable_process_root_is_inaccessible() { + // A pid whose /proc//root does not exist (process gone) must be + // reported as an access failure, not silently treated as an absent + // candidate — even though both surface as ENOENT at the leaf path. + // u32::MAX is far above /proc/sys/kernel/pid_max, so it never exists. + let dead_pid = u32::MAX; + assert!( + matches!( + resolve_binary_in_container("/usr/bin/python3", dead_pid), + BinaryResolution::Inaccessible(_) + ), + "an unreachable process root must classify as Inaccessible" + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn symlink_cycle_exhausts_limit_as_chain_broken() { + // A symlink cycle (a -> b -> a) never resolves to a real target. The + // manual read_link walk resolves one hop at a time, so the kernel never + // raises ELOOP; exhausting the iteration cap must classify as + // ChainBroken, not silently pass as Resolved/Literal. + use std::os::unix::fs::symlink; + + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a"); + let b = dir.path().join("b"); + symlink(&b, &a).unwrap(); // a -> b + symlink(&a, &b).unwrap(); // b -> a + let pid = std::process::id(); + assert!( + matches!( + resolve_binary_in_container(a.to_str().unwrap(), pid), + BinaryResolution::ChainBroken(_) + ), + "a symlink cycle must classify as ChainBroken" + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn absent_candidates_emit_no_warnings() { + // Regression for #2883: several expected-but-absent compatibility + // candidates under an accessible process root must not produce a burst + // of WARN-level noise. Logging now lives at the caller, so drive the + // real caller (proto_to_opa_data_json) and count WARN events. + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tracing::Subscriber; + use tracing_subscriber::layer::{Context, Layer, SubscriberExt}; + use tracing_subscriber::registry::LookupSpan; + + #[derive(Clone)] + struct WarnCounter(Arc); + impl LookupSpan<'a>> Layer for WarnCounter { + fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + if *event.metadata().level() == tracing::Level::WARN { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + } + + let warns = Arc::new(AtomicUsize::new(0)); + let subscriber = tracing_subscriber::registry().with(WarnCounter(Arc::clone(&warns))); + + // Candidates that do not exist under our accessible /proc//root. + // Historically each absent candidate emitted its own WARN. Use uncreated + // children of a real temp dir so the paths are guaranteed absent (not + // merely conventional): a hard-coded path could exist on a runner and + // stop exercising the Absent branch, or be inaccessible and emit a + // legitimate warning that fails this test. + let dir = tempfile::tempdir().unwrap(); + let candidates = [ + dir.path().join("app-python"), + dir.path().join("sandbox-python"), + dir.path().join("opt-python"), + ]; + let mut network_policies = std::collections::HashMap::new(); + network_policies.insert( + "pypi".to_string(), + NetworkPolicyRule { + name: "pypi".to_string(), + endpoints: vec![NetworkEndpoint { + host: "pypi.org".to_string(), + port: 443, + ..Default::default() + }], + binaries: candidates + .iter() + .map(|p| NetworkBinary { + path: p.to_str().unwrap().to_string(), + ..Default::default() + }) + .collect(), + }, + ); + let proto = ProtoSandboxPolicy { + version: 1, + filesystem: None, + landlock: None, + process: None, + network_policies, + network_middlewares: std::collections::HashMap::default(), + }; + + let pid = std::process::id(); // accessible root, leaf paths absent + tracing::subscriber::with_default(subscriber, || { + let _ = proto_to_opa_data_json(&proto, pid); + }); + + assert_eq!( + warns.load(Ordering::SeqCst), + 0, + "absent compatibility candidates must not emit warnings" + ); + } + #[test] fn hot_reload_preserves_symlink_expansion_behavior() { // Simulates the hot-reload path: initial load at pid=0, then reload @@ -7886,7 +8165,10 @@ network_policies: let pid = std::process::id(); let link_path = link.to_string_lossy().to_string(); // Actually attempt the same resolution our production code uses - resolve_binary_in_container(&link_path, pid).is_some() + matches!( + resolve_binary_in_container(&link_path, pid), + BinaryResolution::Resolved(_) + ) } #[cfg(target_os = "linux")] @@ -7915,11 +8197,9 @@ network_policies: let link_path = link.to_string_lossy().to_string(); let result = resolve_binary_in_container(&link_path, our_pid); - assert!( - result.is_some(), - "Should resolve symlink via /proc//root/" - ); - let resolved = result.unwrap(); + let BinaryResolution::Resolved(resolved) = result else { + panic!("Should resolve symlink via /proc//root/, got: {result:?}"); + }; assert!( resolved.ends_with("python3.11"), "Resolved path should point to target: {resolved}" @@ -7945,9 +8225,10 @@ network_policies: let path = tmp.path().to_string_lossy().to_string(); let result = resolve_binary_in_container(&path, our_pid); - assert!( - result.is_none(), - "Non-symlink file should return None, got: {result:?}" + assert_eq!( + result, + BinaryResolution::Literal, + "Non-symlink file should not expand, got: {result:?}" ); } @@ -7975,8 +8256,9 @@ network_policies: let link_path = top_link.to_string_lossy().to_string(); let result = resolve_binary_in_container(&link_path, our_pid); - assert!(result.is_some(), "Should resolve multi-level symlink chain"); - let resolved = result.unwrap(); + let BinaryResolution::Resolved(resolved) = result else { + panic!("Should resolve multi-level symlink chain, got: {result:?}"); + }; assert!( resolved.ends_with("cpython3.11"), "Should resolve to final target: {resolved}" From d8df79d21066cd8fbd04dba047e51bf3bceef5b7 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Wed, 26 Aug 2026 11:16:23 +0100 Subject: [PATCH 2/2] fix(supervisor-network): resolve symlink chains at the SYMLOOP_MAX limit Signed-off-by: Artem Lytvyn --- .../openshell-supervisor-network/src/opa.rs | 133 ++++++++++++++---- 1 file changed, 107 insertions(+), 26 deletions(-) diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 53e29f2ac6..63aa2c2c70 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -1624,19 +1624,6 @@ fn normalize_l7_rule_aliases( } } -/// Resolve a policy binary path through the container's root filesystem. -/// -/// On Linux, `/proc//root/` provides access to the container's mount -/// namespace. If the policy path is a symlink inside the container -/// (e.g., `/usr/bin/python3` → `/usr/bin/python3.11`), returns the -/// canonical target path. Returns `None` if: -/// - Not on Linux -/// - `entrypoint_pid` is 0 (container not yet started) -/// - Path contains glob characters -/// - Path is not a symlink -/// - Resolution fails (binary doesn't exist in container) -/// - Resolved path equals the original -/// /// Normalize a path by resolving `.` and `..` components without touching /// the filesystem. Only works correctly for absolute paths. #[cfg(any(target_os = "linux", test))] @@ -1690,6 +1677,23 @@ impl BinaryResolution { } } +/// Resolve a policy binary path through the container's root filesystem. +/// +/// On Linux, `/proc//root/` provides access to the container's mount +/// namespace. If the policy path is a symlink inside the container +/// (e.g., `/usr/bin/python3` → `/usr/bin/python3.11`), the canonical target is +/// returned as [`BinaryResolution::Resolved`]. The outcome is classified as: +/// - [`BinaryResolution::Literal`] — not on Linux, `entrypoint_pid` is 0 +/// (container not yet started), the path contains glob characters, it is not +/// a symlink, or the resolved path equals the original. +/// - [`BinaryResolution::Absent`] — the candidate does not exist under an +/// otherwise reachable process root (expected; the caller logs it quietly). +/// - [`BinaryResolution::Inaccessible`] — `/proc//root` itself is +/// unreachable (pid gone or access denied). +/// - [`BinaryResolution::CandidateInaccessible`] — the process root is +/// reachable but the candidate path failed for a non-NotFound reason. +/// - [`BinaryResolution::ChainBroken`] — a component mid symlink-chain failed, +/// or the chain forms a cycle / exceeds the kernel symlink limit. #[cfg(target_os = "linux")] fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> BinaryResolution { if policy_path.contains('*') || entrypoint_pid == 0 { @@ -1780,14 +1784,26 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Binary } if !reached_target { - // The cap was exhausted while every component was still a symlink: a - // cycle such as a -> b -> a. read_link resolves one hop at a time, so - // the kernel never surfaces ELOOP; without this the current mid-cycle - // path would be accepted as Resolved/Literal with no warning. Treat it - // as a broken chain so the caller logs it and matches literally only. - return BinaryResolution::ChainBroken( - std::io::Error::from_raw_os_error(libc::ELOOP).kind(), - ); + // The cap was exhausted while following symlinks. Linux SYMLOOP_MAX is + // 40, so a chain of exactly 40 symlinks ending at a real file is still + // valid — after the 40th hop `resolved` may already point at that file. + // Inspect it once more without following another link: a non-symlink is + // the valid final target (fall through to the Literal/Resolved logic + // below), while another symlink (or an error) is a genuine cycle + // (a -> b -> a) or a chain deeper than the kernel allows. read_link + // resolves one hop at a time, so the kernel never surfaces ELOOP; treat + // those as a broken chain so the caller logs it and matches literally. + let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); + + match std::fs::symlink_metadata(&container_path) { + Ok(meta) if !meta.file_type().is_symlink() => {} + Ok(_) => { + return BinaryResolution::ChainBroken( + std::io::Error::from_raw_os_error(libc::ELOOP).kind(), + ); + } + Err(e) => return BinaryResolution::ChainBroken(e.kind()), + } } let resolved_str = resolved.to_string_lossy().into_owned(); @@ -7610,16 +7626,76 @@ network_policies: #[test] #[cfg(target_os = "linux")] - fn absent_candidates_emit_no_warnings() { - // Regression for #2883: several expected-but-absent compatibility - // candidates under an accessible process root must not produce a burst - // of WARN-level noise. Logging now lives at the caller, so drive the - // real caller (proto_to_opa_data_json) and count WARN events. + fn symlink_chain_at_limit_resolves_to_target() { + // Linux SYMLOOP_MAX is 40: a chain of exactly 40 symlinks ending at a + // real file resolves successfully in the kernel. The manual walk must + // follow all 40 hops and accept the final target rather than exhausting + // the cap and reporting ChainBroken (off-by-one regression). + use std::os::unix::fs::symlink; + if !procfs_root_accessible() { eprintln!("Skipping: /proc//root/ not accessible in this environment"); return; } + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("real"); + std::fs::write(&target, b"").unwrap(); + + // link0 -> link1 -> ... -> link39 -> real (40 symlinks, absolute + // targets so the resolver takes the is_absolute() branch). + let link = |i: usize| dir.path().join(format!("link{i}")); + symlink(&target, link(39)).unwrap(); + for i in (0..39).rev() { + symlink(link(i + 1), link(i)).unwrap(); + } + + let pid = std::process::id(); + let result = resolve_binary_in_container(link(0).to_str().unwrap(), pid); + assert!( + matches!(result, BinaryResolution::Resolved(_)), + "a 40-link chain ending at a real file must resolve, got {result:?}" + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn symlink_chain_over_limit_is_chain_broken() { + // A chain of 41 symlinks exceeds SYMLOOP_MAX: the walk must give up and + // classify as ChainBroken, proving the 40-hop budget stays enforced. + use std::os::unix::fs::symlink; + + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("real"); + std::fs::write(&target, b"").unwrap(); + + // link0 -> ... -> link40 -> real (41 symlinks). + let link = |i: usize| dir.path().join(format!("link{i}")); + symlink(&target, link(40)).unwrap(); + for i in (0..40).rev() { + symlink(link(i + 1), link(i)).unwrap(); + } + + let pid = std::process::id(); + let result = resolve_binary_in_container(link(0).to_str().unwrap(), pid); + assert!( + matches!(result, BinaryResolution::ChainBroken(_)), + "a 41-link chain must classify as ChainBroken, got {result:?}" + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn absent_candidates_emit_no_warnings() { + // Regression for #2883: several expected-but-absent compatibility + // candidates under an accessible process root must not produce a burst + // of WARN-level noise. Logging now lives at the caller, so drive the + // real caller (proto_to_opa_data_json) and count WARN events. use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use tracing::Subscriber; @@ -7636,6 +7712,11 @@ network_policies: } } + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + let warns = Arc::new(AtomicUsize::new(0)); let subscriber = tracing_subscriber::registry().with(WarnCounter(Arc::clone(&warns)));