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 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, ¤t) {
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 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 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 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
355fn 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 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 "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 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 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}