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