From e2d340c5cc9e087fbb6262107e50ad65e9d04d62 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Fri, 14 Aug 2026 09:55:43 +0200 Subject: [PATCH] feat: allow partial scans This is achieved by leveraging `GlobSet::matches` which gives a vector of indexes to the patterns that matched, then using those to only do glob expansion on the matched patterns. This is probably the simplest way we can achieve partial scanning without implementing directory walking ourselves. --- fact/src/host_scanner.rs | 66 +++++++++++++++++++++++++--------------- tests/test_wildcard.py | 51 ++++++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 30 deletions(-) diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index b5252601..6594a74b 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -105,6 +105,7 @@ pub struct HostScanner { metrics: HostScannerMetrics, paths_globset: GlobSet, + paths_patterns: Vec, } impl HostScanner { @@ -119,9 +120,8 @@ impl HostScanner { let kernel_inode_map = RefCell::new(bpf.take_inode_map()?); let inode_map = RefCell::new(InodeMap::new()); let (tx, output) = mpsc::channel(100); - let paths_globset = HostScanner::build_globset(paths.borrow().as_slice())?; - let host_scanner = HostScanner { + let mut host_scanner = HostScanner { kernel_inode_map, inode_map, paths, @@ -130,18 +130,26 @@ impl HostScanner { tx, introspection, metrics, - paths_globset, + paths_globset: GlobSet::empty(), + paths_patterns: Vec::new(), }; + host_scanner.reload_paths_config()?; + // Run an initial scan to fill in the inode map host_scanner.scan()?; Ok((host_scanner, output)) } - fn build_globset(paths: &[PathBuf]) -> anyhow::Result { + fn reload_paths_config(&mut self) -> anyhow::Result<()> { + let paths = self.paths.borrow(); let mut builder = GlobSetBuilder::new(); + let mut patterns = Vec::with_capacity(paths.len()); + for p in paths.iter() { + patterns.push(host_info::prepend_host_mount(p)); + let Some(glob_str) = p.to_str() else { bail!("failed to convert path {} to string", p.display()); }; @@ -152,23 +160,24 @@ impl HostScanner { .unwrap(), ); } - Ok(builder.build()?) + + self.paths_globset = builder.build()?; + self.paths_patterns = patterns; + + Ok(()) } fn scan(&self) -> anyhow::Result<()> { info!("Host scan started"); let start = Instant::now(); self.metrics.scan_inc(ScanLabels::Scans); - let config = self.paths.borrow(); // Cleanup any items that are either: // * Not configured to be monitored anymore. // * Are configured to be monitored but no longer are found in // the file system. self.inode_map.borrow_mut().retain(|inode, path| { - if config.iter().any(|prefix| path.starts_with(prefix)) - && host_info::prepend_host_mount(path).exists() - { + if self.paths_globset.is_match(&path) && host_info::prepend_host_mount(path).exists() { true } else { let _ = self.kernel_inode_map.borrow_mut().remove(inode); @@ -177,9 +186,8 @@ impl HostScanner { } }); - for pattern in self.paths.borrow().iter() { - let path = host_info::prepend_host_mount(pattern); - self.scan_inner(&path)?; + for path in &self.paths_patterns { + self.scan_inner(path)?; } let duration = start.elapsed(); self.metrics.scan_duration.observe(duration.as_secs_f64()); @@ -231,6 +239,18 @@ impl HostScanner { Ok(()) } + fn scan_partial(&self, path: &Path) -> anyhow::Result<()> { + for pattern in self + .paths_globset + .matches(path) + .iter() + .map(|index| &self.paths_patterns[*index]) + { + self.scan_inner(pattern)?; + } + Ok(()) + } + fn update_entry(&self, path: &Path, metadata: &Metadata) -> anyhow::Result<()> { let inode = inode_key_t { inode: metadata.st_ino(), @@ -448,12 +468,8 @@ You can increase this limit with: } /// Handle a mount being modified in a monitored directory. - /// - /// This should really do a partial scan of the directory where the - /// mount is being changed, but we don't have an easy way to do that - /// at the moment, so we trigger a full scan instead. - fn handle_mount_event(&self) { - if let Err(e) = self.scan() { + fn handle_mount_event(&self, event: &Event) { + if let Err(e) = self.scan_partial(event.get_host_path()) { warn!("Host scan failed: {e:?}"); } } @@ -511,12 +527,6 @@ You can increase this limit with: warn!("Failed to handle creation event: {e}"); } - // Handle mount events and move on. - if event.is_mount_related() { - self.handle_mount_event(); - continue; - } - if let Some(host_path) = self.get_host_path(Some(event.get_inode())) { self.metrics.scan_inc(ScanLabels::InodeHit); event.set_host_path(host_path); @@ -527,6 +537,12 @@ You can increase this limit with: event.set_old_host_path(host_path); } + // Handle mount events and move on. + if event.is_mount_related() { + self.handle_mount_event(&event); + continue; + } + // Remove inode from the map if event.is_deletion() { self.handle_unlink_event(&event); @@ -568,7 +584,7 @@ You can increase this limit with: } _ = scan_trigger.notified() => self.scan()?, _ = self.paths.changed() => { - self.paths_globset = HostScanner::build_globset(self.paths.borrow().as_slice())?; + self.reload_paths_config()?; self.scan()?; } } diff --git a/tests/test_wildcard.py b/tests/test_wildcard.py index c2a2f01c..6559ec57 100644 --- a/tests/test_wildcard.py +++ b/tests/test_wildcard.py @@ -11,6 +11,15 @@ 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) + + @pytest.fixture def wildcard_config( fact: docker.models.containers.Container, @@ -23,12 +32,22 @@ def wildcard_config( f'{monitored_dir}/*.conf', f'{monitored_dir}/**/test-*.log', ] - with open(config_file, 'w') as f: - yaml.dump(config, f) - # reload the config - fact.kill('SIGHUP') - sleep(0.1) + reload_config(fact, config, config_file) + return config, config_file + + +@pytest.fixture +def partial_path_match_wildcard( + fact: docker.models.containers.Container, + fact_config: tuple[dict, str], + ignored_dir: str, +): + config, config_file = fact_config + partial_dir = ignored_dir.rsplit('-', 1)[0] + config['paths'] = [f'{partial_dir}*/**/*', f'{partial_dir}*'] + + reload_config(fact, config, config_file) return config, config_file @@ -197,3 +216,25 @@ def test_multiple_patterns( ] server.wait_events(events) + + +def test_partial_dir_pattern( + partial_path_match_wildcard: tuple[dict, str], + ignored_dir: str, + server: EventServer, +): + process = Process.from_proc() + file = os.path.join(ignored_dir, 'file.txt') + with open(file, 'w') as f: + f.write('This is a test') + + server.wait_events( + [ + Event( + process=process, + event_type=EventType.CREATION, + file=file, + host_path=file, + ) + ] + )