diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index a48eb953..1a1f96a7 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -73,6 +73,15 @@ __always_inline static void submit_open_event(struct submit_event_args_t* args, __submit_event(args, true); } +__always_inline static void submit_link_event(struct submit_event_args_t* args) { + if (!reserve_event(args)) { + return; + } + args->event->type = FILE_ACTIVITY_LINK; + + __submit_event(args, path_hooks_support_bpf_d_path); +} + __always_inline static void submit_unlink_event(struct submit_event_args_t* args) { if (!reserve_event(args)) { return; diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 94471cdd..8d5aae7d 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -86,6 +86,43 @@ int BPF_PROG(trace_file_open, struct file* file) { return 0; } +SEC("lsm/path_link") +int BPF_PROG(trace_path_link, struct dentry* old_dentry, const struct path* new_dir, struct dentry* new_dentry) { + struct metrics_t* m = get_metrics(); + if (m == NULL) { + return 0; + } + struct submit_event_args_t args = {.metrics = &m->path_link}; + + args.metrics->total++; + + struct bound_path_t* new_path = path_read_append_d_entry((struct path*)new_dir, new_dentry); + if (new_path == NULL) { + bpf_printk("Failed to read new path"); + args.metrics->error++; + return 0; + } + args.filename = new_path->path; + + // The inode is from the old file (being linked to), which is the same + // inode the new link will point to. + args.inode = inode_to_key(old_dentry->d_inode); + args.parent_inode = inode_to_key(new_dir->dentry->d_inode); + args.monitored = is_monitored(&args.inode, new_path, &args.parent_inode); + + if (args.monitored == NOT_MONITORED) { + args.metrics->ignored++; + return 0; + } + + if (args.monitored == MONITORED_BY_PARENT) { + inode_add(&args.inode); + } + + submit_link_event(&args); + return 0; +} + SEC("lsm/path_unlink") int BPF_PROG(trace_path_unlink, struct path* dir, struct dentry* dentry) { struct metrics_t* m = get_metrics(); @@ -112,8 +149,10 @@ 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(&args.inode); + // Only remove from kernel map if this is the last link + if (BPF_CORE_READ(dentry, d_inode, i_nlink) == 1) { + inode_remove(&args.inode); + } submit_unlink_event(&args); return 0; @@ -238,7 +277,9 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, // Old inode is monitored, new path is not. // If the old path is a directory userspace will remove any // subdirectories and files too. - inode_remove(&old_inode); + if (BPF_CORE_READ(old_dentry, d_inode, i_nlink) == 1) { + inode_remove(&old_inode); + } } break; @@ -250,7 +291,9 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, // which should never happen. When the inode crosses into a new // mount, a new inode is created altogether. Still, we can cover // our bases. - inode_remove(&old_inode); + if (BPF_CORE_READ(old_dentry, d_inode, i_nlink) == 1) { + inode_remove(&old_inode); + } } break; @@ -266,7 +309,9 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, // Old inode is monitored and will land on a path that has a // monitored parent but the path itself is not monitored, we // stop tracking the inode - inode_remove(&old_inode); + if (BPF_CORE_READ(old_dentry, d_inode, i_nlink) == 1) { + inode_remove(&old_inode); + } } break; @@ -274,7 +319,9 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, // If we landed here, the new path already has an inode that is // being tracked and is about to be overwritten, we need to remove // it from the map - inode_remove(&args.inode); + if (BPF_CORE_READ(new_dentry, d_inode, i_nlink) == 1) { + inode_remove(&args.inode); + } if (old_monitored != MONITORED_BY_INODE) { // Old inode is not monitored, but is landing in a monitored // path that uses inode tracking. diff --git a/fact-ebpf/src/bpf/types.h b/fact-ebpf/src/bpf/types.h index 4037861e..d54c5ee5 100644 --- a/fact-ebpf/src/bpf/types.h +++ b/fact-ebpf/src/bpf/types.h @@ -106,6 +106,7 @@ typedef enum file_activity_type_t { FILE_ACTIVITY_INIT = -1, FILE_ACTIVITY_OPEN = 0, FILE_ACTIVITY_CREATION, + FILE_ACTIVITY_LINK, FILE_ACTIVITY_UNLINK, FILE_ACTIVITY_CHMOD, FILE_ACTIVITY_CHOWN, @@ -187,6 +188,7 @@ struct metrics_d_instantiate_t { struct metrics_t { struct metrics_by_hook_t file_open; + struct metrics_by_hook_t path_link; struct metrics_by_hook_t path_unlink; struct metrics_by_hook_t path_chmod; struct metrics_by_hook_t path_chown; diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 3bed7a04..27191f3d 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -154,6 +154,10 @@ impl Event { matches!(self.file, FileData::Unlink(_) | FileData::RmDir(_)) } + pub fn is_link(&self) -> bool { + matches!(self.file, FileData::Link { .. }) + } + pub fn is_rename(&self) -> bool { matches!(self.file, FileData::Rename { .. }) } @@ -180,6 +184,7 @@ impl Event { | FileData::Creation(inner) | FileData::MkDir(inner) | FileData::RmDir(inner) + | FileData::Link(inner) | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) @@ -201,6 +206,7 @@ impl Event { | FileData::Creation(inner) | FileData::MkDir(inner) | FileData::RmDir(inner) + | FileData::Link(inner) | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) @@ -233,6 +239,7 @@ impl Event { | FileData::Creation(inner) | FileData::MkDir(inner) | FileData::RmDir(inner) + | FileData::Link(inner) | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) @@ -262,6 +269,7 @@ impl Event { | FileData::Creation(inner) | FileData::MkDir(inner) | FileData::RmDir(inner) + | FileData::Link(inner) | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) @@ -295,6 +303,7 @@ impl Event { | FileData::Creation(inner) | FileData::MkDir(inner) | FileData::RmDir(inner) + | FileData::Link(inner) | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) @@ -326,6 +335,7 @@ impl Event { | FileData::Creation(inner) | FileData::MkDir(inner) | FileData::RmDir(inner) + | FileData::Link(inner) | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) @@ -444,6 +454,7 @@ pub enum FileData { Creation(BaseFileData), MkDir(BaseFileData), RmDir(BaseFileData), + Link(BaseFileData), Unlink(BaseFileData), Chmod(ChmodFileData), Chown(ChownFileData), @@ -489,6 +500,7 @@ impl FileData { file_activity_type_t::FILE_ACTIVITY_CREATION => FileData::Creation(inner), file_activity_type_t::DIR_ACTIVITY_CREATION => FileData::MkDir(inner), file_activity_type_t::DIR_ACTIVITY_UNLINK => FileData::RmDir(inner), + file_activity_type_t::FILE_ACTIVITY_LINK => FileData::Link(inner), file_activity_type_t::FILE_ACTIVITY_UNLINK => FileData::Unlink(inner), file_activity_type_t::FILE_ACTIVITY_CHMOD => { let data = ChmodFileData { @@ -563,6 +575,7 @@ impl FileData { FileData::Creation(_) => "creation", FileData::MkDir(_) => "mkdir", FileData::RmDir(_) => "rmdir", + FileData::Link(_) => "link", FileData::Unlink(_) => "unlink", FileData::Chmod(_) => "permission", FileData::Chown(_) => "ownership", @@ -605,6 +618,11 @@ impl From for fact_api::file_activity::File { let f_act = fact_api::FileXattrChange::from(event); fact_api::file_activity::File::XattrRemove(f_act) } + FileData::Link(event) => { + let activity = Some(fact_api::FileActivityBase::from(event)); + let f_act = fact_api::FileCreation { activity }; + fact_api::file_activity::File::Creation(f_act) + } FileData::Unlink(event) => { let activity = Some(fact_api::FileActivityBase::from(event)); let f_act = fact_api::FileUnlink { activity }; @@ -653,6 +671,7 @@ impl From for opentelemetry::logs::AnyValue { | FileData::RmDir(data) | FileData::Mount(data) | FileData::Umount(data) + | FileData::Link(data) | FileData::Unlink(data) => AnyValue::from(data), FileData::Chmod(data) => AnyValue::from(data), FileData::Chown(data) => AnyValue::from(data), diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index ccebbc33..ee011905 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -111,6 +111,7 @@ pub type IntrospectionRequest = ( pub struct HostScanner { kernel_inode_map: RefCell>, inode_map: RefCell, + usage_count: RefCell>, paths: watch::Receiver>, scan_interval: watch::Receiver, @@ -141,6 +142,7 @@ impl HostScanner { let mut host_scanner = HostScanner { kernel_inode_map, inode_map, + usage_count: RefCell::new(HashMap::new()), paths, scan_interval, rx, @@ -185,6 +187,10 @@ impl HostScanner { let start = Instant::now(); self.metrics.scan_inc(ScanLabels::Scans); + // Clear usage counts before scanning; will be + // repopulated as we encounter monitored inodes. + self.usage_count.borrow_mut().clear(); + // Cleanup any items that are either: // * Not configured to be monitored anymore. // * Are configured to be monitored but no longer are found in @@ -200,7 +206,7 @@ impl HostScanner { }); for path in &self.paths_patterns { - self.scan_inner(path)?; + self.scan_inner(path, true)?; } let duration = start.elapsed(); self.metrics.scan_duration.observe(duration.as_secs_f64()); @@ -212,7 +218,7 @@ impl HostScanner { Ok(()) } - fn scan_inner(&self, path: &Path) -> anyhow::Result<()> { + fn scan_inner(&self, path: &Path, update_usage_count: bool) -> anyhow::Result<()> { self.metrics.scan_inc(ScanLabels::ElementsScanned); let Some(glob_str) = path.to_str() else { @@ -241,7 +247,7 @@ impl HostScanner { self.metrics.scan_inc(ScanLabels::FileScanned); } else if metadata.is_symlink() { self.metrics.scan_inc(ScanLabels::SymlinkScanned); - self.scan_symlink(&path); + self.scan_symlink(&path, update_usage_count); } else if metadata.is_dir() { self.metrics.scan_inc(ScanLabels::DirectoryScanned); } else { @@ -249,13 +255,13 @@ impl HostScanner { continue; } - self.update_entry(&path, &metadata) + self.update_entry(&path, &metadata, update_usage_count) .with_context(|| format!("Failed to update entry for {}", path.display()))?; } Ok(()) } - fn scan_symlink(&self, path: &Path) { + fn scan_symlink(&self, path: &Path, update_usage_count: bool) { let target = match path.read_link() { Ok(p) => { if p.has_root() { @@ -272,7 +278,7 @@ impl HostScanner { match target.metadata() { Ok(metadata) => { - if let Err(e) = self.update_entry(path, &metadata) { + if let Err(e) = self.update_entry(path, &metadata, update_usage_count) { warn!("Failed to update symlink entry for {}: {e}", path.display()); } } @@ -312,7 +318,7 @@ impl HostScanner { .collect::>(); for pattern in scan_set.iter().map(|index| &self.paths_patterns[*index]) { - self.scan_inner(pattern)?; + self.scan_inner(pattern, false)?; } self.metrics @@ -321,21 +327,39 @@ impl HostScanner { Ok(()) } - fn update_entry(&self, path: &Path, metadata: &Metadata) -> anyhow::Result<()> { + fn update_entry( + &self, + path: &Path, + metadata: &Metadata, + update_usage_count: bool, + ) -> anyhow::Result<()> { let inode = inode_key_t { inode: metadata.st_ino(), dev: metadata.st_dev(), }; let host_path = host_info::remove_host_mount(path); - self.update_entry_with_inode(inode, host_path.to_path_buf())?; + self.update_entry_with_inode(inode, host_path.to_path_buf(), update_usage_count)?; debug!("Added entry for {}: {inode:?}", path.display()); Ok(()) } /// Similar to update_entry except we are are directly using the inode instead of the path. - fn update_entry_with_inode(&self, inode: inode_key_t, path: PathBuf) -> anyhow::Result<()> { + fn update_entry_with_inode( + &self, + inode: inode_key_t, + path: PathBuf, + update_usage_count: bool, + ) -> anyhow::Result<()> { + if update_usage_count { + self.usage_count + .borrow_mut() + .entry(inode) + .and_modify(|c| *c += 1) + .or_insert(1); + } + let mut inode_map = self.inode_map.borrow_mut(); match inode_map.get_mut(&inode) { Some(p) => { @@ -401,7 +425,7 @@ You can increase this limit with: match self.build_host_path(event) { Some(host_path) => self - .update_entry_with_inode(*inode, host_path) + .update_entry_with_inode(*inode, host_path, true) .with_context(|| { format!( "Failed to add creation event entry for {}", @@ -413,17 +437,73 @@ You can increase this limit with: } } - /// Handle unlink events by removing the inode from the inode->path map. - /// - /// The probe already cleared the kernel inode map. + /// Handle unlink events by removing the inode from the inode->path map, + /// when its usage count falls to 0. fn handle_unlink_event(&self, event: &Event) { - let inode = event.get_inode(); + self.metrics.scan_inc(ScanLabels::FileRemoved); - if self.inode_map.borrow_mut().remove(inode).is_some() { + let inode = event.get_inode(); + if self.unref_inode(inode) { self.metrics.scan_inc(ScanLabels::InodeRemoved); } + } - self.metrics.scan_inc(ScanLabels::FileRemoved); + /// Decrement the usage count for an inode and remove it from the + /// inode and kernel maps if the count falls to zero. + /// + /// Returns true if the inode was removed + fn unref_inode(&self, inode: &inode_key_t) -> bool { + let mut usage_count = self.usage_count.borrow_mut(); + if let Some(count) = usage_count.get_mut(inode) { + *count -= 1; + if *count > 0 { + return false; + } + usage_count.remove(inode); + } + + if let Err(e) = self.kernel_inode_map.borrow_mut().remove(inode) { + warn!("Failed to remove inode kernel entry: {e:?}"); + } + + self.inode_map.borrow_mut().remove(inode).is_some() + } + + /// Handle link events by potentially adding the new link to the inode map. + fn handle_link_event(&self, event: &mut Event) { + match event.get_monitored() { + monitored_t::MONITORED_BY_INODE => { + // The inode is already tracked, the new link just adds + // another reference to it. + let inode = event.get_inode(); + self.usage_count + .borrow_mut() + .entry(*inode) + .and_modify(|c| *c += 1) + .or_insert(1); + } + monitored_t::NOT_MONITORED => { + // The new path is not monitored, nothing to do. + } + monitored_t::MONITORED_BY_PARENT => { + // The parent for the target is monitored. We need to + // figure out the host path and check if we should track + // the new link. + if let Some(host_path) = self.build_host_path(event) + && self.paths_globset.is_match(&host_path) + { + let inode = *event.get_inode(); + if let Err(e) = self.update_entry_with_inode(inode, host_path.clone(), true) { + warn!("Failed to add link event entry: {e}"); + } + event.set_host_path(host_path); + } + } + monitored_t::MONITORED_BY_PATH => { + // Nothing to do here, no inode tracking is involved. + } + _ => unreachable!("Invalid monitored value"), + } } fn handle_rename_event(&self, event: &mut Event) { @@ -433,15 +513,17 @@ You can increase this limit with: // place of an existing, tracked file. We need to remove the // inode we are landing on and put the associated host path in // the old inode. - let mut inode_map = self.inode_map.borrow_mut(); - let Some(path) = inode_map.remove(event.get_inode()) else { + let inode = event.get_inode(); + let Some(path) = self.inode_map.borrow().get(inode).cloned() else { warn!("Old path was not found for inode tracked event"); return; }; + self.unref_inode(inode); + let Some(old_inode) = event.get_old_inode() else { unreachable!("old inode not found for rename event"); }; - inode_map.insert(*old_inode, path); + self.inode_map.borrow_mut().insert(*old_inode, path); } monitored_t::NOT_MONITORED if event.get_old_monitored() == Some(monitored_t::MONITORED_BY_INODE) => @@ -452,14 +534,16 @@ You can increase this limit with: warn!("Rename event did not have old host path for inode tracked item"); return; }; - self.inode_map.borrow_mut().retain(|inode, path| { - if !path.starts_with(old_host_path) { - return true; - } - - let _ = self.kernel_inode_map.borrow_mut().remove(inode); - false - }); + let inodes_to_remove: Vec<_> = self + .inode_map + .borrow() + .iter() + .filter(|(_, path)| path.starts_with(old_host_path)) + .map(|(inode, _)| *inode) + .collect(); + for inode in inodes_to_remove { + self.unref_inode(&inode); + } } monitored_t::NOT_MONITORED => { // The new path is not monitored and the old path is most likely @@ -468,11 +552,10 @@ You can increase this limit with: monitored_t::MONITORED_BY_PARENT if !event.get_inode().empty() => { // The parent for the target is monitored, but the file itself // is not. Remove the entry for the old file from the map. - self.inode_map.borrow_mut().remove( - event - .get_old_inode() - .expect("rename event did not have old inode"), - ); + let old_inode = event + .get_old_inode() + .expect("rename event did not have old inode"); + self.unref_inode(old_inode); } monitored_t::MONITORED_BY_PARENT if event.get_old_monitored() == Some(monitored_t::MONITORED_BY_INODE) => @@ -511,15 +594,15 @@ You can increase this limit with: event.set_host_path(new_host_path); } else { // New path is not tracked, remove old entries - inode_map.retain(|inode, path| { - if !path.starts_with(old_host_path) { - return true; - } - if let Err(e) = self.kernel_inode_map.borrow_mut().remove(inode) { - warn!("Failed to remove inode kernel entry: {e:?}"); - } - false - }); + let inodes_to_remove: Vec<_> = inode_map + .iter() + .filter(|(_, path)| path.starts_with(old_host_path)) + .map(|(inode, _)| *inode) + .collect(); + drop(inode_map); + for inode in inodes_to_remove { + self.unref_inode(&inode); + } } } monitored_t::MONITORED_BY_PARENT => { @@ -663,6 +746,8 @@ You can increase this limit with: warn!("Failed to handle symlink event: {e:?}"); } + if event.is_link() { self.handle_link_event(&mut event); } + if event.is_rename() { self.handle_rename_event(&mut event); } // Before sending the event forward, we need to check diff --git a/fact/src/metrics/kernel_metrics.rs b/fact/src/metrics/kernel_metrics.rs index 59ad1d38..b65f44e7 100644 --- a/fact/src/metrics/kernel_metrics.rs +++ b/fact/src/metrics/kernel_metrics.rs @@ -60,6 +60,7 @@ macro_rules! define_kernel_metrics { define_kernel_metrics!( file_open, + path_link, path_unlink, path_chmod, path_chown, diff --git a/tests/test_path_link.py b/tests/test_path_link.py new file mode 100644 index 00000000..f71deb71 --- /dev/null +++ b/tests/test_path_link.py @@ -0,0 +1,291 @@ +import os + +from event import Event, EventType, Process +from server import EventServer + + +def test_link(monitored_dir: str, server: EventServer): + """ + Tests the creation of a hardlink and verifies that the corresponding + event is captured by the server. + + Args: + monitored_dir: Temporary directory path for creating test files. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create original file + original = os.path.join(monitored_dir, 'original.txt') + with open(original, 'w') as f: + f.write('test content') + + # Create hardlink + hardlink = os.path.join(monitored_dir, 'hardlink.txt') + os.link(original, hardlink) + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=hardlink, + host_path=original, + ), + ] + + server.wait_events(events) + + +def test_multiple_hardlinks(monitored_dir: str, server: EventServer): + """ + Tests creating multiple hardlinks to the same file. + All paths should be tracked independently. + + Args: + monitored_dir: Temporary directory path for creating test files. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create original file + original = os.path.join(monitored_dir, 'original.txt') + with open(original, 'w') as f: + f.write('test content') + + # Create multiple hardlinks + link1 = os.path.join(monitored_dir, 'link1.txt') + link2 = os.path.join(monitored_dir, 'link2.txt') + link3 = os.path.join(monitored_dir, 'link3.txt') + + os.link(original, link1) + os.link(original, link2) + os.link(original, link3) + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=link1, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=link2, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=link3, + host_path=original, + ), + ] + + server.wait_events(events) + + +def test_ignored(monitored_dir: str, ignored_dir: str, server: EventServer): + """ + Tests that link events creating hardlinks in ignored directories + is captured via inode tracking. + + Args: + monitored_dir: Temporary directory path for creating test files. + ignored_dir: Temporary directory path that is not monitored by fact. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create original file in monitored directory + original = os.path.join(monitored_dir, 'original.txt') + with open(original, 'w') as f: + f.write('test content') + + # Create hardlink in ignored directory + ignored_link = os.path.join(ignored_dir, 'link.txt') + os.link(original, ignored_link) + + # Create hardlink in monitored directory + monitored_link = os.path.join(monitored_dir, 'link.txt') + os.link(original, monitored_link) + + # The hardlink in the ignored directory must be reported with the + # original host_path, since this is the basis of how inode tracking + # works. + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=ignored_link, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=monitored_link, + host_path=original, + ), + ] + + server.wait_events(events) + + +def test_link_from_ignored_to_monitored( + monitored_dir: str, ignored_dir: str, server: EventServer +): + """ + Tests creating a hardlink in a monitored path when the original file + is in an ignored path. The inode should start being tracked. + + Args: + monitored_dir: Temporary directory path for creating test files. + ignored_dir: Temporary directory path that is not monitored by fact. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create original file in IGNORED directory + original = os.path.join(ignored_dir, 'original.txt') + with open(original, 'w') as f: + f.write('test content') + + # Create hardlink in MONITORED directory + monitored_link = os.path.join(monitored_dir, 'link.txt') + os.link(original, monitored_link) + + # Only the monitored hardlink creation should be reported + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored_link, + host_path=monitored_link, + ), + ] + + server.wait_events(events) + + +def test_access_via_unmonitored_hardlink( + monitored_dir: str, ignored_dir: str, server: EventServer +): + """ + Tests accessing a file via an unmonitored hardlink when a monitored + hardlink exists. The inode is tracked, so access should generate an + event. + + Args: + monitored_dir: Temporary directory path for creating test files. + ignored_dir: Temporary directory path that is not monitored by fact. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create file in monitored directory + monitored = os.path.join(monitored_dir, 'file.txt') + with open(monitored, 'w') as f: + f.write('test content') + + # Create hardlink in ignored directory + ignored_link = os.path.join(ignored_dir, 'link.txt') + os.link(monitored, ignored_link) + + # Access via the IGNORED hardlink + with open(ignored_link, 'w') as f: + f.write('This is a test') + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored, + host_path=monitored, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=ignored_link, + host_path=monitored, + ), + Event( + process=process, + event_type=EventType.OPEN, + file=ignored_link, + host_path=monitored, + ), + ] + + server.wait_events(events) + + +def test_unlink_monitored_hardlink_with_ignored_remaining( + monitored_dir: str, ignored_dir: str, server: EventServer +): + """ + Tests unlinking the monitored hardlink when an unmonitored hardlink + still exists. + + Args: + monitored_dir: Temporary directory path for creating test files. + ignored_dir: Temporary directory path that is not monitored by fact. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create file in monitored directory + monitored = os.path.join(monitored_dir, 'file.txt') + with open(monitored, 'w') as f: + f.write('test content') + + # Create hardlink in ignored directory + ignored_link = os.path.join(ignored_dir, 'link.txt') + os.link(monitored, ignored_link) + + # Unlink the MONITORED path + os.unlink(monitored) + + with open(ignored_link) as f: + f.read() + + # Only creation and unlink events expected, no open event + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored, + host_path=monitored, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=ignored_link, + host_path=monitored, + ), + Event( + process=process, + event_type=EventType.UNLINK, + file=monitored, + host_path=monitored, + ), + ] + + server.wait_events(events)