From 2fcd9a38b9e1c1b55ccf56e83540d471f1499ed9 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Wed, 25 Mar 2026 16:59:27 +0100 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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,