Skip to main content

qk_input_analysis/
strace.rs

1//! Decode strace's path-oriented output. Unknown/truncated records are surfaced.
2
3use std::collections::BTreeMap;
4use std::io::{BufRead, BufReader};
5use std::path::{Component, Path, PathBuf};
6
7use crate::{Access, Category, Operation};
8use anyhow::Result;
9
10pub fn normalize(path: &Path) -> PathBuf {
11    let mut result = PathBuf::new();
12    for part in path.components() {
13        match part {
14            Component::CurDir => {}
15            Component::ParentDir => {
16                result.pop();
17            }
18            _ => result.push(part.as_os_str()),
19        }
20    }
21    result
22}
23
24/// How much of a trace is read, and which accesses are kept.
25pub struct Limits<'a> {
26    /// Lines kept, and their bytes; past either the trace is cut short.
27    pub records: usize,
28    pub bytes: usize,
29    /// Accesses kept; past it the trace is cut short.
30    pub accesses: usize,
31    /// Whether an access to an absolute path is kept; `None` keeps all.
32    pub keep: Option<&'a dyn Fn(&Path) -> bool>,
33}
34
35/// What `inputs analyze` reads of a trace: every access, within a budget
36/// that keeps its report readable.
37pub const ANALYSIS: Limits<'static> = Limits {
38    records: 200_000,
39    bytes: 32 * 1024 * 1024,
40    accesses: 100_000,
41    keep: None,
42};
43
44/// A cwd no path can be in, to tell a line whose paths depend on the
45/// process's working directory from one that names its paths whole: deep
46/// enough that no `..` climbs out of it. A path relative to a descriptor,
47/// `AT_FDCWD` included, which strace names, does not depend on it.
48static UNKNOWN_CWD: std::sync::LazyLock<PathBuf> =
49    std::sync::LazyLock::new(|| std::iter::once("/").chain(["\0"; 64]).collect());
50
51/// Whether a trace line can matter under `keep`: it changes a process's
52/// working directory or makes a process, names a path relative to the
53/// working directory, cannot be decoded, or names a kept path.
54fn relevant(process: u32, call: &str, keep: &dyn Fn(&Path) -> bool) -> bool {
55    let unknown = UNKNOWN_CWD.as_path();
56    let Some((events, change, child)) = decode(process, call, unknown) else {
57        return true;
58    };
59    change.is_some()
60        || child.is_some()
61        || events.iter().any(|event| {
62            let path = Path::new(&event.path);
63            // Still somewhere below the unknown cwd, after any `..`.
64            path.components()
65                .any(|component| component.as_os_str() == "\0")
66                || keep(path)
67        })
68}
69
70pub fn collect(directory: &Path, cwd: &Path) -> Result<(Vec<Access>, Vec<String>)> {
71    collect_within(directory, cwd, &ANALYSIS)
72}
73
74/// The accesses of a trace within `limits`, in the order they were made,
75/// and what kept them from being complete.
76pub fn collect_within(
77    directory: &Path,
78    cwd: &Path,
79    limits: &Limits,
80) -> Result<(Vec<Access>, Vec<String>)> {
81    let mut records = Vec::new();
82    let mut diagnostics = Vec::new();
83    let mut pending = BTreeMap::<u32, (f64, String)>::new();
84    let mut bytes_read = 0usize;
85    let mut files = std::fs::read_dir(directory)?.collect::<std::io::Result<Vec<_>>>()?;
86    files.sort_by_key(|entry| entry.file_name());
87    'files: for entry in &files {
88        let name = entry.file_name().to_string_lossy().into_owned();
89        let Some(process) = name
90            .rsplit('.')
91            .next()
92            .and_then(|id| id.parse::<u32>().ok())
93        else {
94            continue;
95        };
96        let reader = BufReader::new(std::fs::File::open(entry.path())?);
97        for (sequence, line) in reader.lines().enumerate() {
98            let line = line?;
99            if bytes_read > limits.bytes || records.len() >= limits.records {
100                diagnostics.push(format!(
101                    "trace collection exceeded its {} MiB/{}-record budget; report truncated",
102                    limits.bytes / 1024 / 1024,
103                    limits.records
104                ));
105                break 'files;
106            }
107            let Some((time, call)) = line.split_once(' ') else {
108                continue;
109            };
110            let Ok(mut time) = time.parse::<f64>() else {
111                diagnostics.push("trace line has an invalid timestamp".into());
112                continue;
113            };
114            let call = call.trim();
115            if call.starts_with("---") || call.starts_with("+++") {
116                continue;
117            }
118            if let Some(prefix) = call.strip_suffix("<unfinished ...>") {
119                pending.insert(process, (time, prefix.to_owned()));
120                continue;
121            }
122            let call = if call.starts_with("<...") {
123                match (pending.remove(&process), call.split_once("resumed>")) {
124                    (Some((started, prefix)), Some((_, suffix))) => {
125                        time = started;
126                        format!("{prefix}{suffix}")
127                    }
128                    _ => {
129                        diagnostics
130                            .push(format!("unmatched resumed syscall for process {process}"));
131                        continue;
132                    }
133                }
134            } else {
135                call.to_owned()
136            };
137            if limits
138                .keep
139                .is_some_and(|keep| !relevant(process, &call, keep))
140            {
141                continue;
142            }
143            bytes_read = bytes_read.saturating_add(call.len());
144            records.push((time, process, sequence, call));
145        }
146    }
147    if files.is_empty() {
148        diagnostics.push("no syscall trace files were produced".into());
149    }
150    if !pending.is_empty() {
151        diagnostics.push("some syscalls did not finish before recording stopped".into());
152    }
153    records.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
154    let mut directories = BTreeMap::new();
155    let mut accesses = Vec::new();
156    let mut undecoded = 0;
157    let mut undecoded_names = std::collections::BTreeSet::new();
158    // The first record not decoded, quoted so that a report names it.
159    let mut undecoded_example = None;
160    for (_, process, _, call) in records {
161        let current = directories
162            .entry(process)
163            .or_insert_with(|| cwd.to_owned())
164            .clone();
165        match decode(process, &call, &current) {
166            Some((events, change, child)) => {
167                accesses.extend(
168                    events.into_iter().filter(|event| {
169                        limits.keep.is_none_or(|keep| keep(Path::new(&event.path)))
170                    }),
171                );
172                if let Some(directory) = change {
173                    directories.insert(process, directory);
174                }
175                if let Some(child) = child {
176                    directories.insert(child, current);
177                }
178            }
179            None => {
180                undecoded += 1;
181                undecoded_names.insert(call.split('(').next().unwrap_or("unknown").to_owned());
182                undecoded_example
183                    .get_or_insert_with(|| call.chars().take(EXAMPLE_CHARS).collect::<String>());
184            }
185        }
186        if accesses.len() > limits.accesses {
187            accesses.truncate(limits.accesses);
188            diagnostics.push(format!(
189                "recording exceeded {} accesses; report truncated",
190                limits.accesses
191            ));
192            break;
193        }
194    }
195    if undecoded > 0 {
196        diagnostics.push(format!(
197            "{undecoded} syscall records could not be decoded: {}, such as {:?}",
198            undecoded_names.into_iter().collect::<Vec<_>>().join(", "),
199            undecoded_example.unwrap_or_default()
200        ));
201    }
202    diagnostics.sort();
203    diagnostics.dedup();
204    Ok((accesses, diagnostics))
205}
206
207type Decoded = (Vec<Access>, Option<PathBuf>, Option<u32>);
208
209/// How much of a record a diagnostic quotes.
210const EXAMPLE_CHARS: usize = 200;
211
212fn decode(process: u32, call: &str, cwd: &Path) -> Option<Decoded> {
213    let (name, tail) = call.split_once('(')?;
214    // A process ending, which strace may write without a result, a call
215    // strace could not name in a thread the ending cut short, or a signal,
216    // which a process sending it to itself can cut short: no access, however
217    // strace writes it.
218    if matches!(
219        name,
220        "exit"
221            | "exit_group"
222            | "???"
223            | "kill"
224            | "tkill"
225            | "tgkill"
226            | "rt_sigqueueinfo"
227            | "rt_tgsigqueueinfo"
228            | "pidfd_send_signal"
229    ) {
230        return Some((Vec::new(), None, None));
231    }
232    let (args, result) = tail.rsplit_once(" = ")?;
233    let args = args.trim_end().strip_suffix(')')?;
234    let args = arguments(args);
235    let success = !result.starts_with("-1") && !result.starts_with('?');
236    let status = if result.starts_with("-1 ENOENT") {
237        "missing"
238    } else if result.starts_with("-1") {
239        result.split_whitespace().nth(1).unwrap_or("failed")
240    } else if success {
241        "success"
242    } else {
243        "unknown"
244    };
245    let mut events = Vec::new();
246    let mut change = None;
247    let mut child = None;
248    let mut emit = |path: PathBuf, operation| {
249        events.push(Access {
250            process,
251            path: path.to_string_lossy().into_owned(),
252            resolved_path: None,
253            keyed_path: None,
254            package: None,
255            operation,
256            result: status.to_owned(),
257            category: Category::Uncovered,
258        });
259    };
260    let path = |index: usize, dir: Option<usize>| -> Option<PathBuf> {
261        let value = quoted(args.get(index)?)?;
262        let value = Path::new(&value);
263        if value.is_absolute() {
264            return Some(normalize(value));
265        }
266        let base = match dir {
267            Some(index) => descriptor(args[index])
268                .or_else(|| args[index].starts_with("AT_FDCWD").then(|| cwd.to_owned()))?,
269            _ => cwd.to_owned(),
270        };
271        Some(normalize(&base.join(value)))
272    };
273    match name {
274        "clone" | "clone3" | "fork" | "vfork" => {
275            if success {
276                child = result.split_whitespace().next()?.parse().ok();
277            }
278        }
279        "chdir" => {
280            if success {
281                change = Some(path(0, None)?);
282            }
283        }
284        "fchdir" => {
285            if success {
286                change = Some(descriptor(args.first()?)?);
287            }
288        }
289        "getcwd" => {
290            if success {
291                change = Some(PathBuf::from(quoted(args.first()?)?));
292            }
293        }
294        "open" | "openat" | "openat2" | "creat" => {
295            let at = name.starts_with("openat");
296            let file = path(usize::from(at), at.then_some(0))?;
297            let flags = args.get(usize::from(at) + 1).copied().unwrap_or("");
298            if name != "creat" && !flags.contains("O_WRONLY") && !flags.contains("O_PATH") {
299                emit(file.clone(), Operation::OpenRead);
300            } else if flags.contains("O_PATH") {
301                emit(file.clone(), Operation::Metadata);
302            }
303            if name == "creat"
304                || flags.contains("O_WRONLY")
305                || flags.contains("O_RDWR")
306                || flags.contains("O_CREAT")
307                || flags.contains("O_TRUNC")
308            {
309                emit(file, Operation::Write);
310            }
311            // -yy gives the fd's actual target without resolving the path later.
312            let resolved = success.then(|| descriptor(result)).flatten();
313            for event in &mut events {
314                event.resolved_path = resolved
315                    .as_ref()
316                    .map(|path| path.to_string_lossy().into_owned());
317            }
318        }
319        "getdents64" => emit(descriptor(args.first()?)?, Operation::ListDirectory),
320        "readlink" => emit(path(0, None)?, Operation::ReadLink),
321        "readlinkat" => emit(path(1, Some(0))?, Operation::ReadLink),
322        "stat" | "lstat" | "stat64" | "lstat64" | "access" | "statfs" | "statfs64" | "getxattr"
323        | "lgetxattr" | "listxattr" | "llistxattr" => emit(path(0, None)?, Operation::Metadata),
324        "newfstatat" | "fstatat64" | "statx" | "faccessat" | "faccessat2" | "name_to_handle_at" => {
325            // With an empty path, or a NULL one with AT_EMPTY_PATH, the call
326            // reads the descriptor itself: a pipe, socket or other descriptor
327            // that is no file names no path. A NULL path without the flag
328            // names nothing, as a runtime passes to see whether the call
329            // exists.
330            let null = args.get(1) == Some(&"NULL");
331            // The flags: statx's third argument, name_to_handle_at's fifth,
332            // the others' fourth; faccessat takes none.
333            let flags = match name {
334                "statx" => args.get(2),
335                "name_to_handle_at" => args.get(4),
336                "faccessat" => None,
337                _ => args.get(3),
338            };
339            let probe = null && !flags.is_some_and(|flags| flags.contains("AT_EMPTY_PATH"));
340            let empty = null || quoted(args.get(1)?).is_some_and(|path| path.is_empty());
341            let unnamed = empty && args[0].contains('<') && descriptor(args[0]).is_none();
342            if !probe && !unnamed {
343                let target = if null {
344                    normalize(
345                        &descriptor(args[0])
346                            .or_else(|| args[0].starts_with("AT_FDCWD").then(|| cwd.to_owned()))?,
347                    )
348                } else {
349                    path(1, Some(0))?
350                };
351                emit(target, Operation::Metadata);
352            }
353        }
354        "inotify_add_watch" => emit(path(1, None)?, Operation::Metadata),
355        "unlink" | "rmdir" => emit(path(0, None)?, Operation::Delete),
356        "unlinkat" => emit(path(1, Some(0))?, Operation::Delete),
357        "rename" => {
358            emit(path(0, None)?, Operation::Delete);
359            emit(path(1, None)?, Operation::Rename);
360        }
361        "renameat" | "renameat2" => {
362            emit(path(1, Some(0))?, Operation::Delete);
363            emit(path(3, Some(2))?, Operation::Rename);
364        }
365        "mkdir" | "truncate" | "chmod" | "chown" | "lchown" | "utime" | "utimes" | "mknod"
366        | "setxattr" | "lsetxattr" | "removexattr" | "lremovexattr" => {
367            emit(path(0, None)?, Operation::Write)
368        }
369        "mkdirat" | "fchmodat" | "fchmodat2" | "fchownat" | "utimensat" | "mknodat"
370        | "futimesat" => emit(path(1, Some(0))?, Operation::Write),
371        "symlink" => emit(path(1, None)?, Operation::Write),
372        "symlinkat" => emit(path(2, Some(1))?, Operation::Write),
373        "link" => {
374            emit(path(0, None)?, Operation::Metadata);
375            emit(path(1, None)?, Operation::Write);
376        }
377        "linkat" => {
378            emit(path(1, Some(0))?, Operation::Metadata);
379            emit(path(3, Some(2))?, Operation::Write);
380        }
381        "execve" | "execveat" | "exit" | "exit_group" | "wait4" | "waitid" | "close" | "dup"
382        | "dup2" | "dup3" | "kill" | "tkill" | "tgkill" | "rt_sigqueueinfo"
383        | "rt_tgsigqueueinfo" | "pidfd_send_signal" | "pidfd_open" => {}
384        _ => return None,
385    }
386    Some((events, change, child))
387}
388
389fn descriptor(arg: &str) -> Option<PathBuf> {
390    let (_, path) = arg.split_once('<')?;
391    let path = path.strip_suffix('>')?;
392    let path = path.strip_suffix(" (deleted)").unwrap_or(path);
393    path.starts_with('/').then(|| PathBuf::from(path))
394}
395
396/// Separate syscall arguments without splitting strings, structures or fd paths.
397fn arguments(text: &str) -> Vec<&str> {
398    let mut result = Vec::new();
399    let (mut start, mut nesting, mut string, mut escaped) = (0, 0usize, false, false);
400    for (index, ch) in text.char_indices() {
401        if string {
402            if escaped {
403                escaped = false;
404            } else if ch == '\\' {
405                escaped = true;
406            } else if ch == '"' {
407                string = false;
408            }
409            continue;
410        }
411        match ch {
412            '"' => string = true,
413            '(' | '[' | '{' | '<' => nesting += 1,
414            ')' | ']' | '}' | '>' => nesting = nesting.saturating_sub(1),
415            ',' if nesting == 0 => {
416                result.push(text[start..index].trim());
417                start = index + 1;
418            }
419            _ => {}
420        }
421    }
422    result.push(text[start..].trim());
423    result
424}
425
426fn quoted(text: &str) -> Option<String> {
427    let bytes = text.as_bytes();
428    if bytes.first() != Some(&b'"') {
429        return None;
430    }
431    let mut result = Vec::new();
432    let mut index = 1;
433    while index < bytes.len() {
434        let byte = bytes[index];
435        index += 1;
436        if byte == b'"' {
437            if text[index..].trim().starts_with("...") {
438                return None;
439            }
440            return String::from_utf8(result).ok();
441        }
442        if byte != b'\\' {
443            result.push(byte);
444            continue;
445        }
446        let escaped = *bytes.get(index)?;
447        index += 1;
448        result.push(match escaped {
449            b'n' => b'\n',
450            b'r' => b'\r',
451            b't' => b'\t',
452            b'\\' => b'\\',
453            b'"' => b'"',
454            b'0'..=b'7' => {
455                let mut value = u16::from(escaped - b'0');
456                for _ in 0..2 {
457                    if let Some(next @ b'0'..=b'7') = bytes.get(index) {
458                        value = value * 8 + u16::from(*next - b'0');
459                        index += 1;
460                    } else {
461                        break;
462                    }
463                }
464                u8::try_from(value).ok()?
465            }
466            _ => return None,
467        });
468    }
469    None
470}
471
472#[cfg(all(test, unix))]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn resolves_cwd_dirfds_and_failed_probes_without_confusing_writes() {
478        let cwd = Path::new("/repo/app");
479        let (events, _, _) = decode(
480            1,
481            "openat(3</repo/shared>, \"a b.json\", O_RDONLY|O_CLOEXEC) = 4</repo/shared/a b.json>",
482            cwd,
483        )
484        .unwrap();
485        assert_eq!(events[0].path, "/repo/shared/a b.json");
486        let (events, _, _) = decode(1, "newfstatat(AT_FDCWD, \"../missing\", 0x123, 0) = -1 ENOENT (No such file or directory)", cwd).unwrap();
487        assert_eq!(events[0].result, "missing");
488        assert_eq!(events[0].path, "/repo/missing");
489        let (events, _, _) = decode(
490            1,
491            "openat(AT_FDCWD, \"out\", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 5</repo/app/out>",
492            cwd,
493        )
494        .unwrap();
495        assert_eq!(events.len(), 1);
496        assert_eq!(events[0].operation, Operation::Write);
497        let (events, _, _) = decode(
498            1,
499            "getdents64(5</repo/app/templates>, [{d_name=\"a\"}], 4096) = 24",
500            cwd,
501        )
502        .unwrap();
503        assert_eq!(events[0].operation, Operation::ListDirectory);
504    }
505
506    #[test]
507    fn detects_truncated_strings_and_decodes_escaped_paths() {
508        assert_eq!(quoted(r#""a\040b\"c""#), Some("a b\"c".into()));
509        assert_eq!(quoted(r#""long"..."#), None);
510        assert_eq!(
511            arguments(r#"3</a,b>, "c,d", {flags=O_RDONLY, mode=0}"#).len(),
512            3
513        );
514    }
515
516    #[test]
517    fn accepts_padded_exit_records_and_uses_annotated_cwd() {
518        assert!(decode(1, "exit_group(0)         = ?", Path::new("/repo")).is_some());
519        // As a process is torn down.
520        for call in ["exit_group(0)", "???()   = ?", "???( <detached ...>"] {
521            let (events, _, _) = decode(1, call, Path::new("/repo")).unwrap();
522            assert!(events.is_empty(), "{call}");
523        }
524        let (events, _, _) = decode(
525            2,
526            "openat(AT_FDCWD</repo/sub>, \"file\", O_RDONLY) = 3</repo/sub/file>",
527            Path::new("/repo"),
528        )
529        .unwrap();
530        assert_eq!(events[0].path, "/repo/sub/file");
531    }
532
533    #[test]
534    fn signals_and_path_metadata_do_not_disable_successful_collection() {
535        let directory = tempfile::tempdir().unwrap();
536        std::fs::write(directory.path().join("command-0.123"), concat!(
537            "1.000 tgkill(123, 124, SIGURG) = 0\n",
538            "1.001 kill(125, SIGTERM) = 0\n",
539            "1.002 lgetxattr(\"f\", \"security.selinux\", 0x0, 0) = -1 ENODATA (No data available)\n",
540            "1.003 inotify_add_watch(3, \"src\", IN_MODIFY) = 1\n",
541            "1.004 setxattr(\"f\", \"user.test\", 0x123, 1, 0) = 0\n",
542            "1.005 futimesat(AT_FDCWD, \"f\", NULL) = 0\n",
543            "1.006 name_to_handle_at(AT_FDCWD, \"f\", 0x123, 0x124, 0) = 0\n",
544        )).unwrap();
545        let (events, diagnostics) = collect(directory.path(), Path::new("/repo")).unwrap();
546        assert!(diagnostics.is_empty(), "{diagnostics:?}");
547        assert_eq!(events.len(), 5);
548        assert_eq!(events[0].path, "/repo/f");
549        assert_eq!(events[0].operation, Operation::Metadata);
550        assert_eq!(events[0].result, "ENODATA");
551        assert_eq!(events[1].path, "/repo/src");
552        assert_eq!(events[1].operation, Operation::Metadata);
553        assert_eq!(events[2].operation, Operation::Write);
554        assert_eq!(events[3].operation, Operation::Write);
555        assert_eq!(events[4].operation, Operation::Metadata);
556        assert!(decode(123, "chroot(\"/other\") = 0", Path::new("/repo")).is_none());
557    }
558
559    #[test]
560    fn a_filter_keeps_only_what_can_matter() {
561        let directory = tempfile::tempdir().unwrap();
562        std::fs::write(
563            directory.path().join("command-0.10"),
564            concat!(
565                "1.000 openat(AT_FDCWD</repo>, \"node_modules/x/index.js\", O_RDONLY) = 3</repo/node_modules/x/index.js>\n",
566                "1.001 openat(AT_FDCWD</repo>, \"src/a.ts\", O_RDONLY) = 3</repo/src/a.ts>\n",
567                "1.002 openat(AT_FDCWD</repo>, \"/usr/lib/libc.so\", O_RDONLY) = 3</usr/lib/libc.so>\n",
568                "1.003 chdir(\"lib\") = 0\n",
569                // Relative to the working directory, which the filter cannot
570                // know: kept, and placed once it is known.
571                "1.004 stat(\"../src/b.ts\", {...}) = 0\n",
572            ),
573        )
574        .unwrap();
575        let keep =
576            |path: &Path| path.starts_with("/repo") && !path.starts_with("/repo/node_modules");
577        let limits = Limits {
578            keep: Some(&keep),
579            ..ANALYSIS
580        };
581        let (events, diagnostics) =
582            collect_within(directory.path(), Path::new("/repo"), &limits).unwrap();
583        assert!(diagnostics.is_empty(), "{diagnostics:?}");
584        let paths: Vec<&str> = events.iter().map(|event| event.path.as_str()).collect();
585        assert_eq!(paths, ["/repo/src/a.ts", "/repo/src/b.ts"]);
586        // Only kept accesses count toward the budget.
587        let tight = Limits {
588            accesses: 2,
589            keep: Some(&keep),
590            ..ANALYSIS
591        };
592        let (_, diagnostics) =
593            collect_within(directory.path(), Path::new("/repo"), &tight).unwrap();
594        assert!(diagnostics.is_empty(), "{diagnostics:?}");
595    }
596
597    #[test]
598    fn stats_of_pipes_and_sockets_name_no_path() {
599        let cwd = Path::new("/repo");
600        for call in [
601            "newfstatat(1<pipe:[123]>, \"\", {st_mode=S_IFIFO|0600, ...}, AT_EMPTY_PATH) = 0",
602            "statx(17<socket:[456]>, \"\", AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {...}) = 0",
603            // Node's probe for statx, and one on a descriptor whose name
604            // holds the flag's name, which is no flag.
605            "statx(0</dev/null<char 1:3>>, NULL, AT_STATX_SYNC_AS_STAT, STATX_ALL, NULL) = -1 EFAULT (Bad address)",
606            "statx(3</repo/AT_EMPTY_PATH.txt>, NULL, AT_STATX_SYNC_AS_STAT, STATX_ALL, NULL) = -1 EFAULT (Bad address)",
607        ] {
608            let (events, _, _) = decode(1, call, cwd).unwrap();
609            assert!(events.is_empty(), "{call}");
610        }
611        // A file's descriptor still names its file, with an empty path or,
612        // as newer kernels take, a NULL one.
613        for call in [
614            "newfstatat(3</repo/a.txt>, \"\", {st_mode=S_IFREG|0644, ...}, AT_EMPTY_PATH) = 0",
615            "statx(3</repo/a.txt>, NULL, AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {...}) = 0",
616        ] {
617            let (events, _, _) = decode(1, call, cwd).unwrap();
618            assert_eq!(events[0].path, "/repo/a.txt", "{call}");
619        }
620    }
621
622    #[test]
623    fn a_signal_is_no_access_however_strace_writes_it() {
624        let cwd = Path::new("/repo");
625        for call in [
626            "tgkill(42, 43, SIGRTMIN) = 0",
627            "tgkill(42, 42, SIGKILL <unfinished ...>) = ?",
628            "tgkill(42, 42, SIGABRT",
629            "kill(42, SIGTERM) = 0",
630        ] {
631            let (events, _, _) = decode(1, call, cwd).unwrap_or_else(|| panic!("{call}"));
632            assert!(events.is_empty(), "{call}");
633        }
634    }
635}