Skip to main content

qk_input_analysis/
strace.rs

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