From 2fcd9a38b9e1c1b55ccf56e83540d471f1499ed9 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Wed, 25 Mar 2026 16:59:27 +0100 Subject: [PATCH 01/10] Unwatch inode of deleted files --- fact-ebpf/src/bpf/main.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index b7c044f1..5ac35159 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -94,6 +94,9 @@ int BPF_PROG(trace_path_unlink, struct path* dir, struct dentry* dentry) { return 0; } + // We only support files with one link for now + inode_remove(&inode_key); + submit_unlink_event(&m->path_unlink, path->path, inode_to_submit, From a8a3bef9292abaa5884e39aa4a2a75448d26fdb1 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Wed, 25 Mar 2026 17:18:00 +0100 Subject: [PATCH 02/10] Dismiss inode entry on unlink --- fact/src/event/mod.rs | 4 ++++ fact/src/host_scanner.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 40bd317a..bc9d7897 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -130,6 +130,10 @@ impl Event { matches!(self.file, FileData::Creation(_)) } + pub fn is_unlink(&self) -> bool { + matches!(self.file, FileData::Unlink(_)) + } + /// Unwrap the inner FileData and return the inode that triggered /// the event. /// diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index 36cacdef..03754912 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -220,6 +220,18 @@ impl HostScanner { Ok(()) } + /// Special handling for unlink events. + /// + /// This method removes the inode from the userland inode->path map. + /// The probe already cleared the kernel inode map. + fn handle_unlink_event(&self, event: &Event) { + let inode = event.get_inode(); + + if self.inode_map.borrow_mut().remove(inode).is_some() { + self.metrics.scan_inc(ScanLabels::InodeRemoved); + } + } + /// Periodically notify the host scanner main task that a scan needs /// to happen. /// @@ -277,6 +289,11 @@ impl HostScanner { event.set_old_host_path(host_path); } + // Special handling for unlink events + if event.is_unlink() { + self.handle_unlink_event(&event); + } + let event = Arc::new(event); if let Err(e) = self.tx.send(event) { self.metrics.events.dropped(); From 807b2a9a557424705632cbd8cf23435733914843 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Wed, 25 Mar 2026 18:22:23 +0100 Subject: [PATCH 03/10] Verify inode deletion in probe --- tests/test_path_unlink.py | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_path_unlink.py b/tests/test_path_unlink.py index 66a533e6..cf44859f 100644 --- a/tests/test_path_unlink.py +++ b/tests/test_path_unlink.py @@ -227,3 +227,52 @@ def test_unmonitored_mounted_dir(test_container, test_file, server): file=fut, host_path=test_file) server.wait_events([event]) + + +def test_probe_inode_map(monitored_dir, ignored_dir, server): + """ + TODO[ROX-33222]: This test won't work when hardlinks are handled properly. + + This test demonstrates that the current implementation removes the inode + from the kernel map correctly, as a second unmonitored hardlink deletion + is not noticed. + + Args: + monitored_dir: Temporary directory path that is monitored by fact. + ignored_dir: Temporary directory path that is NOT monitored by fact. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # File Under Test - original file in monitored directory + original_file = os.path.join(monitored_dir, 'original.txt') + + # Create the original file + with open(original_file, 'w') as f: + f.write('This is a test') + + # Create two hardlinks in the unmonitored directory + hardlink_file1 = os.path.join(ignored_dir, 'hardlink1.txt') + os.link(original_file, hardlink_file1) + + hardlink_file2 = os.path.join(ignored_dir, 'hardlink2.txt') + os.link(original_file, hardlink_file2) + + os.remove(hardlink_file1) + os.remove(hardlink_file2) + + # Create a guard file to ensure all events have been processed + guard_file = os.path.join(monitored_dir, 'guard.txt') + with open(guard_file, 'w') as f: + f.write('guard') + + events = [ + Event(process=process, event_type=EventType.CREATION, + file=original_file, host_path=original_file), + Event(process=process, event_type=EventType.UNLINK, + file=hardlink_file1, host_path=original_file), + Event(process=process, event_type=EventType.CREATION, + file=guard_file, host_path=guard_file), + ] + + server.wait_events(events) From fb7d100306610d767d7a4a4f4833c1cfb69881c2 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Fri, 27 Mar 2026 15:26:02 +0100 Subject: [PATCH 04/10] Split the test in 2 phases for reliability --- tests/test_path_unlink.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_path_unlink.py b/tests/test_path_unlink.py index cf44859f..3c94386a 100644 --- a/tests/test_path_unlink.py +++ b/tests/test_path_unlink.py @@ -231,7 +231,7 @@ def test_unmonitored_mounted_dir(test_container, test_file, server): def test_probe_inode_map(monitored_dir, ignored_dir, server): """ - TODO[ROX-33222]: This test won't work when hardlinks are handled properly. + TODO[ROX-33222]: This test won't work when hardlinks are handled properly. This test demonstrates that the current implementation removes the inode from the kernel map correctly, as a second unmonitored hardlink deletion @@ -258,6 +258,11 @@ def test_probe_inode_map(monitored_dir, ignored_dir, server): hardlink_file2 = os.path.join(ignored_dir, 'hardlink2.txt') os.link(original_file, hardlink_file2) + e = Event(process=process, event_type=EventType.CREATION, + file=original_file, host_path=original_file) + + server.wait_events([e]) + os.remove(hardlink_file1) os.remove(hardlink_file2) @@ -267,8 +272,6 @@ def test_probe_inode_map(monitored_dir, ignored_dir, server): f.write('guard') events = [ - Event(process=process, event_type=EventType.CREATION, - file=original_file, host_path=original_file), Event(process=process, event_type=EventType.UNLINK, file=hardlink_file1, host_path=original_file), Event(process=process, event_type=EventType.CREATION, From 910954bafbe5eb7d82bb88fc50194e560a2ef73d Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Mon, 30 Mar 2026 09:28:21 +0200 Subject: [PATCH 05/10] Enhance comments --- fact/src/host_scanner.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index 03754912..1b83cd69 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -220,9 +220,8 @@ impl HostScanner { Ok(()) } - /// Special handling for unlink events. + /// Handle unlink events by removing the inode from the inode->path map. /// - /// This method removes the inode from the userland inode->path map. /// The probe already cleared the kernel inode map. fn handle_unlink_event(&self, event: &Event) { let inode = event.get_inode(); @@ -289,7 +288,7 @@ impl HostScanner { event.set_old_host_path(host_path); } - // Special handling for unlink events + // Remove inode from the map if event.is_unlink() { self.handle_unlink_event(&event); } From 9796c045369d780e59038abb631aaa6e8353f35e Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Mon, 30 Mar 2026 14:33:45 +0200 Subject: [PATCH 06/10] Count files deleted --- fact/src/host_scanner.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index 1b83cd69..6f5a59be 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -229,6 +229,8 @@ impl HostScanner { if self.inode_map.borrow_mut().remove(inode).is_some() { self.metrics.scan_inc(ScanLabels::InodeRemoved); } + + self.metrics.scan_inc(ScanLabels::FileRemoved); } /// Periodically notify the host scanner main task that a scan needs From 11244a93bf581efd5baaee855417ec7e31e5512b Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Tue, 7 Apr 2026 10:36:19 +0200 Subject: [PATCH 07/10] Restore integration test to have only one wait_events() --- tests/test_path_unlink.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_path_unlink.py b/tests/test_path_unlink.py index 3c94386a..19fa2085 100644 --- a/tests/test_path_unlink.py +++ b/tests/test_path_unlink.py @@ -258,10 +258,6 @@ def test_probe_inode_map(monitored_dir, ignored_dir, server): hardlink_file2 = os.path.join(ignored_dir, 'hardlink2.txt') os.link(original_file, hardlink_file2) - e = Event(process=process, event_type=EventType.CREATION, - file=original_file, host_path=original_file) - - server.wait_events([e]) os.remove(hardlink_file1) os.remove(hardlink_file2) @@ -272,6 +268,8 @@ def test_probe_inode_map(monitored_dir, ignored_dir, server): f.write('guard') events = [ + Event(process=process, event_type=EventType.CREATION, + file=original_file, host_path=original_file), Event(process=process, event_type=EventType.UNLINK, file=hardlink_file1, host_path=original_file), Event(process=process, event_type=EventType.CREATION, From c0b761a4b8759f76a246addf4d6161638811596e Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Tue, 7 Apr 2026 10:47:08 +0200 Subject: [PATCH 08/10] Disable scanner when scan_interval==0 --- fact/src/config/mod.rs | 5 +++-- fact/src/config/tests.rs | 2 -- fact/src/host_scanner.rs | 8 +++++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/fact/src/config/mod.rs b/fact/src/config/mod.rs index 454009e6..4dd16f97 100644 --- a/fact/src/config/mod.rs +++ b/fact/src/config/mod.rs @@ -209,13 +209,14 @@ impl TryFrom> for FactConfig { config.hotreload = Some(hotreload); } "scan_interval" => { + // scan_internal == 0 disables the scanner if let Some(scan_interval) = v.as_f64() { - if scan_interval <= 0.0 { + if scan_interval < 0.0 { bail!("invalid scan_interval: {scan_interval}"); } config.scan_interval = Some(Duration::from_secs_f64(scan_interval)); } else if let Some(scan_interval) = v.as_i64() { - if scan_interval <= 0 { + if scan_interval < 0 { bail!("invalid scan_interval: {scan_interval}"); } config.scan_interval = Some(Duration::from_secs(scan_interval as u64)) diff --git a/fact/src/config/tests.rs b/fact/src/config/tests.rs index c36c25c8..571f879a 100644 --- a/fact/src/config/tests.rs +++ b/fact/src/config/tests.rs @@ -465,8 +465,6 @@ paths: "scan_interval: true", "scan_interval field has incorrect type: Boolean(true)", ), - ("scan_interval: 0", "invalid scan_interval: 0"), - ("scan_interval: 0.0", "invalid scan_interval: 0"), ("scan_interval: -128", "invalid scan_interval: -128"), ("scan_interval: -128.5", "invalid scan_interval: -128.5"), ("unknown:", "Invalid field 'unknown' with value: Null"), diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index 6f5a59be..19d5b719 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -259,8 +259,14 @@ impl HostScanner { } pub fn start(mut self) -> JoinHandle> { + let scan_interval_value = *self.scan_interval.borrow(); let scan_trigger = Arc::new(Notify::new()); - self.start_scan_notifier(scan_trigger.clone()); + + if scan_interval_value.is_zero() { + warn!("Host scanner periodic scans permanently disabled (scan_interval is 0)"); + } else { + self.start_scan_notifier(scan_trigger.clone()); + } tokio::spawn(async move { info!("Starting host scanner..."); From 90cb3b7cc868ac18646da997d715813790037303 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Tue, 7 Apr 2026 11:23:21 +0200 Subject: [PATCH 09/10] Disable periodic scanner in tests --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index 165649f8..cce6a03f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -119,6 +119,7 @@ def fact_config(request, monitored_dir, logs_dir): 'health_check': True, }, 'json': True, + 'scan_interval': 0, } config_file = NamedTemporaryFile( prefix='fact-config-', suffix='.yml', dir=cwd, mode='w') From ca0ef71f64cd8a37a70b6d41cf0a5cae0ead83b8 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Thu, 9 Apr 2026 16:25:21 +0200 Subject: [PATCH 10/10] Remove the test This is flaky for an unknown reason, and would be removed after hardlinks are properly handled. --- tests/test_path_unlink.py | 50 --------------------------------------- 1 file changed, 50 deletions(-) diff --git a/tests/test_path_unlink.py b/tests/test_path_unlink.py index 19fa2085..66a533e6 100644 --- a/tests/test_path_unlink.py +++ b/tests/test_path_unlink.py @@ -227,53 +227,3 @@ def test_unmonitored_mounted_dir(test_container, test_file, server): file=fut, host_path=test_file) server.wait_events([event]) - - -def test_probe_inode_map(monitored_dir, ignored_dir, server): - """ - TODO[ROX-33222]: This test won't work when hardlinks are handled properly. - - This test demonstrates that the current implementation removes the inode - from the kernel map correctly, as a second unmonitored hardlink deletion - is not noticed. - - Args: - monitored_dir: Temporary directory path that is monitored by fact. - ignored_dir: Temporary directory path that is NOT monitored by fact. - server: The server instance to communicate with. - """ - process = Process.from_proc() - - # File Under Test - original file in monitored directory - original_file = os.path.join(monitored_dir, 'original.txt') - - # Create the original file - with open(original_file, 'w') as f: - f.write('This is a test') - - # Create two hardlinks in the unmonitored directory - hardlink_file1 = os.path.join(ignored_dir, 'hardlink1.txt') - os.link(original_file, hardlink_file1) - - hardlink_file2 = os.path.join(ignored_dir, 'hardlink2.txt') - os.link(original_file, hardlink_file2) - - - os.remove(hardlink_file1) - os.remove(hardlink_file2) - - # Create a guard file to ensure all events have been processed - guard_file = os.path.join(monitored_dir, 'guard.txt') - with open(guard_file, 'w') as f: - f.write('guard') - - events = [ - Event(process=process, event_type=EventType.CREATION, - file=original_file, host_path=original_file), - Event(process=process, event_type=EventType.UNLINK, - file=hardlink_file1, host_path=original_file), - Event(process=process, event_type=EventType.CREATION, - file=guard_file, host_path=guard_file), - ] - - server.wait_events(events)