From 69e4ffb538346d11e2d9a48ea8864d6c8c392ea2 Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Thu, 3 Sep 2026 15:31:07 -0700 Subject: [PATCH 1/9] X-Smart-Branch-Parent: main From 7a8665af5758ba46096e940a93f54f62943e9e3e Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Thu, 3 Sep 2026 16:10:04 -0700 Subject: [PATCH 2/9] test(symlink): add failing regression for missed direct-child under recursive symlink-rooted path (ROX-36737) Assisted-by: Claude Code --- tests/test_symlink_recursive_root.py | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/test_symlink_recursive_root.py diff --git a/tests/test_symlink_recursive_root.py b/tests/test_symlink_recursive_root.py new file mode 100644 index 00000000..acfa8793 --- /dev/null +++ b/tests/test_symlink_recursive_root.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import os +from time import sleep + +import docker.models.containers +import yaml + +from event import Event, EventType, Process +from server import EventServer + + +def reload_config( + fact: docker.models.containers.Container, config: dict, config_file: str +): + with open(config_file, 'w') as f: + yaml.dump(config, f) + fact.kill('SIGHUP') + sleep(0.1) + + +def test_configured_relative_symlink_root_tracks_direct_child( + fact: docker.models.containers.Container, + fact_config: tuple[dict, str], + ignored_dir: str, + server: EventServer, +): + """ + Regression test for ROX-36737. + + On RHCOS ``/root`` is a *relative* symlink to ``var/roothome``. When + fact is configured with a recursive path rooted at that symlink + (``/root/**``), creating a file directly beneath the symlink should + emit a CREATION event. + + The bug: ``HostScanner::scan_inner()`` only tracks paths returned by + ``glob::glob()``. For a recursive pattern rooted at a symlink, glob + expansion does not return the symlink root itself, so fact never + calls ``scan_symlink()`` for the configured root and never associates + the target directory inode with the configured logical path. Without + the target directory inode, fact cannot recognise the creation of a + direct child under the configured path, and the event is dropped + inside fact before it can be forwarded. + + This mirrors the RHCOS layout: + + /root -> var/roothome (relative symlink) + + with fact configured to monitor ``/root/**``. + """ + # Reproduce the RHCOS `/root -> var/roothome` layout: a relative + # symlink whose target lives beside it. + base = ignored_dir + target = os.path.join(base, 'var', 'roothome') + os.makedirs(target, exist_ok=True) + + symlink = os.path.join(base, 'root') + os.symlink(os.path.join('var', 'roothome'), symlink) + + # Monitor only the recursive path rooted at the relative symlink, + # exactly as configured on RHCOS. + config, config_file = fact_config + config['paths'] = [f'{symlink}/**'] + reload_config(fact, config, config_file) + + process = Process.from_proc() + + # Create a file directly beneath the symlink root. + child = os.path.join(symlink, 'direct-child.txt') + with open(child, 'w') as f: + f.write('direct child') + + server.wait_events( + [ + Event( + process=process, + event_type=EventType.CREATION, + # d_path resolves the symlink, so the reported path is + # the real path under the target directory... + file=os.path.join(target, 'direct-child.txt'), + # ...while the host_path reflects the configured logical + # path through the symlink. + host_path=child, + ) + ] + ) From c2b8adb5754c2f278df8f8931641729e3dfcd72a Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Thu, 3 Sep 2026 17:01:56 -0700 Subject: [PATCH 3/9] fix(host_scanner): seed symlink roots of recursive paths so direct children are tracked (ROX-36737) Assisted-by: Claude Code --- fact/src/host_scanner.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index 86658de2..295a7609 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -192,6 +192,10 @@ impl HostScanner { bail!("invalid path {}", path.display()); }; + // glob does not return the non-glob root of a recursive pattern, + // so seed it explicitly to cover symlink roots (ROX-36737). + self.scan_pattern_root(glob_str); + for entry in glob::glob(glob_str)? { let path = match entry { Ok(p) => p, @@ -228,6 +232,31 @@ impl HostScanner { Ok(()) } + /// Seed the non-glob root of a pattern when it is a symlink. + /// + /// glob expansion skips the symlink root of a recursive pattern + /// (e.g. `/host/root/**` where `/host/root -> var/roothome`), + /// leaving the target directory inode untracked. This maps that + /// inode to the configured logical path so direct children are seen. + fn scan_pattern_root(&self, glob_str: &str) { + let prefix = glob_str + .split(['*', '?', '[', '{']) + .next() + .unwrap_or(glob_str); + let Some(idx) = prefix.rfind('/') else { + return; + }; + let root = Path::new(if idx == 0 { "/" } else { &prefix[..idx] }); + + match root.symlink_metadata() { + Ok(metadata) if metadata.is_symlink() => { + self.metrics.scan_inc(ScanLabels::SymlinkScanned); + self.scan_symlink(root); + } + _ => {} + } + } + fn scan_symlink(&self, path: &Path) { let target = match path.read_link() { Ok(p) => { From cc2c04f6b15b02b4b80dce526d94a4ca0e4cbff5 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 2 Sep 2026 09:02:37 -0700 Subject: [PATCH 4/9] test: cover configured relative symlink root --- tests/test_path_symlink.py | 65 +++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/test_path_symlink.py b/tests/test_path_symlink.py index 94fb1afb..46c78b2d 100644 --- a/tests/test_path_symlink.py +++ b/tests/test_path_symlink.py @@ -3,10 +3,13 @@ import os import re import subprocess +from concurrent.futures import TimeoutError as FuturesTimeoutError +from pathlib import Path +from time import sleep import docker.models.containers import pytest - +import yaml from event import Event, EventType, Process from server import EventServer from utils import join_path_with_filename, path_to_string @@ -312,6 +315,66 @@ def test_follow_symlink_to_dir_relative( ) +@pytest.mark.xfail( + strict=True, + raises=FuturesTimeoutError, + reason='ROX-36737: a recursive path rooted at a relative symlink does not ' + + 'track direct children of the symlink target', +) +def test_configured_relative_symlink_root_tracks_direct_child( + tmp_path: Path, + fact: docker.models.containers.Container, + fact_config: tuple[dict, str], + server: EventServer, +): + """ + A relative symlink used as a recursive configured path should track files + created directly beneath its target. + + This models an RHCOS node, where /root is a relative symlink to + var/roothome. With /root/** configured, a containerized Fact scans + /host/root/**, which resolves into /host/var/roothome. The scan must seed + the target directory inode so that a node process creating, for example, + /root/direct-child.txt is reported with the resolved file path + /var/roothome/direct-child.txt and the configured host path + /root/direct-child.txt. Without that inode, creation of a direct child is + missed even though it is within the configured recursive path. + + This is distinct from test_follow_symlink_to_dir_relative: the symlink is + present when the host scanner reads the configuration, rather than being + created below a directory whose inode is already monitored. + """ + symlink_parent = tmp_path / 'symlink-parent' + target = tmp_path / 'target' + symlink_parent.mkdir() + target.mkdir() + + link = symlink_parent / 'watched' + link.symlink_to(os.path.relpath(target, symlink_parent)) + + config, config_file = fact_config + config['paths'] = [f'{link}/**'] + with open(config_file, 'w') as f: + yaml.dump(config, f) + fact.kill('SIGHUP') + sleep(0.5) + + file_via_link = link / 'direct-child.txt' + with open(file_via_link, 'w') as f: + f.write('This should be captured') + + server.wait_events( + [ + Event( + process=Process.from_proc(), + event_type=EventType.CREATION, + file=str(target / file_via_link.name), + host_path=str(file_via_link), + ) + ] + ) + + def test_overwrite_symlink(monitored_dir: str, server: EventServer): """ Test overwriting a symlink in a monitored directory is properly captured. From 44ea1db99f19a63a719df2a79e8e7cb1921e7637 Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Thu, 3 Sep 2026 20:42:42 -0700 Subject: [PATCH 5/9] chore(tests): untrack local symlink regression test in favor of upstream one Assisted-by: Claude Code --- tests/test_symlink_recursive_root.py | 86 ---------------------------- 1 file changed, 86 deletions(-) delete mode 100644 tests/test_symlink_recursive_root.py diff --git a/tests/test_symlink_recursive_root.py b/tests/test_symlink_recursive_root.py deleted file mode 100644 index acfa8793..00000000 --- a/tests/test_symlink_recursive_root.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -import os -from time import sleep - -import docker.models.containers -import yaml - -from event import Event, EventType, Process -from server import EventServer - - -def reload_config( - fact: docker.models.containers.Container, config: dict, config_file: str -): - with open(config_file, 'w') as f: - yaml.dump(config, f) - fact.kill('SIGHUP') - sleep(0.1) - - -def test_configured_relative_symlink_root_tracks_direct_child( - fact: docker.models.containers.Container, - fact_config: tuple[dict, str], - ignored_dir: str, - server: EventServer, -): - """ - Regression test for ROX-36737. - - On RHCOS ``/root`` is a *relative* symlink to ``var/roothome``. When - fact is configured with a recursive path rooted at that symlink - (``/root/**``), creating a file directly beneath the symlink should - emit a CREATION event. - - The bug: ``HostScanner::scan_inner()`` only tracks paths returned by - ``glob::glob()``. For a recursive pattern rooted at a symlink, glob - expansion does not return the symlink root itself, so fact never - calls ``scan_symlink()`` for the configured root and never associates - the target directory inode with the configured logical path. Without - the target directory inode, fact cannot recognise the creation of a - direct child under the configured path, and the event is dropped - inside fact before it can be forwarded. - - This mirrors the RHCOS layout: - - /root -> var/roothome (relative symlink) - - with fact configured to monitor ``/root/**``. - """ - # Reproduce the RHCOS `/root -> var/roothome` layout: a relative - # symlink whose target lives beside it. - base = ignored_dir - target = os.path.join(base, 'var', 'roothome') - os.makedirs(target, exist_ok=True) - - symlink = os.path.join(base, 'root') - os.symlink(os.path.join('var', 'roothome'), symlink) - - # Monitor only the recursive path rooted at the relative symlink, - # exactly as configured on RHCOS. - config, config_file = fact_config - config['paths'] = [f'{symlink}/**'] - reload_config(fact, config, config_file) - - process = Process.from_proc() - - # Create a file directly beneath the symlink root. - child = os.path.join(symlink, 'direct-child.txt') - with open(child, 'w') as f: - f.write('direct child') - - server.wait_events( - [ - Event( - process=process, - event_type=EventType.CREATION, - # d_path resolves the symlink, so the reported path is - # the real path under the target directory... - file=os.path.join(target, 'direct-child.txt'), - # ...while the host_path reflects the configured logical - # path through the symlink. - host_path=child, - ) - ] - ) From 38906ff2f02f94e66b37faea247f29b6c42fe1eb Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Thu, 3 Sep 2026 20:49:19 -0700 Subject: [PATCH 6/9] test(symlink): drop xfail now that ROX-36737 is fixed Assisted-by: Claude Code --- tests/test_path_symlink.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/test_path_symlink.py b/tests/test_path_symlink.py index 46c78b2d..7a5efba0 100644 --- a/tests/test_path_symlink.py +++ b/tests/test_path_symlink.py @@ -3,7 +3,6 @@ import os import re import subprocess -from concurrent.futures import TimeoutError as FuturesTimeoutError from pathlib import Path from time import sleep @@ -315,12 +314,6 @@ def test_follow_symlink_to_dir_relative( ) -@pytest.mark.xfail( - strict=True, - raises=FuturesTimeoutError, - reason='ROX-36737: a recursive path rooted at a relative symlink does not ' - + 'track direct children of the symlink target', -) def test_configured_relative_symlink_root_tracks_direct_child( tmp_path: Path, fact: docker.models.containers.Container, From 145174dd2aa907ce8af9866740cf219f42f98db0 Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Tue, 8 Sep 2026 17:02:49 -0700 Subject: [PATCH 7/9] Refactor to add root if it is a symlink to iterator to be processed --- fact/src/host_scanner.rs | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index 295a7609..8a4fbbf3 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -194,9 +194,9 @@ impl HostScanner { // glob does not return the non-glob root of a recursive pattern, // so seed it explicitly to cover symlink roots (ROX-36737). - self.scan_pattern_root(glob_str); + let symlink_root = self.symlink_pattern_root(glob_str); - for entry in glob::glob(glob_str)? { + for entry in symlink_root.map(Ok).into_iter().chain(glob::glob(glob_str)?) { let path = match entry { Ok(p) => p, Err(e) => { @@ -232,28 +232,24 @@ impl HostScanner { Ok(()) } - /// Seed the non-glob root of a pattern when it is a symlink. + /// Return the non-glob root of a pattern when it is a symlink. /// /// glob expansion skips the symlink root of a recursive pattern /// (e.g. `/host/root/**` where `/host/root -> var/roothome`), - /// leaving the target directory inode untracked. This maps that - /// inode to the configured logical path so direct children are seen. - fn scan_pattern_root(&self, glob_str: &str) { + /// leaving the target directory inode untracked. Returning it here + /// lets the scan loop process it like any other symlink entry so + /// direct children are seen (ROX-36737). + fn symlink_pattern_root(&self, glob_str: &str) -> Option { let prefix = glob_str .split(['*', '?', '[', '{']) .next() .unwrap_or(glob_str); - let Some(idx) = prefix.rfind('/') else { - return; - }; + let idx = prefix.rfind('/')?; let root = Path::new(if idx == 0 { "/" } else { &prefix[..idx] }); match root.symlink_metadata() { - Ok(metadata) if metadata.is_symlink() => { - self.metrics.scan_inc(ScanLabels::SymlinkScanned); - self.scan_symlink(root); - } - _ => {} + Ok(metadata) if metadata.is_symlink() => Some(root.to_path_buf()), + _ => None, } } From e84707e62fec9a34420e2cd19b5a7e71b108239f Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Tue, 8 Sep 2026 20:27:38 -0700 Subject: [PATCH 8/9] refactor(host_scanner): split pattern_root for unit testing Assisted-by: Claude Code --- fact/src/host_scanner.rs | 86 +++++++++++++++++++++++++++++----------- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index 8a4fbbf3..e4a38525 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -25,7 +25,7 @@ use std::{ io, ops::{Deref, DerefMut}, os::linux::fs::MetadataExt, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, sync::Arc, time::Duration, }; @@ -122,6 +122,37 @@ pub struct HostScanner { metrics: HostScannerMetrics, } +/// Return the literal directory root of a glob pattern. +/// +/// This is the run of components before the first glob metacharacter +/// (`/host/root/**` -> `/host/root`). A fully literal pattern has no +/// such boundary, so its leaf is dropped to yield the parent directory +/// (`/etc/passwd` -> `/etc`). Pure path computation, no filesystem access. +fn pattern_root(path: &Path) -> Option { + let is_glob = |c: &Component| c.as_os_str().to_string_lossy().contains(['*', '?', '[', '{']); + + if path.components().any(|c| is_glob(&c)) { + Some(path.components().take_while(|c| !is_glob(c)).collect()) + } else { + path.parent().map(Path::to_path_buf) + } +} + +/// Return the non-glob root of a pattern when it resolves to a symlink. +/// +/// glob expansion skips the symlink root of a recursive pattern +/// (e.g. `/host/root/**` where `/host/root -> var/roothome`), leaving the +/// target directory inode untracked. Returning it here lets the scan loop +/// process it like any other symlink entry so direct children are seen +/// (ROX-36737). +fn symlink_pattern_root(path: &Path) -> Option { + let root = pattern_root(path)?; + root.symlink_metadata() + .ok() + .filter(Metadata::is_symlink) + .map(|_| root) +} + impl HostScanner { pub fn new( bpf: &mut Bpf, @@ -194,7 +225,7 @@ impl HostScanner { // glob does not return the non-glob root of a recursive pattern, // so seed it explicitly to cover symlink roots (ROX-36737). - let symlink_root = self.symlink_pattern_root(glob_str); + let symlink_root = symlink_pattern_root(path); for entry in symlink_root.map(Ok).into_iter().chain(glob::glob(glob_str)?) { let path = match entry { @@ -232,27 +263,6 @@ impl HostScanner { Ok(()) } - /// Return the non-glob root of a pattern when it is a symlink. - /// - /// glob expansion skips the symlink root of a recursive pattern - /// (e.g. `/host/root/**` where `/host/root -> var/roothome`), - /// leaving the target directory inode untracked. Returning it here - /// lets the scan loop process it like any other symlink entry so - /// direct children are seen (ROX-36737). - fn symlink_pattern_root(&self, glob_str: &str) -> Option { - let prefix = glob_str - .split(['*', '?', '[', '{']) - .next() - .unwrap_or(glob_str); - let idx = prefix.rfind('/')?; - let root = Path::new(if idx == 0 { "/" } else { &prefix[..idx] }); - - match root.symlink_metadata() { - Ok(metadata) if metadata.is_symlink() => Some(root.to_path_buf()), - _ => None, - } - } - fn scan_symlink(&self, path: &Path) { let target = match path.read_link() { Ok(p) => { @@ -726,3 +736,33 @@ You can increase this limit with: }); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pattern_root_extracts_literal_prefix() { + let cases = [ + ("/host/root/**", Some("/host/root"), "recursive wildcard"), + ("/etc/*.conf", Some("/etc"), "single-star glob"), + ("/host/ro*t/x", Some("/host"), "mid-component wildcard"), + ("/data/[abc]/x", Some("/data"), "character class"), + ("/data/{a,b}/x", Some("/data"), "brace alternation"), + ("/data/f?le", Some("/data"), "question mark"), + ("/a/b/file", Some("/a/b"), "fully literal drops leaf"), + ("/foo", Some("/"), "literal under root"), + ("foo", Some(""), "relative literal single component"), + ("*.conf", Some(""), "wildcard in first component"), + ("/", None, "root has no parent"), + ]; + + for (pattern, expected, description) in cases { + assert_eq!( + pattern_root(Path::new(pattern)).as_deref(), + expected.map(Path::new), + "Failed for {description}: {pattern}" + ); + } + } +} From f271b4c11c0763465325fb27c794f63325e68e87 Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Tue, 8 Sep 2026 20:46:51 -0700 Subject: [PATCH 9/9] Fixed format errors --- fact/src/host_scanner.rs | 12 ++++++++++-- tests/test_path_symlink.py | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index e4a38525..d1f86a6a 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -129,7 +129,11 @@ pub struct HostScanner { /// such boundary, so its leaf is dropped to yield the parent directory /// (`/etc/passwd` -> `/etc`). Pure path computation, no filesystem access. fn pattern_root(path: &Path) -> Option { - let is_glob = |c: &Component| c.as_os_str().to_string_lossy().contains(['*', '?', '[', '{']); + let is_glob = |c: &Component| { + c.as_os_str() + .to_string_lossy() + .contains(['*', '?', '[', '{']) + }; if path.components().any(|c| is_glob(&c)) { Some(path.components().take_while(|c| !is_glob(c)).collect()) @@ -227,7 +231,11 @@ impl HostScanner { // so seed it explicitly to cover symlink roots (ROX-36737). let symlink_root = symlink_pattern_root(path); - for entry in symlink_root.map(Ok).into_iter().chain(glob::glob(glob_str)?) { + for entry in symlink_root + .map(Ok) + .into_iter() + .chain(glob::glob(glob_str)?) + { let path = match entry { Ok(p) => p, Err(e) => { diff --git a/tests/test_path_symlink.py b/tests/test_path_symlink.py index 7a5efba0..0712694b 100644 --- a/tests/test_path_symlink.py +++ b/tests/test_path_symlink.py @@ -9,6 +9,7 @@ import docker.models.containers import pytest import yaml + from event import Event, EventType, Process from server import EventServer from utils import join_path_with_filename, path_to_string