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