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