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