diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index 249daf53..ba68ad76 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -19,6 +19,7 @@ struct submit_event_args_t { inode_key_t inode; inode_key_t parent_inode; monitored_t monitored; + unsigned int nlink; }; __always_inline static bool reserve_event(struct submit_event_args_t* args) { @@ -117,7 +118,8 @@ __always_inline static void submit_ownership_event(struct submit_event_args_t* a __always_inline static void submit_rename_event(struct submit_event_args_t* args, const char old_filename[PATH_MAX], inode_key_t* old_inode, - monitored_t old_monitored) { + monitored_t old_monitored, + unsigned int old_nlink) { if (!reserve_event(args)) { return; } @@ -126,6 +128,7 @@ __always_inline static void submit_rename_event(struct submit_event_args_t* args bpf_probe_read_str(args->event->from.filename, PATH_MAX, old_filename); inode_copy(&args->event->from.inode, old_inode); args->event->from.monitored = old_monitored; + args->event->from.nlink = old_nlink; __submit_event(args, path_hooks_support_bpf_d_path); } diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 12d5e86b..81c586f8 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -77,6 +77,7 @@ int BPF_PROG(trace_file_open, struct file* file) { inode_add(&args.inode); } + args.nlink = BPF_CORE_READ(file, f_inode, i_nlink); submit_open_event(&args, event_type); return 0; @@ -86,6 +87,51 @@ 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->file_open}; + + 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"); + m->file_open.error++; + return 0; + } + args.filename = new_path->path; + + // The inode is from the old file (being linked to) + args.inode = inode_to_key(old_dentry->d_inode); + + struct dentry* parent_dentry = BPF_CORE_READ(new_dir, dentry); + struct inode* parent_inode_ptr = parent_dentry ? BPF_CORE_READ(parent_dentry, d_inode) : NULL; + args.parent_inode = inode_to_key(parent_inode_ptr); + + args.monitored = is_monitored(&args.inode, new_path, &args.parent_inode); + if (args.monitored == NOT_MONITORED) { + goto ignored; + } + + // Add the inode to tracking if monitored by parent + if (args.monitored == MONITORED_BY_PARENT) { + inode_add(&args.inode); + } + + args.nlink = BPF_CORE_READ(old_dentry, d_inode, i_nlink); + submit_open_event(&args, FILE_ACTIVITY_CREATION); + + return 0; + +ignored: + m->file_open.ignored++; + 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 +158,15 @@ 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); + // Read nlink BEFORE the unlink completes (this is a pre-hook) + // After unlink, nlink will be decremented by 1 + unsigned int nlink_before = BPF_CORE_READ(dentry, d_inode, i_nlink); + args.nlink = nlink_before > 0 ? nlink_before - 1 : 0; + + // Only remove from kernel map if this is the last link + if (args.nlink == 0) { + inode_remove(&args.inode); + } submit_unlink_event(&args); return 0; @@ -146,6 +199,7 @@ int BPF_PROG(trace_path_chmod, struct path* path, umode_t mode) { } umode_t old_mode = BPF_CORE_READ(path, dentry, d_inode, i_mode); + args.nlink = BPF_CORE_READ(path, dentry, d_inode, i_nlink); submit_mode_event(&args, mode, old_mode); return 0; @@ -183,6 +237,7 @@ int BPF_PROG(trace_path_chown, struct path* path, unsigned long long uid, unsign struct dentry* d = BPF_CORE_READ(path, dentry); unsigned long long old_uid = BPF_CORE_READ(d, d_inode, i_uid.val); unsigned long long old_gid = BPF_CORE_READ(d, d_inode, i_gid.val); + args.nlink = BPF_CORE_READ(d, d_inode, i_nlink); submit_ownership_event(&args, uid, gid, old_uid, old_gid); @@ -221,6 +276,16 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, inode_key_t old_inode = inode_to_key(old_dentry->d_inode); monitored_t old_monitored = is_monitored(&old_inode, old_path, NULL); + // Get nlink for both old and new dentries + // For rename, the old path's inode nlink doesn't change (same inode, just different path) + unsigned int old_nlink = BPF_CORE_READ(old_dentry, d_inode, i_nlink); + // new_dentry->d_inode is the inode being overwritten (if any), read before it's removed + unsigned int new_nlink = 0; + if (new_dentry->d_inode != NULL) { + new_nlink = BPF_CORE_READ(new_dentry, d_inode, i_nlink); + } + args.nlink = old_nlink; + // From this point on we need to handle inode tracking. // // The result will be a combination of whether we are already tracking @@ -238,7 +303,8 @@ 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); + // Note: We don't remove from kernel map here - userspace will decide + // based on whether other monitored hardlinks exist } break; @@ -250,7 +316,7 @@ 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); + // Note: We don't remove from kernel map here - userspace will decide } break; @@ -264,17 +330,19 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, } } else if (!inode_is_empty(&args.inode)) { // 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); + // monitored parent but the path itself is not monitored + // Note: We don't remove from kernel map here - userspace will decide + // based on whether other monitored hardlinks exist } break; case MONITORED_BY_INODE: // 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); + // being tracked and is about to be overwritten + // Only remove if this is the last link to the overwritten inode + if (new_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. @@ -283,7 +351,7 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, break; } - submit_rename_event(&args, old_path->path, &old_inode, old_monitored); + submit_rename_event(&args, old_path->path, &old_inode, old_monitored, old_nlink); return 0; error: @@ -375,6 +443,7 @@ int BPF_PROG(trace_d_instantiate, struct dentry* dentry, struct inode* inode) { args.monitored = mkdir_ctx->monitored; args.inode = inode_to_key(inode); + args.nlink = BPF_CORE_READ(inode, i_nlink); if (inode_add(&args.inode) == 0) { args.metrics->added++; @@ -481,6 +550,10 @@ int BPF_PROG(trace_path_rmdir, struct path* dir, struct dentry* dentry) { return 0; } + // Directories should have nlink = 2 when empty (. and ..) + // After rmdir, it will be removed, so we always remove from map + args.nlink = BPF_CORE_READ(dentry, d_inode, i_nlink); + submit_rmdir_event(&args); return 0; } diff --git a/fact-ebpf/src/bpf/types.h b/fact-ebpf/src/bpf/types.h index 80b94d22..c7e8730c 100644 --- a/fact-ebpf/src/bpf/types.h +++ b/fact-ebpf/src/bpf/types.h @@ -143,6 +143,7 @@ struct event_t { char filename[PATH_MAX]; inode_key_t inode; monitored_t monitored; + unsigned int nlink; } from; // Used by events that have two paths (like rename or move_mount). struct { char name[XATTR_NAME_MAX_LEN]; diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 3d7b629c..3915aa49 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -102,6 +102,7 @@ impl Event { inode: Default::default(), parent_inode: Default::default(), monitored: Default::default(), + nlink: Default::default(), }; let file = match data { EventTestData::Creation => FileData::Creation(inner), @@ -373,12 +374,22 @@ impl TryFrom<&event_t> for Event { fn try_from(value: &event_t) -> Result { let process = Process::try_from(value.process)?; let timestamp = host_info::get_boot_time() + value.timestamp; + // nlink is only available in the union for rename/move_mount events (from.nlink) + // For other events, we use the default value + let nlink = match value.type_ { + file_activity_type_t::FILE_ACTIVITY_RENAME + | file_activity_type_t::FILE_ACTIVITY_MOVE_MOUNT => unsafe { + value.__bindgen_anon_1.from.nlink + }, + _ => Default::default(), + }; let file = FileData::new( value.type_, value.filename, value.inode, value.parent_inode, value.monitored, + nlink, value.__bindgen_anon_1, )?; @@ -456,17 +467,19 @@ impl FileData { inode: inode_key_t, parent_inode: inode_key_t, monitored: monitored_t, + nlink: u32, extra_data: fact_ebpf::event_t__bindgen_ty_1, ) -> anyhow::Result { fn read_from_data(extra_data: fact_ebpf::event_t__bindgen_ty_1) -> BaseFileData { let filename = unsafe { extra_data.from.filename }; let inode = unsafe { extra_data.from.inode }; let monitored = unsafe { extra_data.from.monitored }; + let nlink = unsafe { extra_data.from.nlink }; - BaseFileData::new(filename, inode, Default::default(), monitored) + BaseFileData::new(filename, inode, Default::default(), monitored, nlink) } - let inner = BaseFileData::new(filename, inode, parent_inode, monitored); + let inner = BaseFileData::new(filename, inode, parent_inode, monitored, nlink); let file = match event_type { file_activity_type_t::FILE_ACTIVITY_OPEN => FileData::Open(inner), file_activity_type_t::FILE_ACTIVITY_CREATION => FileData::Creation(inner), @@ -693,6 +706,7 @@ pub struct BaseFileData { inode: inode_key_t, parent_inode: inode_key_t, monitored: monitored_t, + pub nlink: u32, } impl BaseFileData { @@ -701,6 +715,7 @@ impl BaseFileData { inode: inode_key_t, parent_inode: inode_key_t, monitored: monitored_t, + nlink: u32, ) -> Self { BaseFileData { filename: sanitize_d_path(&filename), @@ -708,6 +723,7 @@ impl BaseFileData { inode, parent_inode, monitored, + nlink, } } } diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index b5252601..3c3ba804 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -21,6 +21,7 @@ use std::{ cell::RefCell, collections::HashMap, + collections::HashSet, fs::Metadata, io, ops::{Deref, DerefMut}, @@ -52,7 +53,7 @@ use crate::{ metrics::host_scanner::{HostScannerMetrics, ScanLabels}, }; -struct InodeMap(HashMap); +struct InodeMap(HashMap>); impl InodeMap { fn new() -> Self { @@ -61,7 +62,7 @@ impl InodeMap { } impl Deref for InodeMap { - type Target = HashMap; + type Target = HashMap>; fn deref(&self) -> &Self::Target { &self.0 @@ -165,15 +166,20 @@ impl HostScanner { // * 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() - { - true - } else { + self.inode_map.borrow_mut().retain(|inode, paths| { + // Remove paths that no longer exist or are not monitored + paths.retain(|path| { + config.iter().any(|prefix| path.starts_with(prefix)) + && host_info::prepend_host_mount(path).exists() + }); + + // If no monitored paths remain, remove the inode entirely + if paths.is_empty() { let _ = self.kernel_inode_map.borrow_mut().remove(inode); self.metrics.scan_inc(ScanLabels::InodeRemoved); false + } else { + true } }); @@ -248,17 +254,17 @@ impl HostScanner { fn update_entry_with_inode(&self, inode: inode_key_t, path: PathBuf) -> anyhow::Result<()> { let mut inode_map = self.inode_map.borrow_mut(); match inode_map.get_mut(&inode) { - Some(p) => { + Some(paths) => { // inode is already tracked. - if path != *p { - *p = path; + if !paths.contains(&path) { self.metrics.scan_inc(ScanLabels::FileUpdated); + paths.insert(path); } return Ok(()); } None => { self.metrics.scan_inc(ScanLabels::FileUpdated); - inode_map.insert(inode, path.clone()); + inode_map.insert(inode, HashSet::from([path.clone()])); } }; @@ -282,7 +288,31 @@ You can increase this limit with: fn get_host_path(&self, inode: Option<&inode_key_t>) -> Option { // The path here needs to be cloned because we won't keep the // inode_map borrow long enough. - self.inode_map.borrow().get(inode?).cloned() + // For hardlinks, return any one of the monitored paths + // TODO: Consider returning the path that matches the event filename + self.inode_map + .borrow() + .get(inode?) + .and_then(|paths| paths.iter().next().cloned()) + } + + /// Get the specific host path that matches the given filename, if it exists + /// Otherwise return any monitored path for this inode + fn get_matching_host_path( + &self, + inode: Option<&inode_key_t>, + filename: &Path, + ) -> Option { + let inode_map = self.inode_map.borrow(); + let paths = inode_map.get(inode?)?; + + // First try to find an exact match + if paths.contains(filename) { + return Some(filename.to_path_buf()); + } + + // Otherwise return any monitored path + paths.iter().next().cloned() } /// Handle file creation events by adding new inodes to the map. @@ -290,22 +320,38 @@ You can increase this limit with: /// We use the parent inode provided by the eBPF code /// to look up the parent directory's host path, then construct the full /// path by appending the new file's name. + /// + /// For hardlinks, the inode already exists but we need to add the new path. fn handle_creation_event(&self, event: &Event) -> anyhow::Result<()> { let inode = event.get_inode(); let parent_inode = event.get_parent_inode(); - if self.get_host_path(Some(inode)).is_some() || parent_inode.empty() { + + // For hardlinks, inode is already tracked but we need to add this new path + // Check if this specific path already exists in our tracking + let filename = event.get_filename(); + let already_has_this_path = self + .inode_map + .borrow() + .get(inode) + .is_some_and(|paths| paths.contains(filename)); + + if already_has_this_path { + return Ok(()); + } + + if parent_inode.empty() { return Ok(()); } - if let Some(filename) = event.get_filename().file_name() + if let Some(filename_component) = filename.file_name() && let Some(parent_host_path) = self.get_host_path(Some(parent_inode)) { - let host_path = parent_host_path.join(filename); + let host_path = parent_host_path.join(filename_component); self.update_entry_with_inode(*inode, host_path) .with_context(|| { format!( "Failed to add creation event entry for {}", - filename.display() + filename_component.display() ) })?; } @@ -313,53 +359,77 @@ You can increase this limit with: Ok(()) } - /// Handle unlink events by removing the inode from the inode->path map. + /// Handle unlink events by removing the specific path from the inode->paths map. /// - /// The probe already cleared the kernel inode map. + /// With hardlinks, we only remove the specific path being unlinked. + /// The kernel already removed from kernel inode map if nlink reached 0. fn handle_unlink_event(&self, event: &Event) { let inode = event.get_inode(); + let filename = event.get_filename(); + let mut inode_map = self.inode_map.borrow_mut(); - if self.inode_map.borrow_mut().remove(inode).is_some() { - self.metrics.scan_inc(ScanLabels::InodeRemoved); + if let Some(paths) = inode_map.get_mut(inode) { + // Remove the specific path being unlinked + paths.remove(filename); + + // If no monitored paths remain for this inode, remove the inode entry + // The kernel already removed it from kernel map if nlink==0 + if paths.is_empty() { + inode_map.remove(inode); + self.metrics.scan_inc(ScanLabels::InodeRemoved); + } } self.metrics.scan_inc(ScanLabels::FileRemoved); } fn handle_rename_event(&self, event: &mut Event) { + let old_filename = event.get_old_filename().cloned(); + let new_filename = event.get_filename().clone(); + match event.get_monitored() { monitored_t::MONITORED_BY_INODE => { - // This condition means a file is being renamed and taking the - // 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. + // Renaming onto an existing tracked file. + // The target inode (being overwritten) was already removed from kernel map if needed. + // For the moving inode, update its path in our tracking. let mut inode_map = self.inode_map.borrow_mut(); - let Some(path) = inode_map.remove(event.get_inode()) else { - warn!("Old path was not found for inode tracked event"); - return; - }; + let Some(old_inode) = event.get_old_inode() else { unreachable!("old inode not found for rename event"); }; - inode_map.insert(*old_inode, path); + + // Remove the old path and add the new path for the moving inode + if let Some(paths) = inode_map.get_mut(old_inode) { + if let Some(ref old_fname) = old_filename { + paths.remove(old_fname); + } + paths.insert(new_filename); + } } monitored_t::NOT_MONITORED if event.get_old_monitored() == Some(monitored_t::MONITORED_BY_INODE) => { - // We are landing on a path that is not tracked at all, remove - // the entries for the old path from the map - let Some(old_host_path) = event.get_old_host_path() else { - warn!("Rename event did not have old host path for inode tracked item"); + // Renaming from a monitored path to an unmonitored path + let Some(old_inode) = event.get_old_inode() else { return; }; - self.inode_map.borrow_mut().retain(|inode, path| { - if !path.starts_with(old_host_path) { - return true; - } + let Some(ref old_fname) = old_filename else { + return; + }; + + let mut inode_map = self.inode_map.borrow_mut(); - let _ = self.kernel_inode_map.borrow_mut().remove(inode); - false - }); + if let Some(paths) = inode_map.get_mut(old_inode) { + // Remove this specific path from tracking + paths.remove(old_fname); + + // If no monitored paths remain, remove the inode + // Note: kernel may have already removed it if nlink==0 + if paths.is_empty() { + inode_map.remove(old_inode); + // Don't call kernel remove here - kernel handles it based on nlink + } + } } monitored_t::NOT_MONITORED => { // The new path is not monitored and the old path is most likely @@ -367,81 +437,93 @@ 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"), - ); + // is not. This is renaming onto an existing file that will be overwritten. + // The overwritten inode was already handled by kernel. + + // For the moving file, update its path + let Some(old_inode) = event.get_old_inode() else { + return; + }; + + let mut inode_map = self.inode_map.borrow_mut(); + if let Some(paths) = inode_map.get_mut(old_inode) { + if let Some(ref old_fname) = old_filename { + paths.remove(old_fname); + } + + // Check if new path should be tracked + if self.paths_globset.is_match(&new_filename) { + paths.insert(new_filename.clone()); + event.set_host_path(new_filename); + } else if paths.is_empty() { + // No monitored paths left + inode_map.remove(old_inode); + } + } } monitored_t::MONITORED_BY_PARENT if event.get_old_monitored() == Some(monitored_t::MONITORED_BY_INODE) => { - // The target is monitored by parent and we are landing on a - // path that didn't hold anything, we need to figure out the - // host path and check if we should track it. + // Renaming into a monitored directory let mut inode_map = self.inode_map.borrow_mut(); - let Some(new_host_parent) = inode_map.get(event.get_parent_inode()) else { + + // Get the parent path to construct the new full path + let Some(parent_paths) = inode_map.get(event.get_parent_inode()) else { warn!("Failed to get parent host path"); return; }; - let Some(filename) = event.get_filename().file_name() else { - warn!("Failed to get last component from event: {event:#?}"); + let Some(parent_path) = parent_paths.iter().next() else { return; }; - let new_host_path = new_host_parent.join(filename); - let Some(old_host_path) = event.get_old_host_path() else { - unreachable!("Rename event did not have an old host path"); + + let Some(filename_component) = new_filename.file_name() else { + warn!("Failed to get last component from event"); + return; }; + let new_host_path = parent_path.join(filename_component); if self.paths_globset.is_match(&new_host_path) { // New path needs to be tracked. - // Move all entries for the old host path to the new one - for path in inode_map.values_mut() { - if let Ok(suffix) = path.strip_prefix(old_host_path) { - if suffix == Path::new("") { - *path = new_host_path.clone(); - } else { - *path = new_host_path.join(suffix); - } + // Update the moving inode's paths to point to the new location + let moving_inode = event.get_inode(); + if let Some(paths) = inode_map.get_mut(moving_inode) { + if let Some(ref old_fname) = old_filename { + paths.remove(old_fname); } + paths.insert(new_host_path.clone()); } // Add the new host path to the event 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; + // New path not tracked, remove old path + let moving_inode = event.get_inode(); + if let Some(paths) = inode_map.get_mut(moving_inode) { + if let Some(ref old_fname) = old_filename { + paths.remove(old_fname); } - if let Err(e) = self.kernel_inode_map.borrow_mut().remove(inode) { - warn!("Failed to remove inode kernel entry: {e:?}"); + if paths.is_empty() { + inode_map.remove(moving_inode); } - false - }); + } } } monitored_t::MONITORED_BY_PARENT => { - // In this case, the target location might be monitored, but we - // don't have any information of the host path for the old path, - // best we can do is attempt to scan the file system and fix the - // inode maps that way. + // Target location might be monitored, but we don't have path info + // Scan to fix the inode maps if let Err(e) = self.scan() { warn!("Scan failed: {e:?}"); } - // Attempt to update the host path with the old inode + // Try to get a path for the old inode if let Some(old_inode) = event.get_old_inode() - && let Some(path) = self.inode_map.borrow().get(old_inode) + && let Some(path) = self.get_host_path(Some(old_inode)) { - event.set_host_path(path.clone()); + event.set_host_path(path); } } monitored_t::MONITORED_BY_PATH => { - // Nothing to do here, having one side of the rename monitored - // by path means at best the other side is also monitored by - // path, no inode tracking is involved. + // Nothing to do here, no inode tracking involved } _ => unreachable!("Invalid monitored value"), } @@ -517,12 +599,19 @@ You can increase this limit with: continue; } - if let Some(host_path) = self.get_host_path(Some(event.get_inode())) { + // Handle mount events and move on. + if event.is_mount_related() { + self.handle_mount_event(); + continue; + } + + // For hardlinks, try to match the exact path from the event + if let Some(host_path) = self.get_matching_host_path(Some(event.get_inode()), event.get_filename()) { self.metrics.scan_inc(ScanLabels::InodeHit); event.set_host_path(host_path); } - if let Some(host_path) = self.get_host_path(event.get_old_inode()) { + if let Some(old_filename) = event.get_old_filename() && let Some(host_path) = self.get_matching_host_path(event.get_old_inode(), old_filename) { self.metrics.scan_inc(ScanLabels::InodeHit); event.set_old_host_path(host_path); } @@ -545,8 +634,14 @@ You can increase this limit with: // path is not to be monitored, so we ignore the // event and attempt to remove the inode from the // maps to prevent it from sending more events. - self.inode_map.borrow_mut().remove(event.get_inode()); - let _ = self.kernel_inode_map.borrow_mut().remove(event.get_inode()); + let mut inode_map = self.inode_map.borrow_mut(); + if let Some(paths) = inode_map.get_mut(event.get_inode()) { + paths.remove(event.get_filename()); + if paths.is_empty() { + inode_map.remove(event.get_inode()); + // Kernel handles removal based on nlink + } + } self.metrics.events.ignored(); continue; } diff --git a/tests/test_file_open.py b/tests/test_file_open.py index b1ef5719..e6164d21 100644 --- a/tests/test_file_open.py +++ b/tests/test_file_open.py @@ -274,3 +274,99 @@ def test_unmonitored_mounted_dir( ) server.wait_events([event]) + + +def test_open_via_hardlink(monitored_dir, server): + """ + Tests opening a file through a hardlink path when the original exists. + The event should report the hardlink path as host_path. + + 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) + + # Open through hardlink + with open(hardlink, 'r') as f: + f.read() + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=hardlink, + host_path=hardlink, + ), + Event( + process=process, + event_type=EventType.OPEN, + file=hardlink, + host_path=hardlink, + ), + ] + + server.wait_events(events) + + +def test_open_via_original_with_hardlink(monitored_dir, server): + """ + Tests opening a file through its original path when a hardlink exists. + Both paths should be tracked for the same inode. + + 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) + + # Open through original path + with open(original, 'r') as f: + f.read() + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=hardlink, + host_path=hardlink, + ), + Event( + process=process, + event_type=EventType.OPEN, + file=original, + host_path=original, + ), + ] + + server.wait_events(events) diff --git a/tests/test_path_chmod.py b/tests/test_path_chmod.py index d1d5dd7c..052b2629 100644 --- a/tests/test_path_chmod.py +++ b/tests/test_path_chmod.py @@ -336,3 +336,52 @@ def test_unmonitored_mounted_dir( ) server.wait_events([event]) + + +def test_chmod_hardlink(monitored_dir, server): + """ + Tests chmod on a file with hardlinks. The chmod affects the inode, + but the event should report the specific path being used. + + Args: + monitored_dir: Temporary directory path for creating test files. + server: The server instance to communicate with. + """ + process = Process.from_proc() + mode = 0o600 + + # 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) + + # chmod through hardlink path + os.chmod(hardlink, mode) + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=hardlink, + host_path=hardlink, + ), + Event( + process=process, + event_type=EventType.PERMISSION, + file=hardlink, + host_path=hardlink, + mode=mode, + ), + ] + + server.wait_events(events) diff --git a/tests/test_path_chown.py b/tests/test_path_chown.py index 27fd25c0..531e88de 100644 --- a/tests/test_path_chown.py +++ b/tests/test_path_chown.py @@ -263,3 +263,72 @@ def test_no_change( ] server.wait_events(events) + + +def test_chown_hardlink(test_container, server): + """ + Tests chown on a file with hardlinks. The chown affects the inode, + but the event should report the specific path being used. + + Args: + test_container: A container for running commands in. + server: The server instance to communicate with. + """ + # File Under Test + original = '/container-dir/original.txt' + hardlink = '/container-dir/hardlink.txt' + + touch_cmd = f'touch {original}' + link_cmd = f'ln {original} {hardlink}' + chown_cmd = f'chown {TEST_UID}:{TEST_GID} {hardlink}' + + # Create file and hardlink + test_container.exec_run(touch_cmd) + test_container.exec_run(link_cmd) + + # chown through hardlink path + test_container.exec_run(chown_cmd) + + touch = Process.in_container( + exe_path='/usr/bin/touch', + args=touch_cmd, + name='touch', + container_id=test_container.id[:12], + ) + ln = Process.in_container( + exe_path='/usr/bin/ln', + args=link_cmd, + name='ln', + container_id=test_container.id[:12], + ) + chown = Process.in_container( + exe_path='/usr/bin/chown', + args=chown_cmd, + name='chown', + container_id=test_container.id[:12], + ) + + events = [ + Event( + process=touch, + event_type=EventType.CREATION, + file=original, + host_path='', + ), + Event( + process=ln, + event_type=EventType.CREATION, + file=hardlink, + host_path='', + ), + Event( + process=chown, + event_type=EventType.OWNERSHIP, + file=hardlink, + host_path='', + owner_uid=TEST_UID, + owner_gid=TEST_GID, + ), + ] + + server.wait_events(events) diff --git a/tests/test_path_link.py b/tests/test_path_link.py new file mode 100644 index 00000000..6434418e --- /dev/null +++ b/tests/test_path_link.py @@ -0,0 +1,385 @@ +import os + +import pytest + +from event import Event, EventType, Process + + +def test_link(monitored_dir, server): + """ + 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=hardlink, + ), + ] + + server.wait_events(events) + + +def test_multiple_hardlinks(monitored_dir, server): + """ + 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=link1, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=link2, + host_path=link2, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=link3, + host_path=link3, + ), + ] + + server.wait_events(events) + + +def test_link_in_subdirectory(monitored_dir, server): + """ + Tests hardlinks in different subdirectories of the monitored path. + + Args: + monitored_dir: Temporary directory path for creating test files. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create subdirectories + dir1 = os.path.join(monitored_dir, 'dir1') + dir2 = os.path.join(monitored_dir, 'dir2') + os.makedirs(dir1) + os.makedirs(dir2) + + # Create original file in dir1 + original = os.path.join(dir1, 'file.txt') + with open(original, 'w') as f: + f.write('test content') + + # Create hardlink in dir2 + hardlink = os.path.join(dir2, 'file.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=hardlink, + ), + ] + + server.wait_events(events) + + +def test_ignored(monitored_dir, ignored_dir, server): + """ + Tests that link events creating hardlinks in ignored directories + are not captured by the server. + + 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) + + # Only the original creation and monitored hardlink should be reported + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=monitored_link, + host_path=monitored_link, + ), + ] + + server.wait_events(events) + + +def test_link_from_ignored_to_monitored(monitored_dir, ignored_dir, server): + """ + 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, ignored_dir, server): + """ + Tests accessing a file via an unmonitored hardlink when a monitored + hardlink exists. The inode is tracked, so access should generate an + event, but what path should be reported? + + 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, 'r') as f: + f.read() + + # Should we get an event? If so, what should host_path be? + # The inode is tracked because monitored path exists. + # Access via ignored path should either: + # 1. Report the actual ignored path (probably empty host_path) + # 2. Report the monitored path that is tracked + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored, + host_path=monitored, + ), + # What event do we expect here? This exposes the implementation question. + Event( + process=process, + event_type=EventType.OPEN, + file=ignored_link, + host_path=monitored, + ), # Or host_path=''? + ] + + server.wait_events(events) + + +def test_unlink_monitored_hardlink_with_ignored_remaining( + monitored_dir, ignored_dir, server +): + """ + Tests unlinking the monitored hardlink when an unmonitored hardlink + still exists. Should inode tracking be removed? + + 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) + + # The inode should be removed from tracking even though + # the ignored hardlink still exists (file not deleted from filesystem) + # Verify this by trying to access via ignored link - should not generate event + with open(ignored_link, 'r') 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.UNLINK, + file=monitored, + host_path=monitored, + ), + ] + + server.wait_events(events) + + +def test_multiple_monitored_and_ignored_hardlinks( + monitored_dir, ignored_dir, server +): + """ + Tests complex scenario with multiple hardlinks in both monitored + and ignored paths. + + 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 + monitored1 = os.path.join(monitored_dir, 'file1.txt') + with open(monitored1, 'w') as f: + f.write('test content') + + # Create multiple hardlinks + monitored2 = os.path.join(monitored_dir, 'file2.txt') + ignored1 = os.path.join(ignored_dir, 'file1.txt') + ignored2 = os.path.join(ignored_dir, 'file2.txt') + + os.link(monitored1, monitored2) + os.link(monitored1, ignored1) + os.link(monitored1, ignored2) + + # Unlink one monitored path + os.unlink(monitored1) + + # Access via remaining monitored path should still work + with open(monitored2, 'r') as f: + f.read() + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored1, + host_path=monitored1, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=monitored2, + host_path=monitored2, + ), + Event( + process=process, + event_type=EventType.UNLINK, + file=monitored1, + host_path=monitored1, + ), + Event( + process=process, + event_type=EventType.OPEN, + file=monitored2, + host_path=monitored2, + ), + ] + + server.wait_events(events) diff --git a/tests/test_path_rename.py b/tests/test_path_rename.py index 00987509..148cdb6e 100644 --- a/tests/test_path_rename.py +++ b/tests/test_path_rename.py @@ -535,3 +535,238 @@ def test_cross_mountpoints( ) server.wait_events(events) + + +def test_rename_hardlink(monitored_dir, server): + """ + Tests renaming a hardlink. Both the original and renamed paths + should continue to be tracked. + + 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) + + # Rename the hardlink + renamed = os.path.join(monitored_dir, 'renamed.txt') + os.rename(hardlink, renamed) + + # Access through both remaining paths + with open(original, 'r') as f: + f.read() + with open(renamed, 'r') as f: + f.read() + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=hardlink, + host_path=hardlink, + ), + Event( + process=process, + event_type=EventType.RENAME, + file=renamed, + host_path=renamed, + old_file=hardlink, + old_host_path=hardlink, + ), + Event( + process=process, + event_type=EventType.OPEN, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.OPEN, + file=renamed, + host_path=renamed, + ), + ] + + server.wait_events(events) + + +def test_rename_monitored_to_ignored_with_hardlink( + monitored_dir, ignored_dir, server +): + """ + Tests renaming a monitored file to an ignored path when another + monitored hardlink exists. The inode should remain 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 file in monitored directory + monitored1 = os.path.join(monitored_dir, 'file1.txt') + with open(monitored1, 'w') as f: + f.write('test content') + + # Create hardlink in monitored directory + monitored2 = os.path.join(monitored_dir, 'file2.txt') + os.link(monitored1, monitored2) + + # Rename first file to ignored path + ignored = os.path.join(ignored_dir, 'file.txt') + os.rename(monitored1, ignored) + + # Access via remaining monitored hardlink should still work + with open(monitored2, 'r') as f: + f.read() + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored1, + host_path=monitored1, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=monitored2, + host_path=monitored2, + ), + Event( + process=process, + event_type=EventType.RENAME, + file=ignored, + host_path='', + old_file=monitored1, + old_host_path=monitored1, + ), + Event( + process=process, + event_type=EventType.OPEN, + file=monitored2, + host_path=monitored2, + ), + ] + + server.wait_events(events) + + +def test_rename_ignored_to_monitored_with_hardlink( + monitored_dir, ignored_dir, server +): + """ + Tests renaming from ignored to monitored path when an ignored + hardlink 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 ignored directory + ignored1 = os.path.join(ignored_dir, 'file1.txt') + with open(ignored1, 'w') as f: + f.write('test content') + + # Create hardlink in ignored directory + ignored2 = os.path.join(ignored_dir, 'file2.txt') + os.link(ignored1, ignored2) + + # Rename one to monitored path + monitored = os.path.join(monitored_dir, 'file.txt') + os.rename(ignored1, monitored) + + # Now the inode is tracked, access via either path + with open(monitored, 'r') as f: + f.read() + + events = [ + Event( + process=process, + event_type=EventType.RENAME, + file=monitored, + host_path=monitored, + old_file=ignored1, + old_host_path='', + ), + Event( + process=process, + event_type=EventType.OPEN, + file=monitored, + host_path=monitored, + ), + ] + + server.wait_events(events) + + +def test_rename_last_monitored_hardlink_to_ignored( + monitored_dir, ignored_dir, server +): + """ + Tests renaming the last monitored hardlink to an ignored path. + The inode should be removed from tracking even though the file + (and ignored hardlinks) still exist. + + 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) + + # Rename the ONLY monitored path to another ignored path + ignored2 = os.path.join(ignored_dir, 'file.txt') + os.rename(monitored, ignored2) + + # Access via ignored hardlink should not generate event + with open(ignored_link, 'r') as f: + f.read() + + # Only creation and rename events expected + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored, + host_path=monitored, + ), + Event( + process=process, + event_type=EventType.RENAME, + file=ignored2, + host_path='', + old_file=monitored, + old_host_path=monitored, + ), + ] + + server.wait_events(events) diff --git a/tests/test_path_unlink.py b/tests/test_path_unlink.py index cce47557..713f7257 100644 --- a/tests/test_path_unlink.py +++ b/tests/test_path_unlink.py @@ -302,3 +302,245 @@ def test_unmonitored_mounted_dir( ) server.wait_events([event]) + + +def test_unlink_with_hardlink(monitored_dir, server): + """ + Tests unlinking one hardlink when multiple exist. The inode should + remain tracked as long as at least one hardlink exists. + + 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 hardlinks + link1 = os.path.join(monitored_dir, 'link1.txt') + link2 = os.path.join(monitored_dir, 'link2.txt') + os.link(original, link1) + os.link(original, link2) + + # Unlink one hardlink + os.unlink(link1) + + # Access through remaining hardlink should still work + with open(link2, 'r') as f: + f.read() + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=link1, + host_path=link1, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=link2, + host_path=link2, + ), + Event( + process=process, + event_type=EventType.UNLINK, + file=link1, + host_path=link1, + ), + Event( + process=process, + event_type=EventType.OPEN, + file=link2, + host_path=link2, + ), + ] + + server.wait_events(events) + + +def test_unlink_all_hardlinks(monitored_dir, server): + """ + Tests unlinking all hardlinks. The inode should be removed from + tracking only when the last hardlink is removed. + + 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) + + # Unlink both + os.unlink(original) + os.unlink(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=hardlink, + ), + Event( + process=process, + event_type=EventType.UNLINK, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.UNLINK, + file=hardlink, + host_path=hardlink, + ), + ] + + server.wait_events(events) + + +def test_unlink_last_monitored_hardlink(monitored_dir, ignored_dir, server): + """ + Tests unlinking the last monitored hardlink when ignored hardlinks + still exist. The inode should be removed from tracking even though + the file itself is not deleted from the filesystem. + + 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) + + # Verify file has 2 links + stat = os.stat(monitored) + assert stat.st_nlink == 2 + + # Unlink the monitored path + os.unlink(monitored) + + # File still exists via ignored hardlink, but should not be tracked + assert os.path.exists(ignored_link) + stat = os.stat(ignored_link) + assert stat.st_nlink == 1 + + # Access via ignored link should not generate event + with open(ignored_link, 'r') as f: + f.read() + + # Only creation and unlink events expected + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored, + host_path=monitored, + ), + Event( + process=process, + event_type=EventType.UNLINK, + file=monitored, + host_path=monitored, + ), + ] + + server.wait_events(events) + + +def test_unlink_one_of_multiple_monitored_hardlinks( + monitored_dir, ignored_dir, server +): + """ + Tests unlinking one monitored hardlink when multiple monitored + hardlinks exist (plus ignored ones). The inode should remain 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 file in monitored directory + monitored1 = os.path.join(monitored_dir, 'file1.txt') + with open(monitored1, 'w') as f: + f.write('test content') + + # Create another monitored hardlink and an ignored hardlink + monitored2 = os.path.join(monitored_dir, 'file2.txt') + ignored_link = os.path.join(ignored_dir, 'link.txt') + os.link(monitored1, monitored2) + os.link(monitored1, ignored_link) + + # Verify we have 3 links + stat = os.stat(monitored1) + assert stat.st_nlink == 3 + + # Unlink one monitored path + os.unlink(monitored1) + + # Inode should still be tracked - access via other monitored path + with open(monitored2, 'r') as f: + f.read() + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored1, + host_path=monitored1, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=monitored2, + host_path=monitored2, + ), + Event( + process=process, + event_type=EventType.UNLINK, + file=monitored1, + host_path=monitored1, + ), + Event( + process=process, + event_type=EventType.OPEN, + file=monitored2, + host_path=monitored2, + ), + ] + + server.wait_events(events)