From 21d7e40b2ccc2a3209341264bae97cc0a64cc068 Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Sun, 29 Jun 2025 17:30:53 +0200 Subject: [PATCH 3/3] Add option -d,--dir Fixes #48 --- fatrace.8 | 27 ++++++++++++- fatrace.c | 50 ++++++++++++++++++++++- tests/test.py | 107 ++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 168 insertions(+), 16 deletions(-) diff --git a/fatrace.8 b/fatrace.8 index 7117ee2..aa355b1 100644 --- a/fatrace.8 +++ b/fatrace.8 @@ -1,4 +1,4 @@ -.TH fatrace 8 "August 20, 2020" "Martin Pitt" +.TH fatrace 8 "September 5, 2025" "Martin Pitt" .SH NAME @@ -10,6 +10,10 @@ fatrace \- report system wide file access events [ .I OPTIONS ] +[ +-- +] +[ \fIDIR\fR... ] .SH DESCRIPTION @@ -212,6 +216,27 @@ Print information about all parent processes. .B \-e\fR, \fB\-\-exe Print executable path. +.TP +.B \-d \fIDIR\fR, \fB\-\-dir=\fIDIR\fR +Show only events where the affected file is \fIdirectly\fR under this directory. +Can be specified multiple times to include events from several directories. +.IP +This is \fInot\fR recursive. For example, \fB\-d /home/user\fR will show events +for \fB/home/user/file\fR but not for \fB/home/user/subdir/file\fR. +.IP +\fIDIR\fRs can also be specified at the end of the command line, advisably +preceded by the \fB\-\-\fR separator. As long as no directories will be created +or moved under a subtree, it's possible to watch that subtree like so: +.RS +.IP "" 4 +fatrace -- $(find /path/to/subtree -type d) +.RE +.IP +The attachment is to a directory inode, not the path. For example, this means +that 1) If you move a watched directory while fatrace runs, you may receive +events for a path that is not listed on the command line; 2) If you delete and +recreate a watched directory you will no longer receive events. + .TP .B \-h \fR, \fB\-\-help Print help and exit. diff --git a/fatrace.c b/fatrace.c index 836c431..1c9042d 100644 --- a/fatrace.c +++ b/fatrace.c @@ -48,6 +48,9 @@ #define BUFSIZE 256*1024 +/* Likely to be less than /proc/sys/fs/fanotify/max_user_marks */ +#define MAX_DIRS 4096 + /* https://man7.org/linux/man-pages/man5/proc_pid_comm.5.html ; not defined in any include file */ #ifndef TASK_COMM_LEN #define TASK_COMM_LEN 16 @@ -73,6 +76,8 @@ static char* option_comm = NULL; static bool option_json = false; static bool option_parents = false; static bool option_exe = false; +static const char *option_dirs[MAX_DIRS]; +static unsigned int option_dirs_len = 0; /* --time alarm sets this to 0 */ static volatile int running = 1; @@ -594,6 +599,26 @@ do_mark (int fan_fd, const char *dir, bool fatal) static void setup_fanotify (int fan_fd) { + if (option_dirs_len > 0) { + mark_mode = FAN_MARK_ADD; + char resolved[PATH_MAX]; + struct stat st; + for (unsigned i = 0; i < option_dirs_len; i++) { + if (realpath(option_dirs[i], resolved) && + stat(resolved, &st) == 0) { + if (S_ISDIR(st.st_mode)) + do_mark (fan_fd, resolved, false); + else + errx(EXIT_FAILURE, + "Not a directory: %s", option_dirs[i]); + } + else + err(EXIT_FAILURE, + "Cannot resolve directory: %s", option_dirs[i]); + } + return; + } + FILE* mounts; struct mntent* mount; @@ -644,7 +669,7 @@ setup_fanotify (int fan_fd) static void help (void) { - puts ("Usage: fatrace [options...] \n" + puts ("Usage: fatrace [options...] [--] [DIR...]\n" "\n" "Options:\n" " -c, --current-mount Only record events on partition/mount of\n" @@ -663,6 +688,10 @@ help (void) " -j, --json Write events in JSONL format.\n" " -P, --parents Include information about all parent processes.\n" " -e, --exe Add executable path to events.\n" +" -d DIR, --dir=DIR Show only events on files directly under this\n" +" directory. NOT recursive. Can be specified\n" +" multiple times. DIRs can also be specified at\n" +" the end of the command line.\n" " -h, --help Show help."); } @@ -691,12 +720,13 @@ parse_args (int argc, char** argv) {"json", no_argument, 0, 'j'}, {"parents", no_argument, 0, 'P'}, {"exe", no_argument, 0, 'e'}, + {"dir", required_argument, 0, 'd'}, {"help", no_argument, 0, 'h'}, {0, 0, 0, 0 } }; while (1) { - c = getopt_long (argc, argv, "C:co:s:tup:f:jPeh", long_options, NULL); + c = getopt_long (argc, argv, "C:co:s:tup:f:jPed:h", long_options, NULL); if (c == -1) break; @@ -801,6 +831,13 @@ parse_args (int argc, char** argv) option_exe = true; break; + case 'd': + if (option_dirs_len >= MAX_DIRS) + errx (EXIT_FAILURE, "Error: Too many --dir arguments" + " (maximum is %d).", MAX_DIRS); + option_dirs[option_dirs_len++] = optarg; + break; + case 'h': help (); exit (EXIT_SUCCESS); @@ -813,6 +850,15 @@ parse_args (int argc, char** argv) errx (EXIT_FAILURE, "Internal error: unexpected option '%c'", c); } } + for (int i = optind; i < argc; i++) { + if (option_dirs_len >= MAX_DIRS) + errx (EXIT_FAILURE, "Error: Too many --dir and DIR arguments" + " (maximum is %d).", MAX_DIRS); + option_dirs[option_dirs_len++] = argv[i]; + } + if (option_current_mount && option_dirs_len > 0) + errx (EXIT_FAILURE, + "Error: -c,--current-mount and -d,--dir are mutually exclusive."); } static void diff --git a/tests/test.py b/tests/test.py index 0dd904a..f86c0e1 100644 --- a/tests/test.py +++ b/tests/test.py @@ -72,18 +72,30 @@ class FatraceRunner: self.log_content = f.read() self.log_dir.cleanup() - def assert_log(self, pattern: str) -> None: + def has_log(self, pattern: str) -> bool: """Check if a regex pattern exists in the log content.""" assert self.log_content, "Need to call run() first" - if not re.search(pattern, self.log_content, re.MULTILINE): - raise AssertionError(f"""Pattern not found in log: {pattern} ----- Log content ---- -{self.log_content} ------------------""") + return bool(re.search(pattern, self.log_content, re.MULTILINE)) - def assert_json(self, condition_func: Callable[[dict], bool]) -> None: + def assert_log(self, pattern: str) -> None: + if self.has_log(pattern): + return + raise AssertionError(f"Pattern not found in log: {pattern}\n" + "---- Log content ----\n" + f"{self.log_content}\n" + "-----------------") + + def assert_not_log(self, pattern: str) -> None: + if not self.has_log(pattern): + return + raise AssertionError(f"Pattern found in log: {pattern}\n" + "---- Log content ----\n" + f"{self.log_content}\n" + "-----------------") + + def has_json(self, condition_func: Callable[[dict], bool]) -> bool: """Check if any JSON line matches the condition function.""" assert self.log_content, "Need to call run() first" @@ -94,16 +106,27 @@ class FatraceRunner: entry = json.loads(line) try: if condition_func(entry): - return + return True except KeyError: # Ignore entries that do not match the expected structure pass + return False - raise AssertionError(f"""No JSON entry matched condition ----- Log content ---- -{self.log_content} ------------------""") - + def assert_json(self, condition_func: Callable[[dict], bool]) -> None: + if self.has_json(condition_func): + return + raise AssertionError("No JSON entry matched condition\n" + "---- Log content ----\n" + f"{self.log_content}\n" + "-----------------") + + def assert_not_json(self, condition_func: Callable[[dict], bool]) -> None: + if not self.has_json(condition_func): + return + raise AssertionError("At least one JSON entry matched condition\n" + "---- Log content ----\n" + f"{self.log_content}\n" + "-----------------") class FatraceTests(unittest.TestCase): def setUp(self): @@ -574,6 +597,64 @@ with open("{python_pid_file}", "w") as f: f.write(f"{{os.getpid()}}\\n") "path" not in e )) + def test_dir(self): + yes1 = str(self.tmp_path / "yes-1") + yes2 = str(self.tmp_path / "yes-2") + no1 = str(self.tmp_path / "no-1") + + exe(["mkdir", yes1]) + exe(["mkdir", yes2]) + exe(["mkdir", no1]) + + f = FatraceRunner(["-s", "3", "-d", yes1, f"--dir={yes2}"]) + f_json = FatraceRunner(["-s", "3", "--json", "--", yes1, yes2]) + + slow_exe(["mkdir", f"{yes1}/subA"]) + slow_exe(["mkdir", f"{no1}/subB"]) + + slow_exe(["touch", f"{yes1}/yesC"]) + slow_exe(["touch", f"{yes1}/subA/noD"]) + slow_exe(["touch", f"{yes2}/yesE"]) + slow_exe(["touch", f"{no1}/noF"]) + slow_exe(["touch", f"{no1}/subB/noG"]) + + slow_exe(["mv", yes1, yes2]) + new_yes1 = str(self.tmp_path / "yes-2" / "yes-1") + slow_exe(["mv", no1, yes2]) + new_no1 = str(self.tmp_path / "yes-2" / "no-1") + + slow_exe(["touch", f"{new_yes1}/yesH"]) + slow_exe(["touch", f"{new_yes1}/subA/noI"]) + slow_exe(["touch", f"{new_no1}/noJ"]) + slow_exe(["touch", f"{new_no1}/subB/noK"]) + + f.finish() + f_json.finish() + + f.assert_log (rf"^mkdir\([0-9]*\): \+ +{re.escape(yes1)}") + f.assert_not_log(rf"^mkdir\([0-9]*\): \+ +{re.escape(no1)}") + f.assert_log (rf"^touch\([0-9]*\): C?WO? +{re.escape(yes1)}/yesC") + f.assert_not_log(rf"^touch\([0-9]*\): C?WO? +{re.escape(yes1)}/subA/noD") + f.assert_log (rf"^touch\([0-9]*\): C?WO? +{re.escape(yes2)}/yesE") + f.assert_not_log(rf"^touch\([0-9]*\): C?WO? +{re.escape(no1)}/noF") + f.assert_not_log(rf"^touch\([0-9]*\): C?WO? +{re.escape(no1)}/subB/noG") + f.assert_log (rf"^touch\([0-9]*\): C?WO? +{re.escape(new_yes1)}/yesH") + f.assert_not_log(rf"^touch\([0-9]*\): C?WO? +{re.escape(new_yes1)}/subA/noI") + f.assert_not_log(rf"^touch\([0-9]*\): C?WO? +{re.escape(new_no1)}/noJ") + f.assert_not_log(rf"^touch\([0-9]*\): C?WO? +{re.escape(new_no1)}/subB/noK") + + f_json.assert_json (lambda e: e["comm"] == "mkdir" and e["types"] == "+" and e["path"] == yes1) + f_json.assert_not_json(lambda e: e["comm"] == "mkdir" and e["types"] == "+" and e["path"] == no1) + f_json.assert_json (lambda e: e["comm"] == "touch" and "W" in e["types"] and e["path"] == f"{yes1}/yesC") + f_json.assert_not_json(lambda e: e["comm"] == "touch" and "W" in e["types"] and e["path"] == f"{yes1}/subA/noD") + f_json.assert_json (lambda e: e["comm"] == "touch" and "W" in e["types"] and e["path"] == f"{yes2}/yesE") + f_json.assert_not_json(lambda e: e["comm"] == "touch" and "W" in e["types"] and e["path"] == f"{no1}/noF") + f_json.assert_not_json(lambda e: e["comm"] == "touch" and "W" in e["types"] and e["path"] == f"{no1}/subB/noG") + f_json.assert_json (lambda e: e["comm"] == "touch" and "W" in e["types"] and e["path"] == f"{new_yes1}/yesH") + f_json.assert_not_json(lambda e: e["comm"] == "touch" and "W" in e["types"] and e["path"] == f"{new_yes1}/subA/noI") + f_json.assert_not_json(lambda e: e["comm"] == "touch" and "W" in e["types"] and e["path"] == f"{new_no1}/noJ") + f_json.assert_not_json(lambda e: e["comm"] == "touch" and "W" in e["types"] and e["path"] == f"{new_no1}/subB/noK") + @unittest.skipIf("container" in os.environ, "Not supported in container environment") @unittest.skipIf(os.path.exists("/sysroot/ostree"), "Test does not work on OSTree") @unittest.skipIf(root_is_btrfs, "FANOTIFY does not work on btrfs, https://github.com/martinpitt/fatrace/issues/3") -- 2.46.3