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 "mkdirat" | "fchmodat" | "fchmodat2" | "fchownat" | "utimensat" | "mknodat"
375 | "futimesat" => emit(path(1, Some(0))?, Operation::Write),
376 "symlink" => emit(path(1, None)?, Operation::Write),
377 "symlinkat" => emit(path(2, Some(1))?, Operation::Write),
378 "link" => {
379 emit(path(0, None)?, Operation::Metadata);
380 emit(path(1, None)?, Operation::Write);
381 }
382 "linkat" => {
383 emit(path(1, Some(0))?, Operation::Metadata);
384 emit(path(3, Some(2))?, Operation::Write);
385 }
386 "execve" | "execveat" | "exit" | "exit_group" | "wait4" | "waitid" | "close" | "dup"
387 | "dup2" | "dup3" | "kill" | "tkill" | "tgkill" | "rt_sigqueueinfo"
388 | "rt_tgsigqueueinfo" | "pidfd_send_signal" | "pidfd_open" => {}
389 _ => return None,
390 }
391 Some((events, change, child))
392}
393
394fn lookup(name: &str) -> bool {
396 matches!(
397 name,
398 "stat"
399 | "lstat"
400 | "stat64"
401 | "lstat64"
402 | "access"
403 | "statfs"
404 | "statfs64"
405 | "getxattr"
406 | "lgetxattr"
407 | "listxattr"
408 | "llistxattr"
409 | "newfstatat"
410 | "fstatat64"
411 | "statx"
412 | "faccessat"
413 | "faccessat2"
414 | "name_to_handle_at"
415 | "readlink"
416 | "readlinkat"
417 | "getdents64"
418 )
419}
420
421fn descriptor(arg: &str) -> Option<PathBuf> {
422 let (_, path) = arg.split_once('<')?;
423 let path = path.strip_suffix('>')?;
424 let path = path.strip_suffix(" (deleted)").unwrap_or(path);
425 path.starts_with('/').then(|| PathBuf::from(path))
426}
427
428fn arguments(text: &str) -> Vec<&str> {
430 let mut result = Vec::new();
431 let (mut start, mut nesting, mut string, mut escaped) = (0, 0usize, false, false);
432 for (index, ch) in text.char_indices() {
433 if string {
434 if escaped {
435 escaped = false;
436 } else if ch == '\\' {
437 escaped = true;
438 } else if ch == '"' {
439 string = false;
440 }
441 continue;
442 }
443 match ch {
444 '"' => string = true,
445 '(' | '[' | '{' | '<' => nesting += 1,
446 ')' | ']' | '}' | '>' => nesting = nesting.saturating_sub(1),
447 ',' if nesting == 0 => {
448 result.push(text[start..index].trim());
449 start = index + 1;
450 }
451 _ => {}
452 }
453 }
454 result.push(text[start..].trim());
455 result
456}
457
458fn quoted(text: &str) -> Option<String> {
459 let bytes = text.as_bytes();
460 if bytes.first() != Some(&b'"') {
461 return None;
462 }
463 let mut result = Vec::new();
464 let mut index = 1;
465 while index < bytes.len() {
466 let byte = bytes[index];
467 index += 1;
468 if byte == b'"' {
469 if text[index..].trim().starts_with("...") {
470 return None;
471 }
472 return String::from_utf8(result).ok();
473 }
474 if byte != b'\\' {
475 result.push(byte);
476 continue;
477 }
478 let escaped = *bytes.get(index)?;
479 index += 1;
480 result.push(match escaped {
481 b'n' => b'\n',
482 b'r' => b'\r',
483 b't' => b'\t',
484 b'\\' => b'\\',
485 b'"' => b'"',
486 b'0'..=b'7' => {
487 let mut value = u16::from(escaped - b'0');
488 for _ in 0..2 {
489 if let Some(next @ b'0'..=b'7') = bytes.get(index) {
490 value = value * 8 + u16::from(*next - b'0');
491 index += 1;
492 } else {
493 break;
494 }
495 }
496 u8::try_from(value).ok()?
497 }
498 _ => return None,
499 });
500 }
501 None
502}
503
504#[cfg(all(test, unix))]
505mod tests {
506 use super::*;
507
508 #[test]
509 fn resolves_cwd_dirfds_and_failed_probes_without_confusing_writes() {
510 let cwd = Path::new("/repo/app");
511 let (events, _, _) = decode(
512 1,
513 "openat(3</repo/shared>, \"a b.json\", O_RDONLY|O_CLOEXEC) = 4</repo/shared/a b.json>",
514 cwd,
515 )
516 .unwrap();
517 assert_eq!(events[0].path, "/repo/shared/a b.json");
518 let (events, _, _) = decode(1, "newfstatat(AT_FDCWD, \"../missing\", 0x123, 0) = -1 ENOENT (No such file or directory)", cwd).unwrap();
519 assert_eq!(events[0].result, "missing");
520 assert_eq!(events[0].path, "/repo/missing");
521 let (events, _, _) = decode(
522 1,
523 "openat(AT_FDCWD, \"out\", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 5</repo/app/out>",
524 cwd,
525 )
526 .unwrap();
527 assert_eq!(events.len(), 1);
528 assert_eq!(events[0].operation, Operation::Write);
529 let (events, _, _) = decode(
530 1,
531 "getdents64(5</repo/app/templates>, [{d_name=\"a\"}], 4096) = 24",
532 cwd,
533 )
534 .unwrap();
535 assert_eq!(events[0].operation, Operation::ListDirectory);
536 }
537
538 #[test]
539 fn detects_truncated_strings_and_decodes_escaped_paths() {
540 assert_eq!(quoted(r#""a\040b\"c""#), Some("a b\"c".into()));
541 assert_eq!(quoted(r#""long"..."#), None);
542 assert_eq!(
543 arguments(r#"3</a,b>, "c,d", {flags=O_RDONLY, mode=0}"#).len(),
544 3
545 );
546 }
547
548 #[test]
549 fn accepts_padded_exit_records_and_uses_annotated_cwd() {
550 assert!(decode(1, "exit_group(0) = ?", Path::new("/repo")).is_some());
551 for call in ["exit_group(0)", "???() = ?", "???( <detached ...>"] {
553 let (events, _, _) = decode(1, call, Path::new("/repo")).unwrap();
554 assert!(events.is_empty(), "{call}");
555 }
556 let (events, _, _) = decode(
557 2,
558 "openat(AT_FDCWD</repo/sub>, \"file\", O_RDONLY) = 3</repo/sub/file>",
559 Path::new("/repo"),
560 )
561 .unwrap();
562 assert_eq!(events[0].path, "/repo/sub/file");
563 }
564
565 #[test]
566 fn signals_and_path_metadata_do_not_disable_successful_collection() {
567 let directory = tempfile::tempdir().unwrap();
568 std::fs::write(directory.path().join("command-0.123"), concat!(
569 "1.000 tgkill(123, 124, SIGURG) = 0\n",
570 "1.001 kill(125, SIGTERM) = 0\n",
571 "1.002 lgetxattr(\"f\", \"security.selinux\", 0x0, 0) = -1 ENODATA (No data available)\n",
572 "1.003 inotify_add_watch(3, \"src\", IN_MODIFY) = 1\n",
573 "1.004 setxattr(\"f\", \"user.test\", 0x123, 1, 0) = 0\n",
574 "1.005 futimesat(AT_FDCWD, \"f\", NULL) = 0\n",
575 "1.006 name_to_handle_at(AT_FDCWD, \"f\", 0x123, 0x124, 0) = 0\n",
576 )).unwrap();
577 let (events, diagnostics) = collect(directory.path(), Path::new("/repo")).unwrap();
578 assert!(diagnostics.is_empty(), "{diagnostics:?}");
579 assert_eq!(events.len(), 5);
580 assert_eq!(events[0].path, "/repo/f");
581 assert_eq!(events[0].operation, Operation::Metadata);
582 assert_eq!(events[0].result, "ENODATA");
583 assert_eq!(events[1].path, "/repo/src");
584 assert_eq!(events[1].operation, Operation::Metadata);
585 assert_eq!(events[2].operation, Operation::Write);
586 assert_eq!(events[3].operation, Operation::Write);
587 assert_eq!(events[4].operation, Operation::Metadata);
588 assert!(decode(123, "chroot(\"/other\") = 0", Path::new("/repo")).is_none());
589 }
590
591 #[test]
592 fn a_filter_keeps_only_what_can_matter() {
593 let directory = tempfile::tempdir().unwrap();
594 std::fs::write(
595 directory.path().join("command-0.10"),
596 concat!(
597 "1.000 openat(AT_FDCWD</repo>, \"node_modules/x/index.js\", O_RDONLY) = 3</repo/node_modules/x/index.js>\n",
598 "1.001 openat(AT_FDCWD</repo>, \"src/a.ts\", O_RDONLY) = 3</repo/src/a.ts>\n",
599 "1.002 openat(AT_FDCWD</repo>, \"/usr/lib/libc.so\", O_RDONLY) = 3</usr/lib/libc.so>\n",
600 "1.003 chdir(\"lib\") = 0\n",
601 "1.004 stat(\"../src/b.ts\", {...}) = 0\n",
604 ),
605 )
606 .unwrap();
607 let keep =
608 |path: &Path| path.starts_with("/repo") && !path.starts_with("/repo/node_modules");
609 let limits = Limits {
610 keep: Some(&keep),
611 ..ANALYSIS
612 };
613 let (events, diagnostics) =
614 collect_within(directory.path(), Path::new("/repo"), &limits).unwrap();
615 assert!(diagnostics.is_empty(), "{diagnostics:?}");
616 let paths: Vec<&str> = events.iter().map(|event| event.path.as_str()).collect();
617 assert_eq!(paths, ["/repo/src/a.ts", "/repo/src/b.ts"]);
618 let tight = Limits {
620 accesses: 2,
621 keep: Some(&keep),
622 ..ANALYSIS
623 };
624 let (_, diagnostics) =
625 collect_within(directory.path(), Path::new("/repo"), &tight).unwrap();
626 assert!(diagnostics.is_empty(), "{diagnostics:?}");
627 }
628
629 #[test]
630 fn stats_of_pipes_and_sockets_name_no_path() {
631 let cwd = Path::new("/repo");
632 for call in [
633 "newfstatat(1<pipe:[123]>, \"\", {st_mode=S_IFIFO|0600, ...}, AT_EMPTY_PATH) = 0",
634 "statx(17<socket:[456]>, \"\", AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {...}) = 0",
635 "statx(0</dev/null<char 1:3>>, NULL, AT_STATX_SYNC_AS_STAT, STATX_ALL, NULL) = -1 EFAULT (Bad address)",
638 "statx(3</repo/AT_EMPTY_PATH.txt>, NULL, AT_STATX_SYNC_AS_STAT, STATX_ALL, NULL) = -1 EFAULT (Bad address)",
639 ] {
640 let (events, _, _) = decode(1, call, cwd).unwrap();
641 assert!(events.is_empty(), "{call}");
642 }
643 for call in [
646 "newfstatat(3</repo/a.txt>, \"\", {st_mode=S_IFREG|0644, ...}, AT_EMPTY_PATH) = 0",
647 "statx(3</repo/a.txt>, NULL, AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {...}) = 0",
648 ] {
649 let (events, _, _) = decode(1, call, cwd).unwrap();
650 assert_eq!(events[0].path, "/repo/a.txt", "{call}");
651 }
652 }
653
654 #[test]
655 fn a_lookup_its_process_ending_cut_short_is_no_access() {
656 let cwd = Path::new("/repo");
657 for call in [
658 "access(0x7fff7546b1d0, F_OK) = ?",
659 "newfstatat(AT_FDCWD, \"a.txt\", 0x7ffd, 0) = ?",
660 "getdents64(3</repo>, 0x5555, 32768) = ?",
661 ] {
662 let (events, _, _) = decode(1, call, cwd).expect(call);
663 assert!(events.is_empty(), "{call}");
664 }
665 let (events, _, _) = decode(1, "unlink(\"/repo/a.txt\") = ?", cwd).unwrap();
667 assert_eq!(events.len(), 1);
668 }
669
670 #[test]
671 fn a_signal_is_no_access_however_strace_writes_it() {
672 let cwd = Path::new("/repo");
673 for call in [
674 "tgkill(42, 43, SIGRTMIN) = 0",
675 "tgkill(42, 42, SIGKILL <unfinished ...>) = ?",
676 "tgkill(42, 42, SIGABRT",
677 "kill(42, SIGTERM) = 0",
678 ] {
679 let (events, _, _) = decode(1, call, cwd).unwrap_or_else(|| panic!("{call}"));
680 assert!(events.is_empty(), "{call}");
681 }
682 }
683}