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 if result.starts_with('?') && lookup(name) {
238 return Some((Vec::new(), None, None));
239 }
240 let success = !result.starts_with("-1") && !result.starts_with('?');
241 let status = if result.starts_with("-1 ENOENT") {
242 "missing"
243 } else if result.starts_with("-1") {
244 result.split_whitespace().nth(1).unwrap_or("failed")
245 } else if success {
246 "success"
247 } else {
248 "unknown"
249 };
250 let mut events = Vec::new();
251 let mut change = None;
252 let mut child = None;
253 let mut emit = |path: PathBuf, operation| {
254 events.push(Access {
255 process,
256 path: path.to_string_lossy().into_owned(),
257 resolved_path: None,
258 keyed_path: None,
259 package: None,
260 operation,
261 result: status.to_owned(),
262 category: Category::Uncovered,
263 });
264 };
265 let path = |index: usize, dir: Option<usize>| -> Option<PathBuf> {
266 let value = quoted(args.get(index)?)?;
267 let value = Path::new(&value);
268 if value.is_absolute() {
269 return Some(normalize(value));
270 }
271 let base = match dir {
272 Some(index) => descriptor(args[index])
273 .or_else(|| args[index].starts_with("AT_FDCWD").then(|| cwd.to_owned()))?,
274 _ => cwd.to_owned(),
275 };
276 Some(normalize(&base.join(value)))
277 };
278 match name {
279 "clone" | "clone3" | "fork" | "vfork" => {
280 if success {
281 child = result.split_whitespace().next()?.parse().ok();
282 }
283 }
284 "chdir" => {
285 if success {
286 change = Some(path(0, None)?);
287 }
288 }
289 "fchdir" => {
290 if success {
291 change = Some(descriptor(args.first()?)?);
292 }
293 }
294 "getcwd" => {
295 if success {
296 change = Some(PathBuf::from(quoted(args.first()?)?));
297 }
298 }
299 "open" | "openat" | "openat2" | "creat" => {
300 let at = name.starts_with("openat");
301 let file = path(usize::from(at), at.then_some(0))?;
302 let flags = args.get(usize::from(at) + 1).copied().unwrap_or("");
303 if name != "creat" && !flags.contains("O_WRONLY") && !flags.contains("O_PATH") {
304 emit(file.clone(), Operation::OpenRead);
305 } else if flags.contains("O_PATH") {
306 emit(file.clone(), Operation::Metadata);
307 }
308 if name == "creat"
309 || flags.contains("O_WRONLY")
310 || flags.contains("O_RDWR")
311 || flags.contains("O_CREAT")
312 || flags.contains("O_TRUNC")
313 {
314 emit(file, Operation::Write);
315 }
316 let resolved = success.then(|| descriptor(result)).flatten();
318 for event in &mut events {
319 event.resolved_path = resolved
320 .as_ref()
321 .map(|path| path.to_string_lossy().into_owned());
322 }
323 }
324 "getdents64" => emit(descriptor(args.first()?)?, Operation::ListDirectory),
325 "readlink" => emit(path(0, None)?, Operation::ReadLink),
326 "readlinkat" => emit(path(1, Some(0))?, Operation::ReadLink),
327 "stat" | "lstat" | "stat64" | "lstat64" | "access" | "statfs" | "statfs64" | "getxattr"
328 | "lgetxattr" | "listxattr" | "llistxattr" => emit(path(0, None)?, Operation::Metadata),
329 "newfstatat" | "fstatat64" | "statx" | "faccessat" | "faccessat2" | "name_to_handle_at" => {
330 let null = args.get(1) == Some(&"NULL");
336 let flags = match name {
339 "statx" => args.get(2),
340 "name_to_handle_at" => args.get(4),
341 "faccessat" => None,
342 _ => args.get(3),
343 };
344 let probe = null && !flags.is_some_and(|flags| flags.contains("AT_EMPTY_PATH"));
345 let empty = null || quoted(args.get(1)?).is_some_and(|path| path.is_empty());
346 let unnamed = empty && args[0].contains('<') && descriptor(args[0]).is_none();
347 if !probe && !unnamed {
348 let target = if null {
349 normalize(
350 &descriptor(args[0])
351 .or_else(|| args[0].starts_with("AT_FDCWD").then(|| cwd.to_owned()))?,
352 )
353 } else {
354 path(1, Some(0))?
355 };
356 emit(target, Operation::Metadata);
357 }
358 }
359 "inotify_add_watch" => emit(path(1, None)?, Operation::Metadata),
360 "unlink" | "rmdir" => emit(path(0, None)?, Operation::Delete),
361 "unlinkat" => emit(path(1, Some(0))?, Operation::Delete),
362 "rename" => {
363 emit(path(0, None)?, Operation::Delete);
364 emit(path(1, None)?, Operation::Rename);
365 }
366 "renameat" | "renameat2" => {
367 emit(path(1, Some(0))?, Operation::Delete);
368 emit(path(3, Some(2))?, Operation::Rename);
369 }
370 "mkdir" | "truncate" | "chmod" | "chown" | "lchown" | "utime" | "utimes" | "mknod"
371 | "setxattr" | "lsetxattr" | "removexattr" | "lremovexattr" => {
372 emit(path(0, None)?, Operation::Write)
373 }
374 "utimensat" if args.get(1).is_some_and(|path| *path == "NULL") => {
377 emit(normalize(&descriptor(args[0])?), Operation::Write)
378 }
379 "mkdirat" | "fchmodat" | "fchmodat2" | "fchownat" | "utimensat" | "mknodat"
380 | "futimesat" => emit(path(1, Some(0))?, Operation::Write),
381 "symlink" => emit(path(1, None)?, Operation::Write),
382 "symlinkat" => emit(path(2, Some(1))?, Operation::Write),
383 "link" => {
384 emit(path(0, None)?, Operation::Metadata);
385 emit(path(1, None)?, Operation::Write);
386 }
387 "linkat" => {
388 emit(path(1, Some(0))?, Operation::Metadata);
389 emit(path(3, Some(2))?, Operation::Write);
390 }
391 "execve" | "execveat" | "exit" | "exit_group" | "wait4" | "waitid" | "close" | "dup"
392 | "dup2" | "dup3" | "kill" | "tkill" | "tgkill" | "rt_sigqueueinfo"
393 | "rt_tgsigqueueinfo" | "pidfd_send_signal" | "pidfd_open" => {}
394 _ => return None,
395 }
396 Some((events, change, child))
397}
398
399fn lookup(name: &str) -> bool {
401 matches!(
402 name,
403 "stat"
404 | "lstat"
405 | "stat64"
406 | "lstat64"
407 | "access"
408 | "statfs"
409 | "statfs64"
410 | "getxattr"
411 | "lgetxattr"
412 | "listxattr"
413 | "llistxattr"
414 | "newfstatat"
415 | "fstatat64"
416 | "statx"
417 | "faccessat"
418 | "faccessat2"
419 | "name_to_handle_at"
420 | "readlink"
421 | "readlinkat"
422 | "getdents64"
423 )
424}
425
426fn descriptor(arg: &str) -> Option<PathBuf> {
427 let (_, path) = arg.split_once('<')?;
428 let path = path.strip_suffix('>')?;
429 let path = path.strip_suffix(" (deleted)").unwrap_or(path);
430 path.starts_with('/').then(|| PathBuf::from(path))
431}
432
433fn arguments(text: &str) -> Vec<&str> {
435 let mut result = Vec::new();
436 let (mut start, mut nesting, mut string, mut escaped) = (0, 0usize, false, false);
437 for (index, ch) in text.char_indices() {
438 if string {
439 if escaped {
440 escaped = false;
441 } else if ch == '\\' {
442 escaped = true;
443 } else if ch == '"' {
444 string = false;
445 }
446 continue;
447 }
448 match ch {
449 '"' => string = true,
450 '(' | '[' | '{' | '<' => nesting += 1,
451 ')' | ']' | '}' | '>' => nesting = nesting.saturating_sub(1),
452 ',' if nesting == 0 => {
453 result.push(text[start..index].trim());
454 start = index + 1;
455 }
456 _ => {}
457 }
458 }
459 result.push(text[start..].trim());
460 result
461}
462
463fn quoted(text: &str) -> Option<String> {
464 let bytes = text.as_bytes();
465 if bytes.first() != Some(&b'"') {
466 return None;
467 }
468 let mut result = Vec::new();
469 let mut index = 1;
470 while index < bytes.len() {
471 let byte = bytes[index];
472 index += 1;
473 if byte == b'"' {
474 if text[index..].trim().starts_with("...") {
475 return None;
476 }
477 return String::from_utf8(result).ok();
478 }
479 if byte != b'\\' {
480 result.push(byte);
481 continue;
482 }
483 let escaped = *bytes.get(index)?;
484 index += 1;
485 result.push(match escaped {
486 b'n' => b'\n',
487 b'r' => b'\r',
488 b't' => b'\t',
489 b'\\' => b'\\',
490 b'"' => b'"',
491 b'0'..=b'7' => {
492 let mut value = u16::from(escaped - b'0');
493 for _ in 0..2 {
494 if let Some(next @ b'0'..=b'7') = bytes.get(index) {
495 value = value * 8 + u16::from(*next - b'0');
496 index += 1;
497 } else {
498 break;
499 }
500 }
501 u8::try_from(value).ok()?
502 }
503 _ => return None,
504 });
505 }
506 None
507}
508
509#[cfg(all(test, unix))]
510mod tests {
511 use super::*;
512
513 #[test]
514 fn resolves_cwd_dirfds_and_failed_probes_without_confusing_writes() {
515 let cwd = Path::new("/repo/app");
516 let (events, _, _) = decode(
517 1,
518 "openat(3</repo/shared>, \"a b.json\", O_RDONLY|O_CLOEXEC) = 4</repo/shared/a b.json>",
519 cwd,
520 )
521 .unwrap();
522 assert_eq!(events[0].path, "/repo/shared/a b.json");
523 let (events, _, _) = decode(1, "newfstatat(AT_FDCWD, \"../missing\", 0x123, 0) = -1 ENOENT (No such file or directory)", cwd).unwrap();
524 assert_eq!(events[0].result, "missing");
525 assert_eq!(events[0].path, "/repo/missing");
526 let (events, _, _) = decode(
527 1,
528 "openat(AT_FDCWD, \"out\", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 5</repo/app/out>",
529 cwd,
530 )
531 .unwrap();
532 assert_eq!(events.len(), 1);
533 assert_eq!(events[0].operation, Operation::Write);
534 let (events, _, _) = decode(
535 1,
536 "getdents64(5</repo/app/templates>, [{d_name=\"a\"}], 4096) = 24",
537 cwd,
538 )
539 .unwrap();
540 assert_eq!(events[0].operation, Operation::ListDirectory);
541 }
542
543 #[test]
544 fn decodes_futimens_as_a_write_to_the_descriptors_file() {
545 let (events, _, _) = decode(
546 1,
547 concat!(
548 "utimensat(23</repo/build/browserconfig.xml>, NULL, ",
549 "[{tv_sec=1792930995, tv_nsec=88344326} /* 2026-10-25T12:23:15.088344326+0000 */, ",
550 "{tv_sec=1792930966, tv_nsec=7823650} /* 2026-10-25T12:22:46.007823650+0000 */], 0) = 0",
551 ),
552 Path::new("/repo"),
553 )
554 .unwrap();
555 assert_eq!(events.len(), 1);
556 assert_eq!(events[0].path, "/repo/build/browserconfig.xml");
557 assert_eq!(events[0].operation, Operation::Write);
558 }
559
560 #[test]
561 fn detects_truncated_strings_and_decodes_escaped_paths() {
562 assert_eq!(quoted(r#""a\040b\"c""#), Some("a b\"c".into()));
563 assert_eq!(quoted(r#""long"..."#), None);
564 assert_eq!(
565 arguments(r#"3</a,b>, "c,d", {flags=O_RDONLY, mode=0}"#).len(),
566 3
567 );
568 }
569
570 #[test]
571 fn accepts_padded_exit_records_and_uses_annotated_cwd() {
572 assert!(decode(1, "exit_group(0) = ?", Path::new("/repo")).is_some());
573 for call in ["exit_group(0)", "???() = ?", "???( <detached ...>"] {
575 let (events, _, _) = decode(1, call, Path::new("/repo")).unwrap();
576 assert!(events.is_empty(), "{call}");
577 }
578 let (events, _, _) = decode(
579 2,
580 "openat(AT_FDCWD</repo/sub>, \"file\", O_RDONLY) = 3</repo/sub/file>",
581 Path::new("/repo"),
582 )
583 .unwrap();
584 assert_eq!(events[0].path, "/repo/sub/file");
585 }
586
587 #[test]
588 fn signals_and_path_metadata_do_not_disable_successful_collection() {
589 let directory = tempfile::tempdir().unwrap();
590 std::fs::write(directory.path().join("command-0.123"), concat!(
591 "1.000 tgkill(123, 124, SIGURG) = 0\n",
592 "1.001 kill(125, SIGTERM) = 0\n",
593 "1.002 lgetxattr(\"f\", \"security.selinux\", 0x0, 0) = -1 ENODATA (No data available)\n",
594 "1.003 inotify_add_watch(3, \"src\", IN_MODIFY) = 1\n",
595 "1.004 setxattr(\"f\", \"user.test\", 0x123, 1, 0) = 0\n",
596 "1.005 futimesat(AT_FDCWD, \"f\", NULL) = 0\n",
597 "1.006 name_to_handle_at(AT_FDCWD, \"f\", 0x123, 0x124, 0) = 0\n",
598 )).unwrap();
599 let (events, diagnostics) = collect(directory.path(), Path::new("/repo")).unwrap();
600 assert!(diagnostics.is_empty(), "{diagnostics:?}");
601 assert_eq!(events.len(), 5);
602 assert_eq!(events[0].path, "/repo/f");
603 assert_eq!(events[0].operation, Operation::Metadata);
604 assert_eq!(events[0].result, "ENODATA");
605 assert_eq!(events[1].path, "/repo/src");
606 assert_eq!(events[1].operation, Operation::Metadata);
607 assert_eq!(events[2].operation, Operation::Write);
608 assert_eq!(events[3].operation, Operation::Write);
609 assert_eq!(events[4].operation, Operation::Metadata);
610 assert!(decode(123, "chroot(\"/other\") = 0", Path::new("/repo")).is_none());
611 }
612
613 #[test]
614 fn a_filter_keeps_only_what_can_matter() {
615 let directory = tempfile::tempdir().unwrap();
616 std::fs::write(
617 directory.path().join("command-0.10"),
618 concat!(
619 "1.000 openat(AT_FDCWD</repo>, \"node_modules/x/index.js\", O_RDONLY) = 3</repo/node_modules/x/index.js>\n",
620 "1.001 openat(AT_FDCWD</repo>, \"src/a.ts\", O_RDONLY) = 3</repo/src/a.ts>\n",
621 "1.002 openat(AT_FDCWD</repo>, \"/usr/lib/libc.so\", O_RDONLY) = 3</usr/lib/libc.so>\n",
622 "1.003 chdir(\"lib\") = 0\n",
623 "1.004 stat(\"../src/b.ts\", {...}) = 0\n",
626 ),
627 )
628 .unwrap();
629 let keep =
630 |path: &Path| path.starts_with("/repo") && !path.starts_with("/repo/node_modules");
631 let limits = Limits {
632 keep: Some(&keep),
633 ..ANALYSIS
634 };
635 let (events, diagnostics) =
636 collect_within(directory.path(), Path::new("/repo"), &limits).unwrap();
637 assert!(diagnostics.is_empty(), "{diagnostics:?}");
638 let paths: Vec<&str> = events.iter().map(|event| event.path.as_str()).collect();
639 assert_eq!(paths, ["/repo/src/a.ts", "/repo/src/b.ts"]);
640 let tight = Limits {
642 accesses: 2,
643 keep: Some(&keep),
644 ..ANALYSIS
645 };
646 let (_, diagnostics) =
647 collect_within(directory.path(), Path::new("/repo"), &tight).unwrap();
648 assert!(diagnostics.is_empty(), "{diagnostics:?}");
649 }
650
651 #[test]
652 fn stats_of_pipes_and_sockets_name_no_path() {
653 let cwd = Path::new("/repo");
654 for call in [
655 "newfstatat(1<pipe:[123]>, \"\", {st_mode=S_IFIFO|0600, ...}, AT_EMPTY_PATH) = 0",
656 "statx(17<socket:[456]>, \"\", AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {...}) = 0",
657 "statx(0</dev/null<char 1:3>>, NULL, AT_STATX_SYNC_AS_STAT, STATX_ALL, NULL) = -1 EFAULT (Bad address)",
660 "statx(3</repo/AT_EMPTY_PATH.txt>, NULL, AT_STATX_SYNC_AS_STAT, STATX_ALL, NULL) = -1 EFAULT (Bad address)",
661 ] {
662 let (events, _, _) = decode(1, call, cwd).unwrap();
663 assert!(events.is_empty(), "{call}");
664 }
665 for call in [
668 "newfstatat(3</repo/a.txt>, \"\", {st_mode=S_IFREG|0644, ...}, AT_EMPTY_PATH) = 0",
669 "statx(3</repo/a.txt>, NULL, AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {...}) = 0",
670 ] {
671 let (events, _, _) = decode(1, call, cwd).unwrap();
672 assert_eq!(events[0].path, "/repo/a.txt", "{call}");
673 }
674 }
675
676 #[test]
677 fn a_lookup_its_process_ending_cut_short_is_no_access() {
678 let cwd = Path::new("/repo");
679 for call in [
680 "access(0x7fff7546b1d0, F_OK) = ?",
681 "newfstatat(AT_FDCWD, \"a.txt\", 0x7ffd, 0) = ?",
682 "getdents64(3</repo>, 0x5555, 32768) = ?",
683 ] {
684 let (events, _, _) = decode(1, call, cwd).expect(call);
685 assert!(events.is_empty(), "{call}");
686 }
687 let (events, _, _) = decode(1, "unlink(\"/repo/a.txt\") = ?", cwd).unwrap();
689 assert_eq!(events.len(), 1);
690 }
691
692 #[test]
693 fn a_signal_is_no_access_however_strace_writes_it() {
694 let cwd = Path::new("/repo");
695 for call in [
696 "tgkill(42, 43, SIGRTMIN) = 0",
697 "tgkill(42, 42, SIGKILL <unfinished ...>) = ?",
698 "tgkill(42, 42, SIGABRT",
699 "kill(42, SIGTERM) = 0",
700 ] {
701 let (events, _, _) = decode(1, call, cwd).unwrap_or_else(|| panic!("{call}"));
702 assert!(events.is_empty(), "{call}");
703 }
704 }
705}