From f2476eeb24c3cc4c1add0908bdecc49e93021385 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Wed, 18 Feb 2026 18:24:58 +0100 Subject: [PATCH] ROX-30256: track files and directories being renamed Implement the path_rename LSM hook in order to capture and generate rename events. The implementation is fairly basic and does nothing with regards to inode tracking other than checking if either of the inodes involved are tracked. With this change we get better visibility into commands that use renaming as a means for editing a file, like vi and sed. --- fact-ebpf/src/bpf/bound_path.h | 60 ++++++++- fact-ebpf/src/bpf/events.h | 18 +++ fact-ebpf/src/bpf/inode.h | 2 +- fact-ebpf/src/bpf/main.c | 76 +++++++---- fact-ebpf/src/bpf/maps.h | 12 +- fact-ebpf/src/bpf/types.h | 6 + fact-ebpf/src/lib.rs | 1 + fact/src/bpf/mod.rs | 25 +++- fact/src/event/mod.rs | 81 +++++++++++- fact/src/host_scanner.rs | 10 +- fact/src/metrics/kernel_metrics.rs | 9 ++ tests/conftest.py | 6 +- tests/event.py | 40 ++++-- tests/test_editors/test_nvim.py | 10 +- tests/test_editors/test_sed.py | 4 + tests/test_editors/test_vi.py | 10 +- tests/test_editors/test_vim.py | 10 +- tests/test_path_rename.py | 198 +++++++++++++++++++++++++++++ 18 files changed, 523 insertions(+), 55 deletions(-) create mode 100644 tests/test_path_rename.py diff --git a/fact-ebpf/src/bpf/bound_path.h b/fact-ebpf/src/bpf/bound_path.h index 0cbe7ebb..80024050 100644 --- a/fact-ebpf/src/bpf/bound_path.h +++ b/fact-ebpf/src/bpf/bound_path.h @@ -19,8 +19,8 @@ __always_inline static void path_write_char(char* p, unsigned int offset, char c *path_safe_access(p, offset) = c; } -__always_inline static struct bound_path_t* _path_read(struct path* path, bool use_bpf_d_path) { - struct bound_path_t* bound_path = get_bound_path(); +__always_inline static struct bound_path_t* _path_read(struct path* path, bound_path_buffer_t key, bool use_bpf_d_path) { + struct bound_path_t* bound_path = get_bound_path(key); if (bound_path == NULL) { return NULL; } @@ -37,11 +37,19 @@ __always_inline static struct bound_path_t* _path_read(struct path* path, bool u } __always_inline static struct bound_path_t* path_read(struct path* path) { - return _path_read(path, true); + return _path_read(path, BOUND_PATH_MAIN, true); } __always_inline static struct bound_path_t* path_read_no_d_path(struct path* path) { - return _path_read(path, false); + return _path_read(path, BOUND_PATH_MAIN, false); +} + +__always_inline static struct bound_path_t* path_read_alt(struct path* path) { + return _path_read(path, BOUND_PATH_ALTERNATE, true); +} + +__always_inline static struct bound_path_t* path_read_alt_no_d_path(struct path* path) { + return _path_read(path, BOUND_PATH_ALTERNATE, false); } enum path_append_status_t { @@ -69,3 +77,47 @@ __always_inline static enum path_append_status_t path_append_dentry(struct bound return 0; } + +__always_inline static struct bound_path_t* _path_read_append_d_entry(struct path* dir, struct dentry* dentry, bound_path_buffer_t key) { + struct bound_path_t* path = _path_read(dir, key, path_hooks_support_bpf_d_path); + + if (path == NULL) { + bpf_printk("Failed to read path"); + return NULL; + } + path_write_char(path->path, path->len - 1, '/'); + + switch (path_append_dentry(path, dentry)) { + case PATH_APPEND_SUCCESS: + break; + case PATH_APPEND_INVALID_LENGTH: + bpf_printk("Invalid path length: %u", path->len); + return NULL; + case PATH_APPEND_READ_ERROR: + bpf_printk("Failed to read final path component"); + return NULL; + } + return path; +} + +/** + * Read the path and append the supplied dentry. + * + * A very common pattern in the kernel is to provide a struct path to a + * directory and a dentry to an element in said directory, this helper + * provides a short way of resolving the full path in one call. + */ +__always_inline static struct bound_path_t* path_read_append_d_entry(struct path* dir, struct dentry* dentry) { + return _path_read_append_d_entry(dir, dentry, BOUND_PATH_MAIN); +} + +/** + * Read the path and append the supplied dentry. + * + * This works essentially the same as path_read_append_d_entry, but does + * so in an alternate buffer. Useful for operations that take more than + * one path, like path_rename. + */ +__always_inline static struct bound_path_t* path_read_alt_append_d_entry(struct path* dir, struct dentry* dentry) { + return _path_read_append_d_entry(dir, dentry, BOUND_PATH_ALTERNATE); +} diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index 9cc6baae..be1237c6 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -95,3 +95,21 @@ __always_inline static void submit_ownership_event(struct metrics_by_hook_t* m, __submit_event(event, m, FILE_ACTIVITY_CHOWN, filename, inode, use_bpf_d_path); } + +__always_inline static void submit_rename_event(struct metrics_by_hook_t* m, + const char new_filename[PATH_MAX], + const char old_filename[PATH_MAX], + inode_key_t* new_inode, + inode_key_t* old_inode, + bool use_bpf_d_path) { + struct event_t* event = bpf_ringbuf_reserve(&rb, sizeof(struct event_t), 0); + if (event == NULL) { + m->ringbuffer_full++; + return; + } + + bpf_probe_read_str(event->rename.old_filename, PATH_MAX, old_filename); + inode_copy_or_reset(&event->rename.old_inode, old_inode); + + __submit_event(event, m, FILE_ACTIVITY_RENAME, new_filename, new_inode, use_bpf_d_path); +} diff --git a/fact-ebpf/src/bpf/inode.h b/fact-ebpf/src/bpf/inode.h index 842f1251..4e9a26dc 100644 --- a/fact-ebpf/src/bpf/inode.h +++ b/fact-ebpf/src/bpf/inode.h @@ -85,7 +85,7 @@ typedef enum inode_monitored_t { * check if the parent of the provided inode is monitored and provide * different results for handling more complicated scenarios. */ -__always_inline static inode_monitored_t inode_is_monitored(const inode_value_t* inode) { +__always_inline static inode_monitored_t inode_is_monitored(const inode_value_t* volatile inode) { if (inode != NULL) { return MONITORED; } diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 3b448128..39a0622f 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -74,28 +74,11 @@ int BPF_PROG(trace_path_unlink, struct path* dir, struct dentry* dentry) { m->path_unlink.total++; - struct bound_path_t* path = NULL; - if (path_hooks_support_bpf_d_path) { - path = path_read(dir); - } else { - path = path_read_no_d_path(dir); - } - + struct bound_path_t* path = path_read_append_d_entry(dir, dentry); if (path == NULL) { bpf_printk("Failed to read path"); - goto error; - } - path_write_char(path->path, path->len - 1, '/'); - - switch (path_append_dentry(path, dentry)) { - case PATH_APPEND_SUCCESS: - break; - case PATH_APPEND_INVALID_LENGTH: - bpf_printk("Invalid path length: %u", path->len); - goto error; - case PATH_APPEND_READ_ERROR: - bpf_printk("Failed to read final path component"); - goto error; + m->path_unlink.error++; + return 0; } inode_key_t inode_key = inode_to_key(dentry->d_inode); @@ -120,10 +103,6 @@ int BPF_PROG(trace_path_unlink, struct path* dir, struct dentry* dentry) { &inode_key, path_hooks_support_bpf_d_path); return 0; - -error: - m->path_unlink.error++; - return 0; } SEC("lsm/path_chmod") @@ -229,3 +208,52 @@ int BPF_PROG(trace_path_chown, struct path* path, unsigned long long uid, unsign return 0; } + +SEC("lsm/path_rename") +int BPF_PROG(trace_path_rename, struct path* old_dir, + struct dentry* old_dentry, struct path* new_dir, + struct dentry* new_dentry, unsigned int flags) { + struct metrics_t* m = get_metrics(); + if (m == NULL) { + return 0; + } + + m->path_rename.total++; + + struct bound_path_t* new_path = path_read_append_d_entry(new_dir, new_dentry); + if (new_path == NULL) { + bpf_printk("Failed to read path"); + goto error; + } + + struct bound_path_t* old_path = path_read_alt_append_d_entry(old_dir, old_dentry); + if (old_path == NULL) { + bpf_printk("Failed to read path"); + goto error; + } + + inode_key_t old_inode = inode_to_key(old_dentry->d_inode); + const inode_value_t* volatile old_inode_value = inode_get(&old_inode); + inode_key_t new_inode = inode_to_key(new_dentry->d_inode); + const inode_value_t* volatile new_inode_value = inode_get(&new_inode); + + if (inode_is_monitored(old_inode_value) == NOT_MONITORED && + inode_is_monitored(new_inode_value) == NOT_MONITORED && + !is_monitored(old_path) && + !is_monitored(new_path)) { + m->path_rename.ignored++; + return 0; + } + + submit_rename_event(&m->path_rename, + new_path->path, + old_path->path, + &old_inode, + &new_inode, + path_hooks_support_bpf_d_path); + return 0; + +error: + m->path_rename.error++; + return 0; +} diff --git a/fact-ebpf/src/bpf/maps.h b/fact-ebpf/src/bpf/maps.h index 37849f2d..13f5fa5d 100644 --- a/fact-ebpf/src/bpf/maps.h +++ b/fact-ebpf/src/bpf/maps.h @@ -79,12 +79,16 @@ struct { __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY); __type(key, __u32); __type(value, struct bound_path_t); - __uint(max_entries, 1); + __uint(max_entries, 2); } bound_path_heap SEC(".maps"); -__always_inline static struct bound_path_t* get_bound_path() { - unsigned int zero = 0; - return bpf_map_lookup_elem(&bound_path_heap, &zero); +typedef enum { + BOUND_PATH_MAIN = 0, + BOUND_PATH_ALTERNATE = 1, +} bound_path_buffer_t; + +__always_inline static struct bound_path_t* get_bound_path(bound_path_buffer_t key) { + return bpf_map_lookup_elem(&bound_path_heap, &key); } struct { diff --git a/fact-ebpf/src/bpf/types.h b/fact-ebpf/src/bpf/types.h index 8fce112e..6a009e66 100644 --- a/fact-ebpf/src/bpf/types.h +++ b/fact-ebpf/src/bpf/types.h @@ -54,6 +54,7 @@ typedef enum file_activity_type_t { FILE_ACTIVITY_UNLINK, FILE_ACTIVITY_CHMOD, FILE_ACTIVITY_CHOWN, + FILE_ACTIVITY_RENAME, } file_activity_type_t; struct event_t { @@ -73,6 +74,10 @@ struct event_t { unsigned int gid; } old, new; } chown; + struct { + char old_filename[PATH_MAX]; + inode_key_t old_inode; + } rename; }; }; @@ -104,4 +109,5 @@ struct metrics_t { struct metrics_by_hook_t path_unlink; struct metrics_by_hook_t path_chmod; struct metrics_by_hook_t path_chown; + struct metrics_by_hook_t path_rename; }; diff --git a/fact-ebpf/src/lib.rs b/fact-ebpf/src/lib.rs index 655d48d7..0bf7a1ad 100644 --- a/fact-ebpf/src/lib.rs +++ b/fact-ebpf/src/lib.rs @@ -113,6 +113,7 @@ impl metrics_t { m.path_unlink = m.path_unlink.accumulate(&other.path_unlink); m.path_chmod = m.path_chmod.accumulate(&other.path_chmod); m.path_chown = m.path_chown.accumulate(&other.path_chown); + m.path_rename = m.path_rename.accumulate(&other.path_rename); m } } diff --git a/fact/src/bpf/mod.rs b/fact/src/bpf/mod.rs index ec9ca57f..71077cb8 100644 --- a/fact/src/bpf/mod.rs +++ b/fact/src/bpf/mod.rs @@ -269,7 +269,7 @@ mod bpf_tests { tokio::time::sleep(Duration::from_millis(500)).await; // Create a file - let file = NamedTempFile::new_in(monitored_path).expect("Failed to create temporary file"); + let file = NamedTempFile::new_in(&monitored_path).expect("Failed to create temporary file"); println!("Created {file:?}"); // Trigger permission changes @@ -286,6 +286,12 @@ mod bpf_tests { let current = Process::current(); let file_path = file.path().to_path_buf(); + // Trigger a file rename + let renamed_path = monitored_path.join("target"); + std::fs::rename(&file, &renamed_path).expect("Failed to rename file"); + // Move the file back so it can be properly closed + std::fs::rename(&renamed_path, &file).expect("Failed to rename file"); + let expected_events = [ Event::new( EventTestData::Creation, @@ -303,6 +309,22 @@ mod bpf_tests { current.clone(), ) .unwrap(), + Event::new( + EventTestData::Rename(file_path.clone()), + host_info::get_hostname(), + renamed_path.clone(), + PathBuf::new(), + current.clone(), + ) + .unwrap(), + Event::new( + EventTestData::Rename(renamed_path), + host_info::get_hostname(), + file_path.clone(), + PathBuf::new(), + current.clone(), + ) + .unwrap(), Event::new( EventTestData::Unlink, host_info::get_hostname(), @@ -312,7 +334,6 @@ mod bpf_tests { ) .unwrap(), ]; - // Close the file, removing it file.close().expect("Failed to close temp file"); diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 463fb2c7..c85189d5 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -64,6 +64,7 @@ pub(crate) enum EventTestData { Creation, Unlink, Chmod(u16, u16), + Rename(PathBuf), } #[derive(Debug, Clone, Serialize)] @@ -103,6 +104,16 @@ impl Event { }; FileData::Chmod(data) } + EventTestData::Rename(old_path) => { + let data = RenameFileData { + new: inner, + old: BaseFileData { + filename: old_path, + ..Default::default() + }, + }; + FileData::Rename(data) + } }; Ok(Event { @@ -113,6 +124,11 @@ impl Event { }) } + /// Unwrap the inner FileData and return the inode that triggered + /// the event. + /// + /// In the case of operations that involve two inodes, like rename, + /// the 'new' inode will be returned. pub fn get_inode(&self) -> &inode_key_t { match &self.file { FileData::Open(data) => &data.inode, @@ -120,9 +136,24 @@ impl Event { FileData::Unlink(data) => &data.inode, FileData::Chmod(data) => &data.inner.inode, FileData::Chown(data) => &data.inner.inode, + FileData::Rename(data) => &data.new.inode, } } + /// Same as `get_inode` but returning the 'old' inode for operations + /// like rename. For operations that involve a single inode, `None` + /// will be returned. + pub fn get_old_inode(&self) -> Option<&inode_key_t> { + match &self.file { + FileData::Rename(data) => Some(&data.old.inode), + _ => None, + } + } + + /// Set the `host_file` field of the event to the one provided. + /// + /// In the case of operations that involve two paths, like rename, + /// the 'new' host_file will be set. pub fn set_host_path(&mut self, host_path: PathBuf) { match &mut self.file { FileData::Open(data) => data.host_file = host_path, @@ -130,6 +161,15 @@ impl Event { FileData::Unlink(data) => data.host_file = host_path, FileData::Chmod(data) => data.inner.host_file = host_path, FileData::Chown(data) => data.inner.host_file = host_path, + FileData::Rename(data) => data.new.host_file = host_path, + } + } + + /// Same as `set_host_path` but setting the 'old' host_file for + /// operations that have one, like rename. + pub fn set_old_host_path(&mut self, host_path: PathBuf) { + if let FileData::Rename(data) = &mut self.file { + data.old.host_file = host_path } } } @@ -185,6 +225,7 @@ pub enum FileData { Unlink(BaseFileData), Chmod(ChmodFileData), Chown(ChownFileData), + Rename(RenameFileData), } impl FileData { @@ -217,6 +258,15 @@ impl FileData { }; FileData::Chown(data) } + file_activity_type_t::FILE_ACTIVITY_RENAME => { + let old_filename = unsafe { extra_data.rename.old_filename }; + let old_inode = unsafe { extra_data.rename.old_inode }; + let data = RenameFileData { + new: inner, + old: BaseFileData::new(old_filename, old_inode)?, + }; + FileData::Rename(data) + } invalid => unreachable!("Invalid event type: {invalid:?}"), }; @@ -250,6 +300,10 @@ impl From for fact_api::file_activity::File { let f_act = fact_api::FileOwnershipChange::from(event); fact_api::file_activity::File::Ownership(f_act) } + FileData::Rename(event) => { + let f_act = fact_api::FileRename::from(event); + fact_api::file_activity::File::Rename(f_act) + } } } } @@ -262,12 +316,13 @@ impl PartialEq for FileData { (FileData::Creation(this), FileData::Creation(other)) => this == other, (FileData::Unlink(this), FileData::Unlink(other)) => this == other, (FileData::Chmod(this), FileData::Chmod(other)) => this == other, + (FileData::Rename(this), FileData::Rename(other)) => this == other, _ => false, } } } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Default)] pub struct BaseFileData { pub filename: PathBuf, host_file: PathBuf, @@ -359,6 +414,30 @@ impl From for fact_api::FileOwnershipChange { } } +#[derive(Debug, Clone, Serialize)] +pub struct RenameFileData { + new: BaseFileData, + old: BaseFileData, +} + +impl From for fact_api::FileRename { + fn from(RenameFileData { new, old }: RenameFileData) -> Self { + let new = fact_api::FileActivityBase::from(new); + let old = fact_api::FileActivityBase::from(old); + fact_api::FileRename { + old: Some(old), + new: Some(new), + } + } +} + +#[cfg(test)] +impl PartialEq for RenameFileData { + fn eq(&self, other: &Self) -> bool { + self.new == other.new && self.old == other.old + } +} + #[cfg(test)] mod test_utils { use std::os::raw::c_char; diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index ac2b5bae..2adbb58d 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -126,10 +126,10 @@ impl HostScanner { self.tx.subscribe() } - fn get_host_path(&self, inode: &inode_key_t) -> Option { + 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() + self.inode_map.borrow().get(inode?).cloned() } pub fn start(mut self) -> JoinHandle> { @@ -144,10 +144,14 @@ impl HostScanner { break; }; - if let Some(host_path) = self.get_host_path(event.get_inode()) { + if let Some(host_path) = self.get_host_path(Some(event.get_inode())) { event.set_host_path(host_path); } + if let Some(host_path) = self.get_host_path(event.get_old_inode()) { + event.set_old_host_path(host_path); + } + let event = Arc::new(event); if let Err(e) = self.tx.send(event) { warn!("Failed to send event: {e}"); diff --git a/fact/src/metrics/kernel_metrics.rs b/fact/src/metrics/kernel_metrics.rs index 1a6fc977..d1a3a242 100644 --- a/fact/src/metrics/kernel_metrics.rs +++ b/fact/src/metrics/kernel_metrics.rs @@ -12,6 +12,7 @@ pub struct KernelMetrics { path_unlink: EventCounter, path_chmod: EventCounter, path_chown: EventCounter, + path_rename: EventCounter, map: PerCpuArray, } @@ -37,17 +38,24 @@ impl KernelMetrics { "Events processed by the path_chown LSM hook", &[], // Labels are not needed since `collect` will add them all ); + let path_rename = EventCounter::new( + "kernel_path_rename_events", + "Events processed by the path_rename LSM hook", + &[], // Labels are not needed since `collect` will add them all + ); file_open.register(reg); path_unlink.register(reg); path_chmod.register(reg); path_chown.register(reg); + path_rename.register(reg); KernelMetrics { file_open, path_unlink, path_chmod, path_chown, + path_rename, map: kernel_metrics, } } @@ -96,6 +104,7 @@ impl KernelMetrics { KernelMetrics::refresh_labels(&self.path_unlink, &metrics.path_unlink); KernelMetrics::refresh_labels(&self.path_chmod, &metrics.path_chmod); KernelMetrics::refresh_labels(&self.path_chown, &metrics.path_chown); + KernelMetrics::refresh_labels(&self.path_rename, &metrics.path_rename); Ok(()) } diff --git a/tests/conftest.py b/tests/conftest.py index aee04534..143167bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ 'test_editors.commons' ] + def join_path_with_filename(directory, filename): """ Join a directory path with a filename, handling bytes filenames properly. @@ -54,6 +55,7 @@ def path_to_string(path): else: return path + @pytest.fixture def monitored_dir(): """ @@ -249,8 +251,8 @@ def fact(request, docker_client, fact_config, server, logs_dir, test_file): with open(metric_log, 'w') as f: f.write(resp.text) - container.stop(timeout=1) - exit_status = container.wait(timeout=1) + container.stop(timeout=2) + exit_status = container.wait(timeout=2) dump_logs(container, container_log) container.remove() assert exit_status['StatusCode'] == 0 diff --git a/tests/event.py b/tests/event.py index 7fee2cb8..324e2ec3 100644 --- a/tests/event.py +++ b/tests/event.py @@ -33,6 +33,7 @@ class EventType(Enum): UNLINK = 3 PERMISSION = 4 OWNERSHIP = 5 + RENAME = 6 class Process: @@ -190,14 +191,18 @@ class Event: event type and a file. """ - def __init__(self, - process: Process, - event_type: EventType, - file: str | Pattern[str], - host_path: str | Pattern[str] = '', - mode: int | None = None, - owner_uid: int | None = None, - owner_gid: int | None = None,): + def __init__( + self, + process: Process, + event_type: EventType, + file: str | Pattern[str], + host_path: str | Pattern[str] = '', + mode: int | None = None, + owner_uid: int | None = None, + owner_gid: int | None = None, + old_file: str | Pattern[str] | None = None, + old_host_path: str | Pattern[str] | None = None, + ): self._type: EventType = event_type self._process: Process = process self._file: str | Pattern[str] = file @@ -205,6 +210,8 @@ def __init__(self, self._mode: int | None = mode self._owner_uid: int | None = owner_uid self._owner_gid: int | None = owner_gid + self._old_file: str | Pattern[str] | None = old_file + self._old_host_path: str | Pattern[str] | None = old_host_path @property def event_type(self) -> EventType: @@ -234,6 +241,14 @@ def owner_uid(self) -> int | None: def owner_gid(self) -> int | None: return self._owner_gid + @property + def old_file(self) -> str | Pattern[str] | None: + return self._old_file + + @property + def old_host_path(self) -> str | Pattern[str] | None: + return self._old_host_path + @override def __eq__(self, other: Any) -> bool: if isinstance(other, FileActivity): @@ -258,6 +273,12 @@ def __eq__(self, other: Any) -> bool: cmp_path(self.host_path, other.ownership.activity.host_path) and \ self.owner_uid == other.ownership.uid and \ self.owner_gid == other.ownership.gid + elif self.event_type == EventType.RENAME: + return cmp_path(self.file, other.rename.new.path) and \ + cmp_path(self.host_path, other.rename.new.host_path) and \ + cmp_path(self.old_file, other.rename.old.path) and \ + cmp_path(self.old_host_path, other.rename.old.host_path) + return False raise NotImplementedError @@ -273,6 +294,9 @@ def __str__(self) -> str: if self.event_type == EventType.OWNERSHIP: s += f', owner=(uid={self.owner_uid}, gid={self.owner_gid})' + if self.event_type == EventType.RENAME: + s += f', old_file="{self.old_file}", old_host_path="{self.old_host_path}"' + s += ')' return s diff --git a/tests/test_editors/test_nvim.py b/tests/test_editors/test_nvim.py index 32175d42..75eeac17 100644 --- a/tests/test_editors/test_nvim.py +++ b/tests/test_editors/test_nvim.py @@ -25,6 +25,7 @@ def test_new_file(editor_container, server): def test_open_file(editor_container, server): fut = '/mounted/test.txt' + fut_backup = f'{fut}~' container_id = editor_container.id[:12] # We ensure the file exists before editing. @@ -56,12 +57,14 @@ def test_open_file(editor_container, server): file=vi_test_file, host_path='', owner_uid=0, owner_gid=0), Event(process=nvim, event_type=EventType.UNLINK, file=vi_test_file, host_path=''), + Event(process=nvim, event_type=EventType.RENAME, + file=fut_backup, host_path='', old_file=fut, old_host_path=''), Event(process=nvim, event_type=EventType.CREATION, file=fut, host_path=''), Event(process=nvim, event_type=EventType.PERMISSION, file=fut, host_path='', mode=0o100644), Event(process=nvim, event_type=EventType.UNLINK, - file=f'{fut}~', host_path=''), + file=fut_backup, host_path=''), ] server.wait_events(events, strict=True) @@ -91,6 +94,7 @@ def test_new_file_ovfs(editor_container, server): def test_open_file_ovfs(editor_container, server): fut = '/container-dir/test.txt' + fut_backup = f'{fut}~' container_id = editor_container.id[:12] # We ensure the file exists before editing. @@ -126,6 +130,8 @@ def test_open_file_ovfs(editor_container, server): file=vi_test_file, host_path='', owner_uid=0, owner_gid=0), Event(process=nvim, event_type=EventType.UNLINK, file=vi_test_file, host_path=''), + Event(process=nvim, event_type=EventType.RENAME, + file=fut_backup, host_path='', old_file=fut, old_host_path=''), Event(process=nvim, event_type=EventType.CREATION, file=fut, host_path=''), Event(process=nvim, event_type=EventType.OPEN, @@ -133,7 +139,7 @@ def test_open_file_ovfs(editor_container, server): Event(process=nvim, event_type=EventType.PERMISSION, file=fut, host_path='', mode=0o100644), Event(process=nvim, event_type=EventType.UNLINK, - file=f'{fut}~', host_path=''), + file=fut_backup, host_path=''), ] server.wait_events(events, strict=True) diff --git a/tests/test_editors/test_sed.py b/tests/test_editors/test_sed.py index 99accb88..39fd5d32 100644 --- a/tests/test_editors/test_sed.py +++ b/tests/test_editors/test_sed.py @@ -32,6 +32,8 @@ def test_sed(vi_container, server): file=sed_tmp_file, host_path=''), Event(process=sed, event_type=EventType.OWNERSHIP, file=sed_tmp_file, host_path='', owner_uid=0, owner_gid=0), + Event(process=sed, event_type=EventType.RENAME, + file=fut, host_path='', old_file=sed_tmp_file, old_host_path=''), ] server.wait_events(events, strict=True) @@ -71,6 +73,8 @@ def test_sed_ovfs(vi_container, server): file=sed_tmp_file, host_path=''), Event(process=sed, event_type=EventType.OWNERSHIP, file=sed_tmp_file, host_path='', owner_uid=0, owner_gid=0), + Event(process=sed, event_type=EventType.RENAME, + file=fut, host_path='', old_file=sed_tmp_file, old_host_path=''), ] server.wait_events(events, strict=True) diff --git a/tests/test_editors/test_vi.py b/tests/test_editors/test_vi.py index 65c4ef06..dd4b3c2b 100644 --- a/tests/test_editors/test_vi.py +++ b/tests/test_editors/test_vi.py @@ -84,6 +84,7 @@ def test_new_file_ovfs(vi_container, server): def test_open_file(vi_container, server): fut = '/mounted/test.txt' + fut_backup = f'{fut}~' swap_file = '/mounted/.test.txt.swp' swx_file = '/mounted/.test.txt.swx' vi_test_file = get_vi_test_file('/mounted') @@ -129,12 +130,14 @@ def test_open_file(vi_container, server): file=vi_test_file, host_path='', owner_uid=0, owner_gid=0), Event(process=vi_process, event_type=EventType.UNLINK, file=vi_test_file, host_path=''), + Event(process=vi_process, event_type=EventType.RENAME, + file=fut_backup, host_path='', old_file=fut, old_host_path=''), Event(process=vi_process, event_type=EventType.CREATION, file=fut, host_path=''), Event(process=vi_process, event_type=EventType.PERMISSION, file=fut, host_path='', mode=0o100644), Event(process=vi_process, event_type=EventType.UNLINK, - file=f'{fut}~', host_path=''), + file=fut_backup, host_path=''), Event(process=vi_process, event_type=EventType.UNLINK, file=swap_file, host_path=''), ] @@ -144,6 +147,7 @@ def test_open_file(vi_container, server): def test_open_file_ovfs(vi_container, server): fut = '/container-dir/test.txt' + fut_backup = f'{fut}~' swap_file = '/container-dir/.test.txt.swp' swx_file = '/container-dir/.test.txt.swx' vi_test_file = get_vi_test_file('/container-dir') @@ -199,6 +203,8 @@ def test_open_file_ovfs(vi_container, server): file=vi_test_file, host_path='', owner_uid=0, owner_gid=0), Event(process=vi_process, event_type=EventType.UNLINK, file=vi_test_file, host_path=''), + Event(process=vi_process, event_type=EventType.RENAME, + file=fut_backup, host_path='', old_file=fut, old_host_path=''), Event(process=vi_process, event_type=EventType.CREATION, file=fut, host_path=''), Event(process=vi_process, event_type=EventType.OPEN, @@ -206,7 +212,7 @@ def test_open_file_ovfs(vi_container, server): Event(process=vi_process, event_type=EventType.PERMISSION, file=fut, host_path='', mode=0o100644), Event(process=vi_process, event_type=EventType.UNLINK, - file=f'{fut}~', host_path=''), + file=fut_backup, host_path=''), Event(process=vi_process, event_type=EventType.UNLINK, file=swap_file, host_path=''), ] diff --git a/tests/test_editors/test_vim.py b/tests/test_editors/test_vim.py index 8919362b..5fa6bf92 100644 --- a/tests/test_editors/test_vim.py +++ b/tests/test_editors/test_vim.py @@ -80,6 +80,7 @@ def test_new_file_ovfs(editor_container, server): def test_open_file(editor_container, server): fut = '/mounted/test.txt' + fut_backup = f'{fut}~' swap_file = '/mounted/.test.txt.swp' swx_file = '/mounted/.test.txt.swx' vi_test_file = get_vi_test_file('/mounted') @@ -124,12 +125,14 @@ def test_open_file(editor_container, server): file=vi_test_file, host_path='', owner_uid=0, owner_gid=0), Event(process=vi_process, event_type=EventType.UNLINK, file=vi_test_file, host_path=''), + Event(process=vi_process, event_type=EventType.RENAME, + file=fut_backup, host_path='', old_file=fut, old_host_path=''), Event(process=vi_process, event_type=EventType.CREATION, file=fut, host_path=''), Event(process=vi_process, event_type=EventType.PERMISSION, file=fut, host_path='', mode=0o100644), Event(process=vi_process, event_type=EventType.UNLINK, - file=f'{fut}~', host_path=''), + file=fut_backup, host_path=''), Event(process=vi_process, event_type=EventType.UNLINK, file=swap_file, host_path=''), ] @@ -139,6 +142,7 @@ def test_open_file(editor_container, server): def test_open_file_ovfs(editor_container, server): fut = '/container-dir/test.txt' + fut_backup = f'{fut}~' swap_file = '/container-dir/.test.txt.swp' swx_file = '/container-dir/.test.txt.swx' vi_test_file = get_vi_test_file('/container-dir') @@ -193,6 +197,8 @@ def test_open_file_ovfs(editor_container, server): file=vi_test_file, host_path='', owner_uid=0, owner_gid=0), Event(process=vi_process, event_type=EventType.UNLINK, file=vi_test_file, host_path=''), + Event(process=vi_process, event_type=EventType.RENAME, + file=fut_backup, host_path='', old_file=fut, old_host_path=''), Event(process=vi_process, event_type=EventType.CREATION, file=fut, host_path=''), Event(process=vi_process, event_type=EventType.OPEN, @@ -200,7 +206,7 @@ def test_open_file_ovfs(editor_container, server): Event(process=vi_process, event_type=EventType.PERMISSION, file=fut, host_path='', mode=0o100644), Event(process=vi_process, event_type=EventType.UNLINK, - file=f'{fut}~', host_path=''), + file=fut_backup, host_path=''), Event(process=vi_process, event_type=EventType.UNLINK, file=swap_file, host_path=''), ] diff --git a/tests/test_path_rename.py b/tests/test_path_rename.py new file mode 100644 index 00000000..5d6eb42d --- /dev/null +++ b/tests/test_path_rename.py @@ -0,0 +1,198 @@ +import os + +import pytest + +from conftest import join_path_with_filename, path_to_string +from event import Event, EventType, Process + + +@pytest.mark.parametrize("filename", [ + pytest.param('rename.txt', id='ASCII'), + pytest.param('café.txt', id='French'), + pytest.param('файл.txt', id='Cyrillic'), + pytest.param('测试.txt', id='Chinese'), + pytest.param('🚀rocket.txt', id='Emoji'), + pytest.param(b'test\xff\xfe.txt', id='Invalid'), +]) +def test_rename(monitored_dir, server, filename): + """ + Tests the renaming of a file and verifies that the corresponding + events are captured by the server. + + Args: + monitored_dir: Temporary directory path for creating the test file. + server: The server instance to communicate with. + filename: Name of the target path to rename to. + """ + # File Under Test + fut = join_path_with_filename(monitored_dir, filename) + old_fut = os.path.join(monitored_dir, 'file.txt') + + # Create a new file, it will have an empty host_path. + with open(old_fut, 'w') as f: + f.write('This is a test') + os.rename(old_fut, fut) + os.rename(fut, old_fut) + + # Convert fut to string for the Event, replacing invalid UTF-8 with U+FFFD + fut = path_to_string(fut) + + events = [ + Event(process=Process.from_proc(), event_type=EventType.CREATION, + file=old_fut, host_path=''), + Event(process=Process.from_proc(), event_type=EventType.RENAME, + file=fut, host_path='', old_file=old_fut, old_host_path=''), + Event(process=Process.from_proc(), event_type=EventType.RENAME, + file=old_fut, host_path='', old_file=fut, old_host_path=''), + ] + + server.wait_events(events) + + +def test_ignored(monitored_dir, ignored_dir, server): + """ + Tests that rename events on ignored files are not captured by the + server. + + Args: + monitored_dir: Temporary directory path for creating the test file. + ignored_dir: Temporary directory path that is not monitored by fact. + server: The server instance to communicate with. + """ + ignored_path = os.path.join(ignored_dir, 'test.txt') + + with open(ignored_path, 'w') as f: + f.write('This is to be ignored') + new_ignored_path = os.path.join(ignored_dir, 'rename.txt') + + # Renaming in between ignored paths should not generate events + os.rename(ignored_path, new_ignored_path) + + # Renaming to a monitored path generates an event + new_path = os.path.join(monitored_dir, 'rename.txt') + os.rename(new_ignored_path, new_path) + + # Renaming from a monitored path generates an event too + os.rename(new_path, ignored_path) + + p = Process.from_proc() + events = [ + Event(process=p, event_type=EventType.RENAME, + file=new_path, host_path='', old_file=new_ignored_path, old_host_path=''), + Event(process=p, event_type=EventType.RENAME, + file=ignored_path, host_path='', old_file=new_path, old_host_path=''), + ] + + server.wait_events(events) + + +def test_rename_dir(monitored_dir, ignored_dir, server): + """ + Test renaming a directory is caught + + We start by creating a subdirectory in an ignored path and give it a + few files to test we only get events for the directory itself. We + then check renaming within the ignored path doesn't trigger events, + move the subdirectory to a monitored path, renaming it within that + path and moving it back out, these three interactions should + generate events for the directory only. + + Args: + monitored_dir: Temporary directory path for creating the test file. + ignored_dir: Temporary directory path that is not monitored by fact. + server: The server instance to communicate with. + """ + # Directory Under Test + dut = os.path.join(monitored_dir, 'some-dir') + new_dut = os.path.join(monitored_dir, 'other-dir') + ignored_dut = os.path.join(ignored_dir, 'some-dir') + new_ignored_dut = os.path.join(ignored_dir, 'other-dir') + + os.mkdir(ignored_dut) + for i in range(3): + with open(os.path.join(ignored_dut, f'{i}.txt'), 'w') as f: + f.write('This is a test') + + # This rename should generate no events + os.rename(ignored_dut, new_ignored_dut) + + # The next three renames need to generate one event each + os.rename(new_ignored_dut, dut) + os.rename(dut, new_dut) + os.rename(new_dut, ignored_dut) + + p = Process.from_proc() + events = [ + Event(process=p, event_type=EventType.RENAME, file=dut, + host_path='', old_file=new_ignored_dut, old_host_path=''), + Event(process=p, event_type=EventType.RENAME, + file=new_dut, host_path='', old_file=dut, old_host_path=''), + Event(process=p, event_type=EventType.RENAME, + file=ignored_dut, host_path='', old_file=new_dut, old_host_path=''), + ] + + server.wait_events(events) + + +def test_overlay(test_container, server): + # File Under Test + fut = '/container-dir/test.txt' + new_fut = '/container-dir/rename.txt' + + # Create the exec and an equivalent event that it will trigger + test_container.exec_run(f'touch {fut}') + test_container.exec_run(f'mv {fut} {new_fut}') + + touch = Process.in_container( + exe_path='/usr/bin/touch', + args=f'touch {fut}', + name='touch', + container_id=test_container.id[:12], + ) + mv = Process.in_container( + exe_path='/usr/bin/mv', + args=f'mv {fut} {new_fut}', + name='mv', + container_id=test_container.id[:12], + ) + events = [ + Event(process=touch, event_type=EventType.CREATION, + file=fut, host_path=''), + Event(process=touch, event_type=EventType.OPEN, + file=fut, host_path=''), + Event(process=mv, event_type=EventType.RENAME, + file=new_fut, host_path='', old_file=fut, old_host_path=''), + ] + + server.wait_events(events) + + +def test_mounted_dir(test_container, ignored_dir, server): + # File Under Test + fut = '/mounted/test.txt' + new_fut = '/mounted/rename.txt' + + # Create the exec and an equivalent event that it will trigger + test_container.exec_run(f'touch {fut}') + test_container.exec_run(f'mv {fut} {new_fut}') + + touch = Process.in_container( + exe_path='/usr/bin/touch', + args=f'touch {fut}', + name='touch', + container_id=test_container.id[:12], + ) + mv = Process.in_container( + exe_path='/usr/bin/mv', + args=f'mv {fut} {new_fut}', + name='mv', + container_id=test_container.id[:12], + ) + events = [ + Event(process=touch, event_type=EventType.CREATION, + file=fut, host_path=''), + Event(process=mv, event_type=EventType.RENAME, + file=new_fut, host_path='', old_file=fut, old_host_path=''), + ] + + server.wait_events(events)