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