diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index e5695653..413dfc03 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -358,3 +358,205 @@ impl From for fact_api::FileOwnershipChange { } } } + +#[cfg(test)] +mod test_utils { + use std::os::raw::c_char; + + /// Helper function to convert raw bytes to a c_char array for testing + pub fn bytes_to_c_char_array(bytes: &[u8]) -> [c_char; N] { + let mut array = [0 as c_char; N]; + let len = bytes.len().min(N - 1); + for (i, &byte) in bytes.iter().take(len).enumerate() { + array[i] = byte as c_char; + } + array + } + + /// Helper function to convert a Rust string to a c_char array for testing + pub fn string_to_c_char_array(s: &str) -> [c_char; N] { + bytes_to_c_char_array(s.as_bytes()) + } +} + +#[cfg(test)] +mod tests { + use super::test_utils::*; + use super::*; + + #[test] + fn slice_to_string_valid_utf8() { + let tests = [ + ("hello", "ASCII"), + ("café", "Latin-1 supplement"), + ("файл", "Cyrillic"), + ("测试文件", "Chinese"), + ("test🚀file", "emoji"), + ("test-файл-测试-🐛.txt", "mixed characters"), + ("ملف", "Arabic"), + ("קובץ", "Hebrew"), + ("ファイル", "Japanese"), + ]; + + for (input, description) in tests { + let arr = string_to_c_char_array::<256>(input); + assert_eq!( + slice_to_string(&arr).unwrap(), + input, + "Failed for {}", + description + ); + } + } + + #[test] + fn slice_to_string_invalid_utf8() { + let tests: &[(&[u8], &str)] = &[ + (&[0xFF, 0xFE, 0xFD], "invalid continuation bytes"), + (b"test\xE2", "truncated multi-byte sequence"), + (&[0xC0, 0x80], "overlong encoding"), + (b"hello\x80world", "invalid start byte"), + (&[0x80], "lone continuation byte"), + (b"test\xFF\xFE", "mixed valid and invalid bytes"), + ]; + + for (bytes, description) in tests { + let arr = bytes_to_c_char_array::<256>(bytes); + assert!( + slice_to_string(&arr).is_err(), + "Should fail for {}", + description + ); + } + } + + #[test] + fn sanitize_d_path_valid_utf8() { + let tests = [ + ("/etc/test", "/etc/test", "ASCII"), + ("/tmp/файл.txt", "/tmp/файл.txt", "Cyrillic"), + ( + "/home/user/测试文件.log", + "/home/user/测试文件.log", + "Chinese", + ), + ("/data/🚀rocket.dat", "/data/🚀rocket.dat", "emoji"), + ( + "/var/log/app-данные-数据-🐛.log", + "/var/log/app-данные-数据-🐛.log", + "mixed Unicode", + ), + ("/home/ملف.txt", "/home/ملف.txt", "Arabic"), + ("/opt/ファイル.conf", "/opt/ファイル.conf", "Japanese"), + ]; + + for (input, expected, description) in tests { + let arr = string_to_c_char_array::<4096>(input); + assert_eq!( + sanitize_d_path(&arr), + PathBuf::from(expected), + "Failed for {}", + description + ); + } + } + + #[test] + fn sanitize_d_path_deleted_suffix() { + let tests = [ + ( + "/tmp/test.txt (deleted)", + "/tmp/test.txt", + "ASCII with deleted suffix", + ), + ( + "/tmp/файл.txt (deleted)", + "/tmp/файл.txt", + "Unicode with deleted suffix", + ), + ("/etc/config.yaml", "/etc/config.yaml", "no deleted suffix"), + ( + "/var/log/app/debug.log (deleted)", + "/var/log/app/debug.log", + "nested path with deleted suffix", + ), + ]; + + for (input, expected, description) in tests { + let arr = string_to_c_char_array::<4096>(input); + assert_eq!( + sanitize_d_path(&arr), + PathBuf::from(expected), + "Failed for {}", + description + ); + } + } + + #[test] + fn sanitize_d_path_invalid_utf8() { + let tests: &[(&[u8], &str, &str, &str)] = &[ + ( + b"/tmp/\xFF\xFE.txt", + "/tmp/", + ".txt", + "invalid continuation bytes", + ), + ( + b"/var/test\xE2\x80", + "/var/", + "", + "truncated multi-byte sequence", + ), + ( + b"/home/file\x80.log", + "/home/", + ".log", + "invalid start byte", + ), + ( + b"/tmp/\xD1\x84\xFF\xD0\xBB.txt", + "/tmp/", + "", + "mixed valid and invalid UTF-8", + ), + ]; + + for (bytes, must_contain1, must_contain2, description) in tests { + let arr = bytes_to_c_char_array::<4096>(bytes); + let result = sanitize_d_path(&arr); + let result_str = result.to_string_lossy(); + + assert!( + result_str.contains(must_contain1), + "Failed for {} - should contain '{}'", + description, + must_contain1 + ); + if !must_contain2.is_empty() { + assert!( + result_str.contains(must_contain2), + "Failed for {} - should contain '{}'", + description, + must_contain2 + ); + } + assert!( + result_str.contains('\u{FFFD}'), + "Failed for {} - should contain replacement character", + description + ); + } + } + + #[test] + fn sanitize_d_path_invalid_utf8_with_deleted_suffix() { + let invalid_with_deleted = bytes_to_c_char_array::<4096>(b"/tmp/\xFF\xFE (deleted)"); + let result = sanitize_d_path(&invalid_with_deleted); + let result_str = result.to_string_lossy(); + + assert!(result_str.contains("/tmp/")); + assert!(!result_str.ends_with(" (deleted)")); + assert!(result_str.contains('\u{FFFD}')); + } +} diff --git a/fact/src/event/process.rs b/fact/src/event/process.rs index d7d1d139..b4dd4f71 100644 --- a/fact/src/event/process.rs +++ b/fact/src/event/process.rs @@ -222,6 +222,29 @@ impl From for fact_api::ProcessSignal { #[cfg(test)] mod tests { use super::*; + use crate::event::test_utils::*; + use std::os::raw::c_char; + + /// Helper to create a default process_t for testing + fn default_process_t() -> process_t { + process_t { + comm: string_to_c_char_array::<16>("test"), + args: string_to_c_char_array::<4096>("arg1\0arg2\0"), + args_len: 10, + exe_path: string_to_c_char_array::<4096>("/usr/bin/test"), + memory_cgroup: string_to_c_char_array::<4096>("init.scope"), + uid: 1000, + gid: 1000, + login_uid: 1000, + pid: 12345, + lineage: [lineage_t { + uid: 1000, + exe_path: string_to_c_char_array::<4096>("/bin/bash"), + }; 2], + lineage_len: 0, + in_root_mount_ns: 1, + } + } #[test] fn extract_container_id() { @@ -259,4 +282,201 @@ mod tests { assert_eq!(id, expected); } } + + #[test] + fn process_conversion_valid_utf8_comm() { + let tests = [ + ("test", "ASCII"), + ("тест", "Cyrillic"), + ("测试", "Chinese"), + ("app🚀", "emoji"), + ]; + + for (comm, description) in tests { + let mut proc = default_process_t(); + proc.comm = string_to_c_char_array::<16>(comm); + let result = Process::try_from(proc); + assert!(result.is_ok(), "Failed for {}", description); + assert_eq!(result.unwrap().comm, comm, "Failed for {}", description); + } + } + + #[test] + fn process_conversion_invalid_utf8_comm() { + let tests: &[(&[u8], &str)] = &[ + (b"test\xFF\xFE", "invalid bytes"), + (b"app\xE2\x80", "truncated multi-byte sequence"), + ]; + + for (bytes, description) in tests { + let mut proc = default_process_t(); + proc.comm = bytes_to_c_char_array::<16>(bytes); + let result = Process::try_from(proc); + assert!(result.is_err(), "Should fail for {}", description); + } + } + + #[test] + fn process_conversion_valid_utf8_exe_path() { + let tests = [ + ("/usr/bin/test", "ASCII"), + ("/usr/bin/тест", "Cyrillic"), + ("/opt/应用/测试", "Chinese"), + ("/home/user/🚀app", "emoji"), + ("/var/app-данные-数据/bin", "mixed UTF-8"), + ]; + + for (path, description) in tests { + let mut proc = default_process_t(); + proc.exe_path = string_to_c_char_array::<4096>(path); + let result = Process::try_from(proc); + assert!(result.is_ok(), "Failed for {}", description); + assert_eq!( + result.unwrap().exe_path, + PathBuf::from(path), + "Failed for {}", + description + ); + } + } + + #[test] + fn process_conversion_invalid_utf8_exe_path() { + let mut proc = default_process_t(); + proc.exe_path = bytes_to_c_char_array::<4096>(b"/usr/bin/\xFF\xFE"); + let result = Process::try_from(proc); + assert!(result.is_ok()); + let exe_path = result.unwrap().exe_path; + assert!(exe_path.to_string_lossy().contains("/usr/bin/")); + assert!(exe_path.to_string_lossy().contains('\u{FFFD}')); + } + + #[test] + fn process_conversion_valid_utf8_args() { + let tests: &[(&str, Vec<&str>, &str)] = &[ + ("arg1\0arg2\0arg3\0", vec!["arg1", "arg2", "arg3"], "ASCII"), + ("файл\0данные\0", vec!["файл", "данные"], "Cyrillic"), + ( + "测试\0文件\0数据\0", + vec!["测试", "文件", "数据"], + "Chinese", + ), + ( + "app\0🚀file\0📁data\0", + vec!["app", "🚀file", "📁data"], + "emoji", + ), + ( + "test\0файл\0测试\0🚀\0", + vec!["test", "файл", "测试", "🚀"], + "mixed UTF-8", + ), + ]; + + for (args_str, expected, description) in tests { + let mut proc = default_process_t(); + proc.args = string_to_c_char_array::<4096>(args_str); + proc.args_len = args_str.len() as u32; + let result = Process::try_from(proc); + assert!(result.is_ok(), "Failed for {}", description); + assert_eq!( + result.unwrap().args, + *expected, + "Failed for {}", + description + ); + } + } + + #[test] + fn process_conversion_invalid_utf8_args() { + let tests: &[(&[u8], u32, &str)] = &[ + (b"arg1\0\xFF\xFEarg\0", 11, "invalid bytes"), + (b"test\0\xE2\x80\0", 8, "truncated multi-byte sequence"), + ]; + + for (bytes, args_len, description) in tests { + let mut proc = default_process_t(); + proc.args = bytes_to_c_char_array::<4096>(bytes); + proc.args_len = *args_len; + let result = Process::try_from(proc); + assert!(result.is_err(), "Should fail for {}", description); + } + } + + #[test] + fn process_conversion_valid_utf8_memory_cgroup() { + let tests = [ + ("init.scope", None, "ASCII init.scope"), + ( + "/docker/951e643e3c241b225b6284ef2b79a37c13fc64cbf65b5d46bda95fcb98fe63a4", + Some("951e643e3c24"), + "container ID", + ), + ]; + + for (cgroup, expected_id, description) in tests { + let mut proc = default_process_t(); + proc.memory_cgroup = string_to_c_char_array::<4096>(cgroup); + let result = Process::try_from(proc); + assert!(result.is_ok(), "Failed for {}", description); + assert_eq!( + result.unwrap().container_id, + expected_id.map(|s| s.to_string()), + "Failed for {}", + description + ); + } + } + + #[test] + fn process_conversion_invalid_utf8_memory_cgroup() { + let mut proc = default_process_t(); + proc.memory_cgroup = bytes_to_c_char_array::<4096>(b"/docker/\xFF\xFE"); + let result = Process::try_from(proc); + assert!(result.is_err()); + } + + #[test] + fn process_conversion_valid_utf8_lineage() { + let tests = [ + ("/bin/bash", "ASCII"), + ("/usr/bin/тест", "Cyrillic"), + ("/opt/应用", "Chinese"), + ]; + + for (path, description) in tests { + let mut proc = default_process_t(); + proc.lineage[0] = lineage_t { + uid: 1000, + exe_path: string_to_c_char_array::<4096>(path), + }; + proc.lineage_len = 1; + let result = Process::try_from(proc); + assert!(result.is_ok(), "Failed for {}", description); + let lineage = result.unwrap().lineage; + assert_eq!(lineage.len(), 1); + assert_eq!( + lineage[0].exe_path, + PathBuf::from(path), + "Failed for {}", + description + ); + } + } + + #[test] + fn process_conversion_invalid_utf8_lineage() { + let mut proc = default_process_t(); + proc.lineage[0] = lineage_t { + uid: 1000, + exe_path: bytes_to_c_char_array::<4096>(b"/bin/\xFF\xFE"), + }; + proc.lineage_len = 1; + let result = Process::try_from(proc); + assert!(result.is_ok()); + let lineage = result.unwrap().lineage; + assert!(lineage[0].exe_path.to_string_lossy().contains("/bin/")); + assert!(lineage[0].exe_path.to_string_lossy().contains('\u{FFFD}')); + } } diff --git a/tests/test_file_open.py b/tests/test_file_open.py index c47272c7..07ff1dc5 100644 --- a/tests/test_file_open.py +++ b/tests/test_file_open.py @@ -2,11 +2,20 @@ import os import docker +import pytest from event import Event, EventType, Process -def test_open(fact, monitored_dir, server): +@pytest.mark.parametrize("filename", [ + pytest.param('create.txt', id='ascii'), + pytest.param('café.txt', id='spanish'), + pytest.param('файл.txt', id='cyrilic'), + pytest.param('测试.txt', id='chinese'), + pytest.param('🚀rocket.txt', id='emoji'), + pytest.param(b'test\xff\xfe.txt', id='invalid'), +]) +def test_open(fact, monitored_dir, server, filename): """ Tests the opening of a file and verifies that the corresponding event is captured by the server. @@ -15,14 +24,26 @@ def test_open(fact, monitored_dir, server): fact: Fixture for file activity (only required to be running). monitored_dir: Temporary directory path for creating the test file. server: The server instance to communicate with. + filename: Name of the file to create (includes UTF-8 test cases). """ # File Under Test - fut = os.path.join(monitored_dir, 'create.txt') + # Handle bytes filenames by converting monitored_dir to bytes + if isinstance(filename, bytes): + fut = os.path.join(os.fsencode(monitored_dir), filename) + else: + fut = os.path.join(monitored_dir, filename) + with open(fut, 'w') as f: f.write('This is a test') + # Convert fut back to string for the Event + # For bytes paths with invalid UTF-8, Rust will use the replacement character U+FFFD + if isinstance(fut, bytes): + # Manually convert to match Rust's behavior: replace invalid UTF-8 with U+FFFD + fut = fut.decode('utf-8', errors='replace') + e = Event(process=Process.from_proc(), event_type=EventType.CREATION, - file=fut, host_path='') + file=fut_str, host_path='') print(f'Waiting for event: {e}') server.wait_events([e]) @@ -37,6 +58,7 @@ def test_multiple(fact, monitored_dir, server): fact: Fixture for file activity (only required to be running). monitored_dir: Temporary directory path for creating the test file. server: The server instance to communicate with. + filenames: List of filenames to create (includes UTF-8 test cases). """ events = [] process = Process.from_proc() diff --git a/tests/test_path_chmod.py b/tests/test_path_chmod.py index 4b62e2c2..dd5e1f36 100644 --- a/tests/test_path_chmod.py +++ b/tests/test_path_chmod.py @@ -1,10 +1,20 @@ import multiprocessing as mp import os +import pytest + from event import Event, EventType, Process -def test_chmod(fact, monitored_dir, server): +@pytest.mark.parametrize("filename", [ + 'chmod.txt', + 'café.txt', + 'файл.txt', + '测试.txt', + '🔒secure.txt', + b'perm\xff\xfe.txt', +]) +def test_chmod(fact, monitored_dir, server, filename): """ Tests changing permissions on a file and verifies the corresponding event is captured by the server @@ -13,18 +23,41 @@ def test_chmod(fact, monitored_dir, server): fact: Fixture for file activity (only required to be runing). monitored_dir: Temporary directory path for creating the test file. server: The server instance to communicate with. + filename: Name of the file to create (includes UTF-8 test cases). """ - # File Under Test - fut = os.path.join(monitored_dir, 'test.txt') + # Handle bytes filenames by converting monitored_dir to bytes + if isinstance(filename, bytes): + fut = os.path.join(os.fsencode(monitored_dir), filename) + else: + fut = os.path.join(monitored_dir, filename) + + # Create the file first + with open(fut, 'w') as f: + f.write('This is a test') + mode = 0o666 os.chmod(fut, mode) - e = Event(process=Process.from_proc(), event_type=EventType.PERMISSION, - file=fut, host_path=fut, mode=mode) + # Convert fut back to string for the Event + # For bytes paths with invalid UTF-8, Rust will use the replacement character U+FFFD + if isinstance(fut, bytes): + fut_str = fut.decode('utf-8', errors='replace') + else: + fut_str = fut - print(f'Waiting for event: {e}') + process = Process.from_proc() + # We expect both CREATION (from file creation) and PERMISSION (from chmod) + events = [ + Event(process=process, event_type=EventType.CREATION, + file=fut_str, host_path=''), + Event(process=process, event_type=EventType.PERMISSION, + file=fut_str, host_path='', mode=mode), + ] - server.wait_events([e]) + for e in events: + print(f'Waiting for event: {e}') + + server.wait_events(events) def test_multiple(fact, monitored_dir, server): diff --git a/tests/test_path_chown.py b/tests/test_path_chown.py index d318f4eb..95cb9ed0 100644 --- a/tests/test_path_chown.py +++ b/tests/test_path_chown.py @@ -1,4 +1,7 @@ import os +import shlex + +import pytest from event import Event, EventType, Process @@ -10,7 +13,15 @@ TEST_GID = 2345 -def test_chown(fact, test_container, server): +@pytest.mark.parametrize("filename", [ + 'chown.txt', + 'café.txt', + 'файл.txt', + '测试.txt', + '👤owner.txt', + b'own\xff\xfe.txt', +]) +def test_chown(fact, test_container, server, filename): """ Execute a chown operation on a file and verifies the corresponding event is captured by the server. @@ -19,15 +30,29 @@ def test_chown(fact, test_container, server): fact: Fixture for file activity (only required to be running). test_container: A container for running commands in. server: The server instance to communicate with. + filename: Name of the file to create (includes UTF-8 test cases). """ + # Handle bytes filenames - convert to string with replacement characters + # Rust will use the same replacement, so the strings will match + if isinstance(filename, bytes): + filename_str = filename.decode('utf-8', errors='replace') + else: + filename_str = filename + # File Under Test - fut = '/container-dir/test.txt' + fut = f'/container-dir/{filename_str}' # Create the file and chown it + # Use shlex.quote to properly escape special characters for shell + fut_quoted = shlex.quote(fut) + touch_cmd_shell = f'touch {fut_quoted}' + chown_cmd_shell = f'chown {TEST_UID}:{TEST_GID} {fut_quoted}' + test_container.exec_run(touch_cmd_shell) + test_container.exec_run(chown_cmd_shell) + + # The args in the event won't have quotes (shell removes them) touch_cmd = f'touch {fut}' chown_cmd = f'chown {TEST_UID}:{TEST_GID} {fut}' - test_container.exec_run(touch_cmd) - test_container.exec_run(chown_cmd) loginuid = pow(2, 32) - 1 touch = Process(pid=None, diff --git a/tests/test_path_unlink.py b/tests/test_path_unlink.py index 3a7cde5b..283897ea 100644 --- a/tests/test_path_unlink.py +++ b/tests/test_path_unlink.py @@ -2,26 +2,57 @@ import os import docker +import pytest from event import Event, EventType, Process -def test_remove(fact, test_file, server): +@pytest.mark.parametrize("filename", [ + 'remove.txt', + 'café.txt', + 'файл.txt', + '测试.txt', + '🗑️delete.txt', + b'rm\xff\xfe.txt', +]) +def test_remove(fact, monitored_dir, server, filename): """ Tests the removal of a file and verifies the corresponding event is captured by the server. Args: fact: Fixture for file activity (only required to be running). - test_file: Temporary file for testing. + monitored_dir: Temporary directory path for creating the test file. server: The server instance to communicate with. + filename: Name of the file to create and remove (includes UTF-8 test cases). """ + # Handle bytes filenames by converting monitored_dir to bytes + if isinstance(filename, bytes): + test_file = os.path.join(os.fsencode(monitored_dir), filename) + else: + test_file = os.path.join(monitored_dir, filename) + + # Create the file first + with open(test_file, 'w') as f: + f.write('This is a test') + + # Remove the file os.remove(test_file) + # Convert test_file back to string for the Event + # For bytes paths with invalid UTF-8, Rust will use the replacement character U+FFFD + if isinstance(test_file, bytes): + test_file_str = test_file.decode('utf-8', errors='replace') + else: + test_file_str = test_file + process = Process.from_proc() + # We expect both CREATION (from file creation) and UNLINK (from removal) events = [ + Event(process=process, event_type=EventType.CREATION, + file=test_file_str, host_path=''), Event(process=process, event_type=EventType.UNLINK, - file=test_file, host_path=test_file), + file=test_file_str, host_path=''), ] server.wait_events(events)