1use 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
24pub struct Limits<'a> {
26 pub records: usize,
28 pub bytes: usize,
29 pub accesses: usize,
31 pub keep: Option<&'a dyn Fn(&Path) -> bool>,
33}
34
35pub const ANALYSIS: Limits<'static> = Limits {
38 records: 200_000,
39 bytes: 32 * 1024 * 1024,
40 accesses: 100_000,
41 keep: None,
42};
43
44static UNKNOWN_CWD: std::sync::LazyLock<PathBuf> =
49 std::sync::LazyLock::new(|| std::iter::once("/").chain(["\0"; 64]).collect());
50
51fn 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 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
74pub 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 let mut undecoded_example = None;
160 for (_, process, _, call) in records {
161 let current = directories
162 .entry(process)
163 .or_insert_with(|| cwd.to_owned())
164 .clone();
165 match decode(process, &call, ¤t) {
166 Some((events, change, child)) => {
167 accesses.extend(
168 events.into_iter().filter(|event| {
169 limits.keep.is_none_or(|keep| keep(Path::new(&event.path)))
170 }),
171 );
172 if let Some(directory) = change {
173 directories.insert(process, directory);
174 }
175 if let Some(child) = child {
176 directories.insert(child, current);
177 }
178 }
179 None => {
180 undecoded += 1;
181 undecoded_names.insert(call.split('(').next().unwrap_or("unknown").to_owned());
182 undecoded_example
183 .get_or_insert_with(|| call.chars().take(EXAMPLE_CHARS).collect::<String>());
184 }
185 }
186 if accesses.len() > limits.accesses {
187 accesses.truncate(limits.accesses);
188 diagnostics.push(format!(
189 "recording exceeded {} accesses; report truncated",
190 limits.accesses
191 ));
192 break;
193 }
194 }
195 if undecoded > 0 {
196 diagnostics.push(format!(
197 "{undecoded} syscall records could not be decoded: {}, such as {:?}",
198 undecoded_names.into_iter().collect::<Vec<_>>().join(", "),
199 undecoded_example.unwrap_or_default()
200 ));
201 }
202 diagnostics.sort();
203 diagnostics.dedup();
204 Ok((accesses, diagnostics))
205}
206
207type Decoded = (Vec<Access>, Option<PathBuf>, Option<u32>);
208
209const EXAMPLE_CHARS: usize = 200;
211
212fn decode(process: u32, call: &str, cwd: &Path) -> Option<Decoded> {
213 let (name, tail) = call.split_once('(')?;
214 if matches!(
219 name,
220 "exit"
221 | "exit_group"
222 | "???"
223 | "kill"
224 | "tkill"
225 | "tgkill"
226 | "rt_sigqueueinfo"
227 | "rt_tgsigqueueinfo"
228 | "pidfd_send_signal"
229 ) {
230 return Some((Vec::new(), None, None));
231 }
232 let (args, result) = tail.rsplit_once(" = ")?;
233 let args = args.trim_end().strip_suffix(')')?;
234 let args = arguments(args);
235 let success = !result.starts_with("-1") && !result.starts_with('?');
236 let status = if result.starts_with("-1 ENOENT") {
237 "missing"
238 } else if result.starts_with("-1") {
239 result.split_whitespace().nth(1).unwrap_or("failed")
240 } else if success {
241 "success"
242 } else {
243 "unknown"
244 };
245 let mut events = Vec::new();
246 let mut change = None;
247 let mut child = None;
248 let mut emit = |path: PathBuf, operation| {
249 events.push(Access {
250 process,
251 path: path.to_string_lossy().into_owned(),
252 resolved_path: None,
253 keyed_path: None,
254 package: None,
255 operation,
256 result: status.to_owned(),
257 category: Category::Uncovered,
258 });
259 };
260 let path = |index: usize, dir: Option<usize>| -> Option<PathBuf> {
261 let value = quoted(args.get(index)?)?;
262 let value = Path::new(&value);
263 if value.is_absolute() {
264 return Some(normalize(value));
265 }
266 let base = match dir {
267 Some(index) => descriptor(args[index])
268 .or_else(|| args[index].starts_with("AT_FDCWD").then(|| cwd.to_owned()))?,
269 _ => cwd.to_owned(),
270 };
271 Some(normalize(&base.join(value)))
272 };
273 match name {
274 "clone" | "clone3" | "fork" | "vfork" => {
275 if success {
276 child = result.split_whitespace().next()?.parse().ok();
277 }
278 }
279 "chdir" => {
280 if success {
281 change = Some(path(0, None)?);
282 }
283 }
284 "fchdir" => {
285 if success {
286 change = Some(descriptor(args.first()?)?);
287 }
288 }
289 "getcwd" => {
290 if success {
291 change = Some(PathBuf::from(quoted(args.first()?)?));
292 }
293 }
294 "open" | "openat" | "openat2" | "creat" => {
295 let at = name.starts_with("openat");
296 let file = path(usize::from(at), at.then_some(0))?;
297 let flags = args.get(usize::from(at) + 1).copied().unwrap_or("");
298 if name != "creat" && !flags.contains("O_WRONLY") && !flags.contains("O_PATH") {
299 emit(file.clone(), Operation::OpenRead);
300 } else if flags.contains("O_PATH") {
301 emit(file.clone(), Operation::Metadata);
302 }
303 if name == "creat"
304 || flags.contains("O_WRONLY")
305 || flags.contains("O_RDWR")
306 || flags.contains("O_CREAT")
307 || flags.contains("O_TRUNC")
308 {
309 emit(file, Operation::Write);
310 }
311 let resolved = success.then(|| descriptor(result)).flatten();
313 for event in &mut events {
314 event.resolved_path = resolved
315 .as_ref()
316 .map(|path| path.to_string_lossy().into_owned());
317 }
318 }
319 "getdents64" => emit(descriptor(args.first()?)?, Operation::ListDirectory),
320 "readlink" => emit(path(0, None)?, Operation::ReadLink),
321 "readlinkat" => emit(path(1, Some(0))?, Operation::ReadLink),
322 "stat" | "lstat" | "stat64" | "lstat64" | "access" | "statfs" | "statfs64" | "getxattr"
323 | "lgetxattr" | "listxattr" | "llistxattr" => emit(path(0, None)?, Operation::Metadata),
324 "newfstatat" | "fstatat64" | "statx" | "faccessat" | "faccessat2" | "name_to_handle_at" => {
325 let null = args.get(1) == Some(&"NULL");
331 let flags = match name {
334 "statx" => args.get(2),
335 "name_to_handle_at" => args.get(4),
336 "faccessat" => None,
337 _ => args.get(3),
338 };
339 let probe = null && !flags.is_some_and(|flags| flags.contains("AT_EMPTY_PATH"));
340 let empty = null || quoted(args.get(1)?).is_some_and(|path| path.is_empty());
341 let unnamed = empty && args[0].contains('<') && descriptor(args[0]).is_none();
342 if !probe && !unnamed {
343 let target = if null {
344 normalize(
345 &descriptor(args[0])
346 .or_else(|| args[0].starts_with("AT_FDCWD").then(|| cwd.to_owned()))?,
347 )
348 } else {
349 path(1, Some(0))?
350 };
351 emit(target, Operation::Metadata);
352 }
353 }
354 "inotify_add_watch" => emit(path(1, None)?, Operation::Metadata),
355 "unlink" | "rmdir" => emit(path(0, None)?, Operation::Delete),
356 "unlinkat" => emit(path(1, Some(0))?, Operation::Delete),
357 "rename" => {
358 emit(path(0, None)?, Operation::Delete);
359 emit(path(1, None)?, Operation::Rename);
360 }
361 "renameat" | "renameat2" => {
362 emit(path(1, Some(0))?, Operation::Delete);
363 emit(path(3, Some(2))?, Operation::Rename);
364 }
365 "mkdir" | "truncate" | "chmod" | "chown" | "lchown" | "utime" | "utimes" | "mknod"
366 | "setxattr" | "lsetxattr" | "removexattr" | "lremovexattr" => {
367 emit(path(0, None)?, Operation::Write)
368 }
369 "mkdirat" | "fchmodat" | "fchmodat2" | "fchownat" | "utimensat" | "mknodat"
370 | "futimesat" => emit(path(1, Some(0))?, Operation::Write),
371 "symlink" => emit(path(1, None)?, Operation::Write),
372 "symlinkat" => emit(path(2, Some(1))?, Operation::Write),
373 "link" => {
374 emit(path(0, None)?, Operation::Metadata);
375 emit(path(1, None)?, Operation::Write);
376 }
377 "linkat" => {
378 emit(path(1, Some(0))?, Operation::Metadata);
379 emit(path(3, Some(2))?, Operation::Write);
380 }
381 "execve" | "execveat" | "exit" | "exit_group" | "wait4" | "waitid" | "close" | "dup"
382 | "dup2" | "dup3" | "kill" | "tkill" | "tgkill" | "rt_sigqueueinfo"
383 | "rt_tgsigqueueinfo" | "pidfd_send_signal" | "pidfd_open" => {}
384 _ => return None,
385 }
386 Some((events, change, child))
387}
388
389fn descriptor(arg: &str) -> Option<PathBuf> {
390 let (_, path) = arg.split_once('<')?;
391 let path = path.strip_suffix('>')?;
392 let path = path.strip_suffix(" (deleted)").unwrap_or(path);
393 path.starts_with('/').then(|| PathBuf::from(path))
394}
395
396fn arguments(text: &str) -> Vec<&str> {
398 let mut result = Vec::new();
399 let (mut start, mut nesting, mut string, mut escaped) = (0, 0usize, false, false);
400 for (index, ch) in text.char_indices() {
401 if string {
402 if escaped {
403 escaped = false;
404 } else if ch == '\\' {
405 escaped = true;
406 } else if ch == '"' {
407 string = false;
408 }
409 continue;
410 }
411 match ch {
412 '"' => string = true,
413 '(' | '[' | '{' | '<' => nesting += 1,
414 ')' | ']' | '}' | '>' => nesting = nesting.saturating_sub(1),
415 ',' if nesting == 0 => {
416 result.push(text[start..index].trim());
417 start = index + 1;
418 }
419 _ => {}
420 }
421 }
422 result.push(text[start..].trim());
423 result
424}
425
426fn quoted(text: &str) -> Option<String> {
427 let bytes = text.as_bytes();
428 if bytes.first() != Some(&b'"') {
429 return None;
430 }
431 let mut result = Vec::new();
432 let mut index = 1;
433 while index < bytes.len() {
434 let byte = bytes[index];
435 index += 1;
436 if byte == b'"' {
437 if text[index..].trim().starts_with("...") {
438 return None;
439 }
440 return String::from_utf8(result).ok();
441 }
442 if byte != b'\\' {
443 result.push(byte);
444 continue;
445 }
446 let escaped = *bytes.get(index)?;
447 index += 1;
448 result.push(match escaped {
449 b'n' => b'\n',
450 b'r' => b'\r',
451 b't' => b'\t',
452 b'\\' => b'\\',
453 b'"' => b'"',
454 b'0'..=b'7' => {
455 let mut value = u16::from(escaped - b'0');
456 for _ in 0..2 {
457 if let Some(next @ b'0'..=b'7') = bytes.get(index) {
458 value = value * 8 + u16::from(*next - b'0');
459 index += 1;
460 } else {
461 break;
462 }
463 }
464 u8::try_from(value).ok()?
465 }
466 _ => return None,
467 });
468 }
469 None
470}
471
472#[cfg(all(test, unix))]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn resolves_cwd_dirfds_and_failed_probes_without_confusing_writes() {
478 let cwd = Path::new("/repo/app");
479 let (events, _, _) = decode(
480 1,
481 "openat(3</repo/shared>, \"a b.json\", O_RDONLY|O_CLOEXEC) = 4</repo/shared/a b.json>",
482 cwd,
483 )
484 .unwrap();
485 assert_eq!(events[0].path, "/repo/shared/a b.json");
486 let (events, _, _) = decode(1, "newfstatat(AT_FDCWD, \"../missing\", 0x123, 0) = -1 ENOENT (No such file or directory)", cwd).unwrap();
487 assert_eq!(events[0].result, "missing");
488 assert_eq!(events[0].path, "/repo/missing");
489 let (events, _, _) = decode(
490 1,
491 "openat(AT_FDCWD, \"out\", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 5</repo/app/out>",
492 cwd,
493 )
494 .unwrap();
495 assert_eq!(events.len(), 1);
496 assert_eq!(events[0].operation, Operation::Write);
497 let (events, _, _) = decode(
498 1,
499 "getdents64(5</repo/app/templates>, [{d_name=\"a\"}], 4096) = 24",
500 cwd,
501 )
502 .unwrap();
503 assert_eq!(events[0].operation, Operation::ListDirectory);
504 }
505
506 #[test]
507 fn detects_truncated_strings_and_decodes_escaped_paths() {
508 assert_eq!(quoted(r#""a\040b\"c""#), Some("a b\"c".into()));
509 assert_eq!(quoted(r#""long"..."#), None);
510 assert_eq!(
511 arguments(r#"3</a,b>, "c,d", {flags=O_RDONLY, mode=0}"#).len(),
512 3
513 );
514 }
515
516 #[test]
517 fn accepts_padded_exit_records_and_uses_annotated_cwd() {
518 assert!(decode(1, "exit_group(0) = ?", Path::new("/repo")).is_some());
519 for call in ["exit_group(0)", "???() = ?", "???( <detached ...>"] {
521 let (events, _, _) = decode(1, call, Path::new("/repo")).unwrap();
522 assert!(events.is_empty(), "{call}");
523 }
524 let (events, _, _) = decode(
525 2,
526 "openat(AT_FDCWD</repo/sub>, \"file\", O_RDONLY) = 3</repo/sub/file>",
527 Path::new("/repo"),
528 )
529 .unwrap();
530 assert_eq!(events[0].path, "/repo/sub/file");
531 }
532
533 #[test]
534 fn signals_and_path_metadata_do_not_disable_successful_collection() {
535 let directory = tempfile::tempdir().unwrap();
536 std::fs::write(directory.path().join("command-0.123"), concat!(
537 "1.000 tgkill(123, 124, SIGURG) = 0\n",
538 "1.001 kill(125, SIGTERM) = 0\n",
539 "1.002 lgetxattr(\"f\", \"security.selinux\", 0x0, 0) = -1 ENODATA (No data available)\n",
540 "1.003 inotify_add_watch(3, \"src\", IN_MODIFY) = 1\n",
541 "1.004 setxattr(\"f\", \"user.test\", 0x123, 1, 0) = 0\n",
542 "1.005 futimesat(AT_FDCWD, \"f\", NULL) = 0\n",
543 "1.006 name_to_handle_at(AT_FDCWD, \"f\", 0x123, 0x124, 0) = 0\n",
544 )).unwrap();
545 let (events, diagnostics) = collect(directory.path(), Path::new("/repo")).unwrap();
546 assert!(diagnostics.is_empty(), "{diagnostics:?}");
547 assert_eq!(events.len(), 5);
548 assert_eq!(events[0].path, "/repo/f");
549 assert_eq!(events[0].operation, Operation::Metadata);
550 assert_eq!(events[0].result, "ENODATA");
551 assert_eq!(events[1].path, "/repo/src");
552 assert_eq!(events[1].operation, Operation::Metadata);
553 assert_eq!(events[2].operation, Operation::Write);
554 assert_eq!(events[3].operation, Operation::Write);
555 assert_eq!(events[4].operation, Operation::Metadata);
556 assert!(decode(123, "chroot(\"/other\") = 0", Path::new("/repo")).is_none());
557 }
558
559 #[test]
560 fn a_filter_keeps_only_what_can_matter() {
561 let directory = tempfile::tempdir().unwrap();
562 std::fs::write(
563 directory.path().join("command-0.10"),
564 concat!(
565 "1.000 openat(AT_FDCWD</repo>, \"node_modules/x/index.js\", O_RDONLY) = 3</repo/node_modules/x/index.js>\n",
566 "1.001 openat(AT_FDCWD</repo>, \"src/a.ts\", O_RDONLY) = 3</repo/src/a.ts>\n",
567 "1.002 openat(AT_FDCWD</repo>, \"/usr/lib/libc.so\", O_RDONLY) = 3</usr/lib/libc.so>\n",
568 "1.003 chdir(\"lib\") = 0\n",
569 "1.004 stat(\"../src/b.ts\", {...}) = 0\n",
572 ),
573 )
574 .unwrap();
575 let keep =
576 |path: &Path| path.starts_with("/repo") && !path.starts_with("/repo/node_modules");
577 let limits = Limits {
578 keep: Some(&keep),
579 ..ANALYSIS
580 };
581 let (events, diagnostics) =
582 collect_within(directory.path(), Path::new("/repo"), &limits).unwrap();
583 assert!(diagnostics.is_empty(), "{diagnostics:?}");
584 let paths: Vec<&str> = events.iter().map(|event| event.path.as_str()).collect();
585 assert_eq!(paths, ["/repo/src/a.ts", "/repo/src/b.ts"]);
586 let tight = Limits {
588 accesses: 2,
589 keep: Some(&keep),
590 ..ANALYSIS
591 };
592 let (_, diagnostics) =
593 collect_within(directory.path(), Path::new("/repo"), &tight).unwrap();
594 assert!(diagnostics.is_empty(), "{diagnostics:?}");
595 }
596
597 #[test]
598 fn stats_of_pipes_and_sockets_name_no_path() {
599 let cwd = Path::new("/repo");
600 for call in [
601 "newfstatat(1<pipe:[123]>, \"\", {st_mode=S_IFIFO|0600, ...}, AT_EMPTY_PATH) = 0",
602 "statx(17<socket:[456]>, \"\", AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {...}) = 0",
603 "statx(0</dev/null<char 1:3>>, NULL, AT_STATX_SYNC_AS_STAT, STATX_ALL, NULL) = -1 EFAULT (Bad address)",
606 "statx(3</repo/AT_EMPTY_PATH.txt>, NULL, AT_STATX_SYNC_AS_STAT, STATX_ALL, NULL) = -1 EFAULT (Bad address)",
607 ] {
608 let (events, _, _) = decode(1, call, cwd).unwrap();
609 assert!(events.is_empty(), "{call}");
610 }
611 for call in [
614 "newfstatat(3</repo/a.txt>, \"\", {st_mode=S_IFREG|0644, ...}, AT_EMPTY_PATH) = 0",
615 "statx(3</repo/a.txt>, NULL, AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {...}) = 0",
616 ] {
617 let (events, _, _) = decode(1, call, cwd).unwrap();
618 assert_eq!(events[0].path, "/repo/a.txt", "{call}");
619 }
620 }
621
622 #[test]
623 fn a_signal_is_no_access_however_strace_writes_it() {
624 let cwd = Path::new("/repo");
625 for call in [
626 "tgkill(42, 43, SIGRTMIN) = 0",
627 "tgkill(42, 42, SIGKILL <unfinished ...>) = ?",
628 "tgkill(42, 42, SIGABRT",
629 "kill(42, SIGTERM) = 0",
630 ] {
631 let (events, _, _) = decode(1, call, cwd).unwrap_or_else(|| panic!("{call}"));
632 assert!(events.is_empty(), "{call}");
633 }
634 }
635}