From 199aaa4a0aa918555d0f3d4eacf99b01bd547a58 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Thu, 3 Sep 2026 19:28:58 -0700 Subject: [PATCH] fix: follow absolute symlink directories from host mount --- fact/src/host_scanner.rs | 80 ++++++++++++++++++++++++++------------ tests/test_path_symlink.py | 25 ++++++------ 2 files changed, 70 insertions(+), 35 deletions(-) diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index ccebbc33..cbdbec00 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -241,7 +241,9 @@ impl HostScanner { self.metrics.scan_inc(ScanLabels::FileScanned); } else if metadata.is_symlink() { self.metrics.scan_inc(ScanLabels::SymlinkScanned); - self.scan_symlink(&path); + if let Err(e) = self.scan_symlink(&path) { + warn!("Failed to scan symlink {}: {e:#}", path.display()); + } } else if metadata.is_dir() { self.metrics.scan_inc(ScanLabels::DirectoryScanned); } else { @@ -255,34 +257,64 @@ impl HostScanner { Ok(()) } - fn scan_symlink(&self, path: &Path) { - let target = match path.read_link() { - Ok(p) => { - if p.has_root() { - &host_info::prepend_host_mount(&p) - } else { - path - } - } - Err(e) => { - warn!("Failed to read symlink path: {e}"); - return; - } + fn scan_symlink(&self, path: &Path) -> anyhow::Result<()> { + let link_target = path + .read_link() + .with_context(|| format!("failed to read symlink {}", path.display()))?; + let target = if link_target.has_root() { + // FACT scans the host below FACT_HOST_MOUNT. Absolute links are + // absolute in the host filesystem, not in FACT's container. + host_info::prepend_host_mount(&link_target) + } else { + path.parent().unwrap_or(Path::new("/")).join(link_target) }; + let metadata = target + .metadata() + .with_context(|| format!("failed to read symlink target {}", target.display()))?; + self.update_entry(path, &metadata) + .with_context(|| format!("failed to update symlink entry for {}", path.display()))?; - match target.metadata() { - Ok(metadata) => { - if let Err(e) = self.update_entry(path, &metadata) { - warn!("Failed to update symlink entry for {}: {e}", path.display()); + if !metadata.is_dir() { + return Ok(()); + } + + // glob expands an absolute symlink relative to FACT's container root. + // Expand the translated target instead, but retain the configured + // symlink path as the inode-map alias used for event enrichment. + let recursive_target = target.join("**/*"); + let glob_str = recursive_target + .to_str() + .context("invalid recursive symlink target path")?; + for entry in glob::glob(glob_str)? { + let target_path = match entry { + Ok(path) => path, + Err(e) => { + debug!("Glob expansion failed: {e:?}"); + self.metrics.scan_inc(ScanLabels::GlobFailed); + continue; } - } - Err(e) => { - warn!( - "Failed to read metadata for symlink target {}: {e}", + }; + let suffix = target_path.strip_prefix(&target).with_context(|| { + format!( + "symlink target {} escaped recursive root {}", + target_path.display(), target.display() - ); - } + ) + })?; + let metadata = match target_path.symlink_metadata() { + Ok(metadata) => metadata, + Err(e) if e.kind() == io::ErrorKind::NotFound => continue, + Err(e) => { + warn!("Failed to get metadata for {}: {e}", target_path.display()); + continue; + } + }; + self.update_entry(&path.join(suffix), &metadata).with_context(|| { + format!("failed to update symlink descendant {}", target_path.display()) + })?; } + + Ok(()) } /// Do a partial scan of any pattern that matches the provided path diff --git a/tests/test_path_symlink.py b/tests/test_path_symlink.py index 94fb1afb..516eeb41 100644 --- a/tests/test_path_symlink.py +++ b/tests/test_path_symlink.py @@ -201,10 +201,6 @@ def test_follow_symlink_to_file_relative( ) -@pytest.mark.skip( - reason='symlinks with absolute paths are broken when ' - + 'running inside container' -) def test_follow_symlink_to_dir( monitored_dir: str, ignored_dir: str, server: EventServer ): @@ -215,6 +211,8 @@ def test_follow_symlink_to_dir( file = os.path.join(ignored_dir, 'file.txt') other_file = os.path.join(ignored_dir, 'other.txt') link = os.path.join(monitored_dir, 'symlink') + link_file = os.path.join(link, 'file.txt') + link_other_file = os.path.join(link, 'other.txt') proc = Process.from_proc() with open(file, 'w') as f: @@ -232,26 +230,31 @@ def test_follow_symlink_to_dir( ] ) - # At this point, modifying files in the ignored path should - # trigger events + # The existing child must be seeded through the absolute directory + # symlink. Test it before creating a new child: the latter can be + # observed merely because the symlink directory itself is tracked. with open(file, 'w') as f: f.write('This is a test') - with open(other_file, 'w') as f: - f.write('This is a test') - server.wait_events( [ Event( process=proc, event_type=EventType.OPEN, file=file, - host_path=link, + host_path=link_file, ), + ] + ) + + with open(other_file, 'w') as f: + f.write('This is a test') + server.wait_events( + [ Event( process=proc, event_type=EventType.CREATION, file=other_file, - host_path=link, + host_path=link_other_file, ), ] )