lurk_cli/
lib.rs

1//! lurk is a pretty (simple) alternative to strace.
2//!
3//! ## Installation
4//!
5//! Add the following dependencies to your `Cargo.toml`
6//!
7//! ```toml
8//! [dependencies]
9//! lurk-cli = "0.3.6"
10//! nix = { version = "0.27.1", features = ["ptrace", "signal"] }
11//! console = "0.15.8"
12//! ```
13//!
14//! ## Usage
15//!
16//! First crate a tracee using [`run_tracee`] method. Then you can construct a [`Tracer`]
17//! struct to trace the system calls via calling [`run_tracer`].
18//!
19//! ## Examples
20//!
21//! ```rust
22//! use anyhow::{bail, Result};
23//! use console::Style;
24//! use lurk_cli::{args::Args, style::StyleConfig, Tracer};
25//! use nix::unistd::{fork, ForkResult};
26//! use std::io;
27//!
28//! fn main() -> Result<()> {
29//!     let command = String::from("/usr/bin/ls");
30//!
31//!     let pid = match unsafe { fork() } {
32//!         Ok(ForkResult::Child) => {
33//!             return lurk_cli::run_tracee(&[command], &[], &None);
34//!         }
35//!         Ok(ForkResult::Parent { child }) => child,
36//!         Err(err) => bail!("fork() failed: {err}"),
37//!     };
38//!
39//!     let args = Args::default();
40//!     let output = io::stdout();
41//!     let style = StyleConfig {
42//!         pid: Style::new().cyan(),
43//!         syscall: Style::new().white().bold(),
44//!         success: Style::new().green(),
45//!         error: Style::new().red(),
46//!         result: Style::new().yellow(),
47//!         use_colors: true,
48//!     };
49//!
50//!     Tracer::new(pid, args, output, style)?.run_tracer()
51//! }
52//! ```
53//!
54//! [`run_tracee`]: crate::run_tracee
55//! [`Tracer`]: crate::Tracer
56//! [`run_tracer`]: crate::Tracer::run_tracer
57
58#[deny(clippy::all, clippy::pedantic, clippy::format_push_string)]
59// TODO: re-check the casting lints - they might indicate an issue
60#[allow(
61    clippy::cast_possible_truncation,
62    clippy::cast_possible_wrap,
63    clippy::cast_precision_loss,
64    clippy::redundant_closure_for_method_calls,
65    clippy::struct_excessive_bools
66)]
67pub mod arch;
68pub mod args;
69pub mod style;
70pub mod syscall_info;
71
72use anyhow::{anyhow, Result};
73use comfy_table::modifiers::UTF8_ROUND_CORNERS;
74use comfy_table::presets::UTF8_BORDERS_ONLY;
75use comfy_table::CellAlignment::Right;
76use comfy_table::{Cell, ContentArrangement, Row, Table};
77use libc::user_regs_struct;
78use linux_personality::{personality, Personality};
79use nix::sys::ptrace::{self, Event};
80use nix::sys::signal::Signal;
81use nix::sys::wait::{wait, WaitStatus};
82use nix::unistd::Pid;
83use std::collections::HashMap;
84use std::fs;
85use std::io::Write;
86use std::os::unix::process::CommandExt;
87use std::process::{Command, Stdio};
88use std::time::{Duration, SystemTime};
89use style::StyleConfig;
90use syscalls::{Sysno, SysnoMap, SysnoSet};
91use users::get_user_by_name;
92
93use crate::args::{Args, Filter};
94use crate::syscall_info::{RetCode, SyscallInfo};
95
96const STRING_LIMIT: usize = 32;
97
98pub struct Tracer<W: Write> {
99    pid: Pid,
100    args: Args,
101    string_limit: Option<usize>,
102    filter: Filter,
103    syscalls_time: SysnoMap<Duration>,
104    syscalls_pass: SysnoMap<u64>,
105    syscalls_fail: SysnoMap<u64>,
106    style_config: StyleConfig,
107    output: W,
108}
109
110impl<W: Write> Tracer<W> {
111    pub fn new(pid: Pid, args: Args, output: W, style_config: StyleConfig) -> Result<Self> {
112        Ok(Self {
113            pid,
114            filter: args.create_filter()?,
115            string_limit: if args.no_abbrev {
116                None
117            } else {
118                Some(args.string_limit.unwrap_or(STRING_LIMIT))
119            },
120            args,
121            syscalls_time: SysnoMap::from_iter(
122                SysnoSet::all().iter().map(|v| (v, Duration::default())),
123            ),
124            syscalls_pass: SysnoMap::from_iter(SysnoSet::all().iter().map(|v| (v, 0))),
125            syscalls_fail: SysnoMap::from_iter(SysnoSet::all().iter().map(|v| (v, 0))),
126            style_config,
127            output,
128        })
129    }
130
131    pub fn set_output(&mut self, output: W) {
132        self.output = output;
133    }
134
135    #[allow(clippy::too_many_lines)]
136    pub fn run_tracer(&mut self) -> Result<()> {
137        // Create a hashmap to track entry and exit times across all forked processes individually.
138        let mut start_times = HashMap::<Pid, Option<SystemTime>>::new();
139        start_times.insert(self.pid, None);
140
141        let mut options_initialized = false;
142
143        loop {
144            let status = wait()?;
145
146            if !options_initialized {
147                if self.args.follow_forks {
148                    arch::ptrace_init_options_fork(self.pid)?;
149                } else {
150                    arch::ptrace_init_options(self.pid)?;
151                }
152                options_initialized = true;
153            }
154
155            match status {
156                // `WIFSTOPPED(status), signal is WSTOPSIG(status)
157                WaitStatus::Stopped(pid, signal) => {
158                    // There are three reasons why a child might stop with SIGTRAP:
159                    // 1) syscall entry
160                    // 2) syscall exit
161                    // 3) child calls exec
162                    //
163                    // Because we are tracing with PTRACE_O_TRACESYSGOOD, syscall entry and syscall exit
164                    // are stopped in PtraceSyscall and not here, which means if we get a SIGTRAP here,
165                    // it's because the child called exec.
166                    if signal == Signal::SIGTRAP {
167                        self.log_standard_syscall(pid, None, None)?;
168                        self.issue_ptrace_syscall_request(pid, None)?;
169                        continue;
170                    }
171
172                    // If we trace with PTRACE_O_TRACEFORK, PTRACE_O_TRACEVFORK, and PTRACE_O_TRACECLONE,
173                    // a created child of our tracee will stop with SIGSTOP.
174                    // If our tracee creates children of their own, we want to trace their syscall times with a new value.
175                    if signal == Signal::SIGSTOP {
176                        if self.args.follow_forks {
177                            start_times.insert(pid, None);
178
179                            if !self.args.summary_only {
180                                writeln!(&mut self.output, "Attaching to child {}", pid,)?;
181                            }
182                        }
183
184                        self.issue_ptrace_syscall_request(pid, None)?;
185                        continue;
186                    }
187
188                    // The SIGCHLD signal is sent to a process when a child process terminates, interrupted, or resumes after being interrupted
189                    // This means, that if our tracee forked and said fork exits before the parent, the parent will get stopped.
190                    // Therefor issue a PTRACE_SYSCALL request to the parent to continue execution.
191                    // This is also important if we trace without the following forks option.
192                    if signal == Signal::SIGCHLD {
193                        self.issue_ptrace_syscall_request(pid, Some(signal))?;
194                        continue;
195                    }
196
197                    // If we fall through to here, we have another signal that's been sent to the tracee,
198                    // in this case, just forward the singal to the tracee to let it handle it.
199                    // TODO: Finer signal handling, edge-cases etc.
200                    ptrace::cont(pid, signal)?;
201                }
202                // WIFEXITED(status)
203                WaitStatus::Exited(pid, _) => {
204                    // If the process that exits is the original tracee, we can safely break here,
205                    // but we need to continue if the process that exits is a child of the original tracee.
206                    if self.pid == pid {
207                        break;
208                    } else {
209                        continue;
210                    };
211                }
212                // The traced process was stopped by a `PTRACE_EVENT_*` event.
213                WaitStatus::PtraceEvent(pid, _, code) => {
214                    // We stop at the PTRACE_EVENT_EXIT event because of the PTRACE_O_TRACEEXIT option.
215                    // We do this to properly catch and log exit-family syscalls, which do not have an PTRACE_SYSCALL_INFO_EXIT event.
216                    if code == Event::PTRACE_EVENT_EXIT as i32 && self.is_exit_syscall(pid)? {
217                        self.log_standard_syscall(pid, None, None)?;
218                    }
219
220                    self.issue_ptrace_syscall_request(pid, None)?;
221                }
222                // Tracee is traced with the PTRACE_O_TRACESYSGOOD option.
223                WaitStatus::PtraceSyscall(pid) => {
224                    // ptrace(PTRACE_GETEVENTMSG,...) can be one of three values here:
225                    // 1) PTRACE_SYSCALL_INFO_NONE
226                    // 2) PTRACE_SYSCALL_INFO_ENTRY
227                    // 3) PTRACE_SYSCALL_INFO_EXIT
228                    let event = ptrace::getevent(pid)? as u8;
229
230                    // Snapshot current time, to avoid polluting the syscall time with
231                    // non-syscall related latency.
232                    let timestamp = Some(SystemTime::now());
233
234                    // We only want to log regular syscalls on exit
235                    if let Some(syscall_start_time) = start_times.get_mut(&pid) {
236                        if event == 2 {
237                            self.log_standard_syscall(pid, *syscall_start_time, timestamp)?;
238                            *syscall_start_time = None;
239                        } else {
240                            *syscall_start_time = timestamp;
241                        }
242                    } else {
243                        return Err(anyhow!("Unable to get start time for tracee {}", pid));
244                    }
245
246                    self.issue_ptrace_syscall_request(pid, None)?;
247                }
248                // WIFSIGNALED(status), signal is WTERMSIG(status) and coredump is WCOREDUMP(status)
249                WaitStatus::Signaled(pid, signal, coredump) => {
250                    writeln!(
251                        &mut self.output,
252                        "Child {} terminated by signal {} {}",
253                        pid,
254                        signal,
255                        if coredump { "(core dumped)" } else { "" }
256                    )?;
257                    break;
258                }
259                // WIFCONTINUED(status), this usually happens when a process receives a SIGCONT.
260                // Just continue with the next iteration of the loop.
261                WaitStatus::Continued(_) | WaitStatus::StillAlive => {
262                    continue;
263                }
264            }
265        }
266
267        if !self.args.json && (self.args.summary_only || self.args.summary) {
268            if !self.args.summary_only {
269                // Make a gap between the last syscall and the summary
270                writeln!(&mut self.output)?;
271            }
272            self.report_summary()?;
273        }
274
275        Ok(())
276    }
277
278    pub fn report_summary(&mut self) -> Result<()> {
279        let headers = vec!["% time", "time", "time/call", "calls", "errors", "syscall"];
280        let mut table = Table::new();
281        table
282            .load_preset(UTF8_BORDERS_ONLY)
283            .apply_modifier(UTF8_ROUND_CORNERS)
284            .set_content_arrangement(ContentArrangement::Dynamic)
285            .set_header(&headers);
286
287        for i in 0..headers.len() {
288            table.column_mut(i).unwrap().set_cell_alignment(Right);
289        }
290
291        let mut sorted_sysno: Vec<_> = self.filter.all_enabled().iter().collect();
292        sorted_sysno.sort_by_key(|k| k.name());
293        let t_time: Duration = self.syscalls_time.values().sum();
294
295        for sysno in sorted_sysno {
296            let (Some(pass), Some(fail), Some(time)) = (
297                self.syscalls_pass.get(sysno),
298                self.syscalls_fail.get(sysno),
299                self.syscalls_time.get(sysno),
300            ) else {
301                continue;
302            };
303
304            let calls = pass + fail;
305            if calls == 0 {
306                continue;
307            }
308
309            let time_percent = if !t_time.is_zero() {
310                time.as_secs_f32() / t_time.as_secs_f32() * 100f32
311            } else {
312                0f32
313            };
314
315            table.add_row(vec![
316                Cell::new(&format!("{time_percent:.1}%")),
317                Cell::new(&format!("{}µs", time.as_micros())),
318                Cell::new(&format!("{:.1}ns", time.as_nanos() as f64 / calls as f64)),
319                Cell::new(&format!("{calls}")),
320                Cell::new(&format!("{fail}")),
321                Cell::new(sysno.name()),
322            ]);
323        }
324
325        // Create the totals row, but don't add it to the table yet
326        let failed = self.syscalls_fail.values().sum::<u64>();
327        let calls: u64 = self.syscalls_pass.values().sum::<u64>() + failed;
328        let totals: Row = vec![
329            Cell::new("100%"),
330            Cell::new(format!("{}µs", t_time.as_micros())),
331            Cell::new(format!("{:.1}ns", t_time.as_nanos() as f64 / calls as f64)),
332            Cell::new(calls),
333            Cell::new(failed.to_string()),
334            Cell::new("total"),
335        ]
336        .into();
337
338        // TODO: consider using another table-creating crate
339        //       https://github.com/Nukesor/comfy-table/issues/104
340        // This is a hack to add a line between the table and the summary,
341        // computing max column width of each existing row plus the totals row
342        let divider_row: Vec<String> = table
343            .column_max_content_widths()
344            .iter()
345            .copied()
346            .enumerate()
347            .map(|(idx, val)| {
348                let cell_at_idx = totals.cell_iter().nth(idx).unwrap();
349                (val as usize).max(cell_at_idx.content().len())
350            })
351            .map(|v| str::repeat("-", v))
352            .collect();
353        table.add_row(divider_row);
354        table.add_row(totals);
355
356        if !self.args.summary_only {
357            // separate a list of syscalls from the summary table with an blank line
358            writeln!(&mut self.output)?;
359        }
360        writeln!(&mut self.output, "{table}")?;
361
362        Ok(())
363    }
364
365    fn log_standard_syscall(
366        &mut self,
367        pid: Pid,
368        syscall_start_time: Option<SystemTime>,
369        syscall_end_time: Option<SystemTime>,
370    ) -> Result<()> {
371        let (syscall_number, registers) = self.parse_register_data(pid)?;
372
373        // Theres no PTRACE_SYSCALL_INFO_EXIT for an exit-family syscall, hence ret_code will always be 0xffffffffffffffda (which is -38)
374        // -38 is ENOSYS which is put into RAX as a default return value by the kernel's syscall entry code.
375        // In order to not pollute the summary with this false positive, avoid exit-family syscalls from being counted (same behaviour as strace).
376        let ret_code = match syscall_number {
377            Sysno::exit | Sysno::exit_group => RetCode::from_raw(0),
378            _ => {
379                #[cfg(target_arch = "x86_64")]
380                let code = RetCode::from_raw(registers.rax);
381                #[cfg(target_arch = "riscv64")]
382                let code = RetCode::from_raw(registers.a7);
383                #[cfg(target_arch = "aarch64")]
384                let code: RetCode = RetCode::from_raw(registers.regs[8]);
385                match code {
386                    RetCode::Err(_) => self.syscalls_fail[syscall_number] += 1,
387                    _ => self.syscalls_pass[syscall_number] += 1,
388                }
389                code
390            }
391        };
392
393        if self.filter.matches(syscall_number, ret_code) {
394            let elapsed = syscall_start_time.map_or(Duration::default(), |start_time| {
395                let end_time = syscall_end_time.unwrap_or(SystemTime::now());
396                end_time.duration_since(start_time).unwrap_or_default()
397            });
398
399            if syscall_start_time.is_some() {
400                self.syscalls_time[syscall_number] += elapsed;
401            }
402
403            if !self.args.summary_only {
404                let info = SyscallInfo::new(pid, syscall_number, ret_code, registers, elapsed);
405                self.write_syscall_info(&info)?;
406            }
407        }
408
409        Ok(())
410    }
411
412    fn write_syscall_info(&mut self, info: &SyscallInfo) -> Result<()> {
413        if self.args.json {
414            let json = serde_json::to_string(&info)?;
415            Ok(writeln!(&mut self.output, "{json}")?)
416        } else {
417            info.write_syscall(
418                self.style_config.clone(),
419                self.string_limit,
420                self.args.syscall_number,
421                self.args.syscall_times,
422                &mut self.output,
423            )
424        }
425    }
426
427    // Issue a PTRACE_SYSCALL request to the tracee, forwarding a signal if one is provided.
428    fn issue_ptrace_syscall_request(&self, pid: Pid, signal: Option<Signal>) -> Result<()> {
429        ptrace::syscall(pid, signal)
430            .map_err(|_| anyhow!("Unable to issue a PTRACE_SYSCALL request in tracee {}", pid))
431    }
432
433    // TODO: This is arch-specific code and should be modularized
434    fn get_registers(&self, pid: Pid) -> Result<user_regs_struct> {
435        ptrace::getregs(pid).map_err(|_| anyhow!("Unable to get registers from tracee {}", pid))
436    }
437
438    fn get_syscall(&self, registers: user_regs_struct) -> Result<Sysno> {
439        #[cfg(target_arch = "x86_64")]
440        let reg = registers.orig_rax;
441        #[cfg(target_arch = "riscv64")]
442        let reg = registers.a7;
443        #[cfg(target_arch = "aarch64")]
444        let reg = registers.regs[8];
445        (reg as u32)
446            .try_into()
447            .map_err(|_| anyhow!("Invalid syscall number {}", reg))
448    }
449
450    // Issues a ptrace(PTRACE_GETREGS, ...) request and gets the corresponding syscall number (Sysno).
451    fn parse_register_data(&self, pid: Pid) -> Result<(Sysno, user_regs_struct)> {
452        let registers = self.get_registers(pid)?;
453        let syscall_number = self.get_syscall(registers)?;
454
455        Ok((syscall_number, registers))
456    }
457
458    fn is_exit_syscall(&self, pid: Pid) -> Result<bool> {
459        self.get_registers(pid).map(|registers| {
460            #[cfg(target_arch = "x86_64")]
461            let reg = registers.orig_rax;
462            #[cfg(target_arch = "riscv64")]
463            let reg = registers.a7;
464            #[cfg(target_arch = "aarch64")]
465            let reg = registers.regs[8];
466            reg == Sysno::exit as u64 || reg == Sysno::exit_group as u64
467        })
468    }
469}
470
471pub fn run_tracee(command: &[String], envs: &[String], username: &Option<String>) -> Result<()> {
472    ptrace::traceme()?;
473    personality(Personality::ADDR_NO_RANDOMIZE)
474        .map_err(|_| anyhow!("Unable to set ADDR_NO_RANDOMIZE"))?;
475    let mut binary = command
476        .get(0)
477        .ok_or_else(|| anyhow!("No command"))?
478        .to_string();
479    if let Ok(bin) = fs::canonicalize(&binary) {
480        binary = bin
481            .to_str()
482            .ok_or_else(|| anyhow!("Invalid binary path"))?
483            .to_string()
484    }
485    let mut cmd = Command::new(binary);
486    cmd.args(command[1..].iter()).stdout(Stdio::null());
487
488    for token in envs {
489        let mut parts = token.splitn(2, '=');
490        match (parts.next(), parts.next()) {
491            (Some(key), Some(value)) => cmd.env(key, value),
492            (Some(key), None) => cmd.env_remove(key),
493            _ => unreachable!(),
494        };
495    }
496
497    if let Some(username) = username {
498        if let Some(user) = get_user_by_name(username) {
499            cmd.uid(user.uid());
500        }
501    }
502
503    cmd.exec();
504
505    Ok(())
506}