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