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    // A lookup that never returned, cut short by its process ending: what it
236    // would have found reached nothing, and strace may not have read its path.
237    if result.starts_with('?') && lookup(name) {
238        return Some((Vec::new(), None, None));
239    }
240    let success = !result.starts_with("-1") && !result.starts_with('?');
241    let status = if result.starts_with("-1 ENOENT") {
242        "missing"
243    } else if result.starts_with("-1") {
244        result.split_whitespace().nth(1).unwrap_or("failed")
245    } else if success {
246        "success"
247    } else {
248        "unknown"
249    };
250    let mut events = Vec::new();
251    let mut change = None;
252    let mut child = None;
253    let mut emit = |path: PathBuf, operation| {
254        events.push(Access {
255            process,
256            path: path.to_string_lossy().into_owned(),
257            resolved_path: None,
258            keyed_path: None,
259            package: None,
260            operation,
261            result: status.to_owned(),
262            category: Category::Uncovered,
263        });
264    };
265    let path = |index: usize, dir: Option<usize>| -> Option<PathBuf> {
266        let value = quoted(args.get(index)?)?;
267        let value = Path::new(&value);
268        if value.is_absolute() {
269            return Some(normalize(value));
270        }
271        let base = match dir {
272            Some(index) => descriptor(args[index])
273                .or_else(|| args[index].starts_with("AT_FDCWD").then(|| cwd.to_owned()))?,
274            _ => cwd.to_owned(),
275        };
276        Some(normalize(&base.join(value)))
277    };
278    match name {
279        "clone" | "clone3" | "fork" | "vfork" => {
280            if success {
281                child = result.split_whitespace().next()?.parse().ok();
282            }
283        }
284        "chdir" => {
285            if success {
286                change = Some(path(0, None)?);
287            }
288        }
289        "fchdir" => {
290            if success {
291                change = Some(descriptor(args.first()?)?);
292            }
293        }
294        "getcwd" => {
295            if success {
296                change = Some(PathBuf::from(quoted(args.first()?)?));
297            }
298        }
299        "open" | "openat" | "openat2" | "creat" => {
300            let at = name.starts_with("openat");
301            let file = path(usize::from(at), at.then_some(0))?;
302            let flags = args.get(usize::from(at) + 1).copied().unwrap_or("");
303            if name != "creat" && !flags.contains("O_WRONLY") && !flags.contains("O_PATH") {
304                emit(file.clone(), Operation::OpenRead);
305            } else if flags.contains("O_PATH") {
306                emit(file.clone(), Operation::Metadata);
307            }
308            if name == "creat"
309                || flags.contains("O_WRONLY")
310                || flags.contains("O_RDWR")
311                || flags.contains("O_CREAT")
312                || flags.contains("O_TRUNC")
313            {
314                emit(file, Operation::Write);
315            }
316            // -yy gives the fd's actual target without resolving the path later.
317            let resolved = success.then(|| descriptor(result)).flatten();
318            for event in &mut events {
319                event.resolved_path = resolved
320                    .as_ref()
321                    .map(|path| path.to_string_lossy().into_owned());
322            }
323        }
324        "getdents64" => emit(descriptor(args.first()?)?, Operation::ListDirectory),
325        "readlink" => emit(path(0, None)?, Operation::ReadLink),
326        "readlinkat" => emit(path(1, Some(0))?, Operation::ReadLink),
327        "stat" | "lstat" | "stat64" | "lstat64" | "access" | "statfs" | "statfs64" | "getxattr"
328        | "lgetxattr" | "listxattr" | "llistxattr" => emit(path(0, None)?, Operation::Metadata),
329        "newfstatat" | "fstatat64" | "statx" | "faccessat" | "faccessat2" | "name_to_handle_at" => {
330            // With an empty path, or a NULL one with AT_EMPTY_PATH, the call
331            // reads the descriptor itself: a pipe, socket or other descriptor
332            // that is no file names no path. A NULL path without the flag
333            // names nothing, as a runtime passes to see whether the call
334            // exists.
335            let null = args.get(1) == Some(&"NULL");
336            // The flags: statx's third argument, name_to_handle_at's fifth,
337            // the others' fourth; faccessat takes none.
338            let flags = match name {
339                "statx" => args.get(2),
340                "name_to_handle_at" => args.get(4),
341                "faccessat" => None,
342                _ => args.get(3),
343            };
344            let probe = null && !flags.is_some_and(|flags| flags.contains("AT_EMPTY_PATH"));
345            let empty = null || quoted(args.get(1)?).is_some_and(|path| path.is_empty());
346            let unnamed = empty && args[0].contains('<') && descriptor(args[0]).is_none();
347            if !probe && !unnamed {
348                let target = if null {
349                    normalize(
350                        &descriptor(args[0])
351                            .or_else(|| args[0].starts_with("AT_FDCWD").then(|| cwd.to_owned()))?,
352                    )
353                } else {
354                    path(1, Some(0))?
355                };
356                emit(target, Operation::Metadata);
357            }
358        }
359        "inotify_add_watch" => emit(path(1, None)?, Operation::Metadata),
360        "unlink" | "rmdir" => emit(path(0, None)?, Operation::Delete),
361        "unlinkat" => emit(path(1, Some(0))?, Operation::Delete),
362        "rename" => {
363            emit(path(0, None)?, Operation::Delete);
364            emit(path(1, None)?, Operation::Rename);
365        }
366        "renameat" | "renameat2" => {
367            emit(path(1, Some(0))?, Operation::Delete);
368            emit(path(3, Some(2))?, Operation::Rename);
369        }
370        "mkdir" | "truncate" | "chmod" | "chown" | "lchown" | "utime" | "utimes" | "mknod"
371        | "setxattr" | "lsetxattr" | "removexattr" | "lremovexattr" => {
372            emit(path(0, None)?, Operation::Write)
373        }
374        // `futimens` is `utimensat` with a null path: the descriptor's own
375        // file.
376        "utimensat" if args.get(1).is_some_and(|path| *path == "NULL") => {
377            emit(normalize(&descriptor(args[0])?), Operation::Write)
378        }
379        "mkdirat" | "fchmodat" | "fchmodat2" | "fchownat" | "utimensat" | "mknodat"
380        | "futimesat" => emit(path(1, Some(0))?, Operation::Write),
381        "symlink" => emit(path(1, None)?, Operation::Write),
382        "symlinkat" => emit(path(2, Some(1))?, Operation::Write),
383        "link" => {
384            emit(path(0, None)?, Operation::Metadata);
385            emit(path(1, None)?, Operation::Write);
386        }
387        "linkat" => {
388            emit(path(1, Some(0))?, Operation::Metadata);
389            emit(path(3, Some(2))?, Operation::Write);
390        }
391        "execve" | "execveat" | "exit" | "exit_group" | "wait4" | "waitid" | "close" | "dup"
392        | "dup2" | "dup3" | "kill" | "tkill" | "tgkill" | "rt_sigqueueinfo"
393        | "rt_tgsigqueueinfo" | "pidfd_send_signal" | "pidfd_open" => {}
394        _ => return None,
395    }
396    Some((events, change, child))
397}
398
399/// A call that only reads what is at a path, changing nothing.
400fn lookup(name: &str) -> bool {
401    matches!(
402        name,
403        "stat"
404            | "lstat"
405            | "stat64"
406            | "lstat64"
407            | "access"
408            | "statfs"
409            | "statfs64"
410            | "getxattr"
411            | "lgetxattr"
412            | "listxattr"
413            | "llistxattr"
414            | "newfstatat"
415            | "fstatat64"
416            | "statx"
417            | "faccessat"
418            | "faccessat2"
419            | "name_to_handle_at"
420            | "readlink"
421            | "readlinkat"
422            | "getdents64"
423    )
424}
425
426fn descriptor(arg: &str) -> Option<PathBuf> {
427    let (_, path) = arg.split_once('<')?;
428    let path = path.strip_suffix('>')?;
429    let path = path.strip_suffix(" (deleted)").unwrap_or(path);
430    path.starts_with('/').then(|| PathBuf::from(path))
431}
432
433/// Separate syscall arguments without splitting strings, structures or fd paths.
434fn arguments(text: &str) -> Vec<&str> {
435    let mut result = Vec::new();
436    let (mut start, mut nesting, mut string, mut escaped) = (0, 0usize, false, false);
437    for (index, ch) in text.char_indices() {
438        if string {
439            if escaped {
440                escaped = false;
441            } else if ch == '\\' {
442                escaped = true;
443            } else if ch == '"' {
444                string = false;
445            }
446            continue;
447        }
448        match ch {
449            '"' => string = true,
450            '(' | '[' | '{' | '<' => nesting += 1,
451            ')' | ']' | '}' | '>' => nesting = nesting.saturating_sub(1),
452            ',' if nesting == 0 => {
453                result.push(text[start..index].trim());
454                start = index + 1;
455            }
456            _ => {}
457        }
458    }
459    result.push(text[start..].trim());
460    result
461}
462
463fn quoted(text: &str) -> Option<String> {
464    let bytes = text.as_bytes();
465    if bytes.first() != Some(&b'"') {
466        return None;
467    }
468    let mut result = Vec::new();
469    let mut index = 1;
470    while index < bytes.len() {
471        let byte = bytes[index];
472        index += 1;
473        if byte == b'"' {
474            if text[index..].trim().starts_with("...") {
475                return None;
476            }
477            return String::from_utf8(result).ok();
478        }
479        if byte != b'\\' {
480            result.push(byte);
481            continue;
482        }
483        let escaped = *bytes.get(index)?;
484        index += 1;
485        result.push(match escaped {
486            b'n' => b'\n',
487            b'r' => b'\r',
488            b't' => b'\t',
489            b'\\' => b'\\',
490            b'"' => b'"',
491            b'0'..=b'7' => {
492                let mut value = u16::from(escaped - b'0');
493                for _ in 0..2 {
494                    if let Some(next @ b'0'..=b'7') = bytes.get(index) {
495                        value = value * 8 + u16::from(*next - b'0');
496                        index += 1;
497                    } else {
498                        break;
499                    }
500                }
501                u8::try_from(value).ok()?
502            }
503            _ => return None,
504        });
505    }
506    None
507}
508
509#[cfg(all(test, unix))]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn resolves_cwd_dirfds_and_failed_probes_without_confusing_writes() {
515        let cwd = Path::new("/repo/app");
516        let (events, _, _) = decode(
517            1,
518            "openat(3</repo/shared>, \"a b.json\", O_RDONLY|O_CLOEXEC) = 4</repo/shared/a b.json>",
519            cwd,
520        )
521        .unwrap();
522        assert_eq!(events[0].path, "/repo/shared/a b.json");
523        let (events, _, _) = decode(1, "newfstatat(AT_FDCWD, \"../missing\", 0x123, 0) = -1 ENOENT (No such file or directory)", cwd).unwrap();
524        assert_eq!(events[0].result, "missing");
525        assert_eq!(events[0].path, "/repo/missing");
526        let (events, _, _) = decode(
527            1,
528            "openat(AT_FDCWD, \"out\", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 5</repo/app/out>",
529            cwd,
530        )
531        .unwrap();
532        assert_eq!(events.len(), 1);
533        assert_eq!(events[0].operation, Operation::Write);
534        let (events, _, _) = decode(
535            1,
536            "getdents64(5</repo/app/templates>, [{d_name=\"a\"}], 4096) = 24",
537            cwd,
538        )
539        .unwrap();
540        assert_eq!(events[0].operation, Operation::ListDirectory);
541    }
542
543    #[test]
544    fn decodes_futimens_as_a_write_to_the_descriptors_file() {
545        let (events, _, _) = decode(
546            1,
547            concat!(
548                "utimensat(23</repo/build/browserconfig.xml>, NULL, ",
549                "[{tv_sec=1792930995, tv_nsec=88344326} /* 2026-10-25T12:23:15.088344326+0000 */, ",
550                "{tv_sec=1792930966, tv_nsec=7823650} /* 2026-10-25T12:22:46.007823650+0000 */], 0) = 0",
551            ),
552            Path::new("/repo"),
553        )
554        .unwrap();
555        assert_eq!(events.len(), 1);
556        assert_eq!(events[0].path, "/repo/build/browserconfig.xml");
557        assert_eq!(events[0].operation, Operation::Write);
558    }
559
560    #[test]
561    fn detects_truncated_strings_and_decodes_escaped_paths() {
562        assert_eq!(quoted(r#""a\040b\"c""#), Some("a b\"c".into()));
563        assert_eq!(quoted(r#""long"..."#), None);
564        assert_eq!(
565            arguments(r#"3</a,b>, "c,d", {flags=O_RDONLY, mode=0}"#).len(),
566            3
567        );
568    }
569
570    #[test]
571    fn accepts_padded_exit_records_and_uses_annotated_cwd() {
572        assert!(decode(1, "exit_group(0)         = ?", Path::new("/repo")).is_some());
573        // As a process is torn down.
574        for call in ["exit_group(0)", "???()   = ?", "???( <detached ...>"] {
575            let (events, _, _) = decode(1, call, Path::new("/repo")).unwrap();
576            assert!(events.is_empty(), "{call}");
577        }
578        let (events, _, _) = decode(
579            2,
580            "openat(AT_FDCWD</repo/sub>, \"file\", O_RDONLY) = 3</repo/sub/file>",
581            Path::new("/repo"),
582        )
583        .unwrap();
584        assert_eq!(events[0].path, "/repo/sub/file");
585    }
586
587    #[test]
588    fn signals_and_path_metadata_do_not_disable_successful_collection() {
589        let directory = tempfile::tempdir().unwrap();
590        std::fs::write(directory.path().join("command-0.123"), concat!(
591            "1.000 tgkill(123, 124, SIGURG) = 0\n",
592            "1.001 kill(125, SIGTERM) = 0\n",
593            "1.002 lgetxattr(\"f\", \"security.selinux\", 0x0, 0) = -1 ENODATA (No data available)\n",
594            "1.003 inotify_add_watch(3, \"src\", IN_MODIFY) = 1\n",
595            "1.004 setxattr(\"f\", \"user.test\", 0x123, 1, 0) = 0\n",
596            "1.005 futimesat(AT_FDCWD, \"f\", NULL) = 0\n",
597            "1.006 name_to_handle_at(AT_FDCWD, \"f\", 0x123, 0x124, 0) = 0\n",
598        )).unwrap();
599        let (events, diagnostics) = collect(directory.path(), Path::new("/repo")).unwrap();
600        assert!(diagnostics.is_empty(), "{diagnostics:?}");
601        assert_eq!(events.len(), 5);
602        assert_eq!(events[0].path, "/repo/f");
603        assert_eq!(events[0].operation, Operation::Metadata);
604        assert_eq!(events[0].result, "ENODATA");
605        assert_eq!(events[1].path, "/repo/src");
606        assert_eq!(events[1].operation, Operation::Metadata);
607        assert_eq!(events[2].operation, Operation::Write);
608        assert_eq!(events[3].operation, Operation::Write);
609        assert_eq!(events[4].operation, Operation::Metadata);
610        assert!(decode(123, "chroot(\"/other\") = 0", Path::new("/repo")).is_none());
611    }
612
613    #[test]
614    fn a_filter_keeps_only_what_can_matter() {
615        let directory = tempfile::tempdir().unwrap();
616        std::fs::write(
617            directory.path().join("command-0.10"),
618            concat!(
619                "1.000 openat(AT_FDCWD</repo>, \"node_modules/x/index.js\", O_RDONLY) = 3</repo/node_modules/x/index.js>\n",
620                "1.001 openat(AT_FDCWD</repo>, \"src/a.ts\", O_RDONLY) = 3</repo/src/a.ts>\n",
621                "1.002 openat(AT_FDCWD</repo>, \"/usr/lib/libc.so\", O_RDONLY) = 3</usr/lib/libc.so>\n",
622                "1.003 chdir(\"lib\") = 0\n",
623                // Relative to the working directory, which the filter cannot
624                // know: kept, and placed once it is known.
625                "1.004 stat(\"../src/b.ts\", {...}) = 0\n",
626            ),
627        )
628        .unwrap();
629        let keep =
630            |path: &Path| path.starts_with("/repo") && !path.starts_with("/repo/node_modules");
631        let limits = Limits {
632            keep: Some(&keep),
633            ..ANALYSIS
634        };
635        let (events, diagnostics) =
636            collect_within(directory.path(), Path::new("/repo"), &limits).unwrap();
637        assert!(diagnostics.is_empty(), "{diagnostics:?}");
638        let paths: Vec<&str> = events.iter().map(|event| event.path.as_str()).collect();
639        assert_eq!(paths, ["/repo/src/a.ts", "/repo/src/b.ts"]);
640        // Only kept accesses count toward the budget.
641        let tight = Limits {
642            accesses: 2,
643            keep: Some(&keep),
644            ..ANALYSIS
645        };
646        let (_, diagnostics) =
647            collect_within(directory.path(), Path::new("/repo"), &tight).unwrap();
648        assert!(diagnostics.is_empty(), "{diagnostics:?}");
649    }
650
651    #[test]
652    fn stats_of_pipes_and_sockets_name_no_path() {
653        let cwd = Path::new("/repo");
654        for call in [
655            "newfstatat(1<pipe:[123]>, \"\", {st_mode=S_IFIFO|0600, ...}, AT_EMPTY_PATH) = 0",
656            "statx(17<socket:[456]>, \"\", AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {...}) = 0",
657            // Node's probe for statx, and one on a descriptor whose name
658            // holds the flag's name, which is no flag.
659            "statx(0</dev/null<char 1:3>>, NULL, AT_STATX_SYNC_AS_STAT, STATX_ALL, NULL) = -1 EFAULT (Bad address)",
660            "statx(3</repo/AT_EMPTY_PATH.txt>, NULL, AT_STATX_SYNC_AS_STAT, STATX_ALL, NULL) = -1 EFAULT (Bad address)",
661        ] {
662            let (events, _, _) = decode(1, call, cwd).unwrap();
663            assert!(events.is_empty(), "{call}");
664        }
665        // A file's descriptor still names its file, with an empty path or,
666        // as newer kernels take, a NULL one.
667        for call in [
668            "newfstatat(3</repo/a.txt>, \"\", {st_mode=S_IFREG|0644, ...}, AT_EMPTY_PATH) = 0",
669            "statx(3</repo/a.txt>, NULL, AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {...}) = 0",
670        ] {
671            let (events, _, _) = decode(1, call, cwd).unwrap();
672            assert_eq!(events[0].path, "/repo/a.txt", "{call}");
673        }
674    }
675
676    #[test]
677    fn a_lookup_its_process_ending_cut_short_is_no_access() {
678        let cwd = Path::new("/repo");
679        for call in [
680            "access(0x7fff7546b1d0, F_OK) = ?",
681            "newfstatat(AT_FDCWD, \"a.txt\", 0x7ffd, 0) = ?",
682            "getdents64(3</repo>, 0x5555, 32768) = ?",
683        ] {
684            let (events, _, _) = decode(1, call, cwd).expect(call);
685            assert!(events.is_empty(), "{call}");
686        }
687        // A call that may have changed something still counts.
688        let (events, _, _) = decode(1, "unlink(\"/repo/a.txt\") = ?", cwd).unwrap();
689        assert_eq!(events.len(), 1);
690    }
691
692    #[test]
693    fn a_signal_is_no_access_however_strace_writes_it() {
694        let cwd = Path::new("/repo");
695        for call in [
696            "tgkill(42, 43, SIGRTMIN) = 0",
697            "tgkill(42, 42, SIGKILL <unfinished ...>) = ?",
698            "tgkill(42, 42, SIGABRT",
699            "kill(42, SIGTERM) = 0",
700        ] {
701            let (events, _, _) = decode(1, call, cwd).unwrap_or_else(|| panic!("{call}"));
702            assert!(events.is_empty(), "{call}");
703        }
704    }
705}