diff --git a/README.md b/README.md index 0af106c..82a9f8b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,80 @@ cargo run --release --config 'target."cfg(all())".runner="sudo -E"' Cargo build scripts are used to automatically build the eBPF correctly and include it in the program. +## Usage + +Pinchy can trace syscalls for a running process or launch a new process and trace it. You can specify which syscalls to trace using the `-e` or `--event` option. + +### Basic Examples + +Trace all syscalls for a command: +```shell +pinchy ls /tmp +``` + +Trace specific syscalls: +```shell +pinchy -e read,write,open ls /tmp +``` + +Attach to a running process: +```shell +pinchy -p -e open,close +``` + +### Syscall Aliases + +Pinchy supports common syscall aliases that users might be familiar with from `libc` or other tools. + +#### Signal Syscalls + +On x86_64 and aarch64, signal-related syscalls use `rt_*` prefixes in the kernel, but you can use the more familiar names without the prefix: + +```shell +# These are equivalent: +pinchy -e sigaction,sigprocmask ./myprogram +pinchy -e rt_sigaction,rt_sigprocmask ./myprogram +``` + +Supported signal aliases (all architectures): +- `sigaction` → `rt_sigaction` +- `sigprocmask` → `rt_sigprocmask` +- `sigreturn` → `rt_sigreturn` +- `sigpending` → `rt_sigpending` +- `sigtimedwait` → `rt_sigtimedwait` +- `sigqueueinfo` → `rt_sigqueueinfo` +- `sigsuspend` → `rt_sigsuspend` + +#### Architecture-Specific Aliases + +On **aarch64**, many traditional syscalls don't exist in the kernel but are provided by glibc as wrappers around newer `*at` variants. Pinchy supports these for convenience: + +```shell +# On aarch64, these are equivalent: +pinchy -e open,stat ./myprogram +pinchy -e openat,newfstatat ./myprogram +``` + +Supported aarch64 aliases: +- `open` → `openat` +- `stat` → `newfstatat` +- `lstat` → `newfstatat` +- `poll` → `ppoll` +- `dup2` → `dup3` +- `pipe` → `pipe2` +- `access` → `faccessat` +- `chmod` → `fchmodat` +- `chown` → `fchownat` +- `link` → `linkat` +- `mkdir` → `mkdirat` +- `mknod` → `mknodat` +- `rename` → `renameat` +- `rmdir` → `unlinkat` +- `symlink` → `symlinkat` +- `unlink` → `unlinkat` + +On **x86_64**, these traditional syscalls exist directly in the kernel, so no aliases are needed. + ## Cross-compiling on macOS Cross compilation should work on both Intel and Apple Silicon Macs. diff --git a/pinchy-common/src/syscalls/aarch64.rs b/pinchy-common/src/syscalls/aarch64.rs index ef6bad6..30fdf37 100644 --- a/pinchy-common/src/syscalls/aarch64.rs +++ b/pinchy-common/src/syscalls/aarch64.rs @@ -309,4 +309,33 @@ declare_syscalls! { SYS_futex_waitv = 449, SYS_set_mempolicy_home_node = 450, SYS_mseal = 462, +; + aliases: + // Signal-related syscalls that libc users might know by their non-rt_ names + "sigaction" => SYS_rt_sigaction, + "sigprocmask" => SYS_rt_sigprocmask, + "sigreturn" => SYS_rt_sigreturn, + "sigpending" => SYS_rt_sigpending, + "sigtimedwait" => SYS_rt_sigtimedwait, + "sigqueueinfo" => SYS_rt_sigqueueinfo, + "sigsuspend" => SYS_rt_sigsuspend, + + // File/directory syscalls that don't exist on aarch64 but are provided by glibc + // These map to the *at variants with AT_FDCWD + "open" => SYS_openat, + "stat" => SYS_newfstatat, + "lstat" => SYS_newfstatat, + "poll" => SYS_ppoll, + "dup2" => SYS_dup3, + "pipe" => SYS_pipe2, + "access" => SYS_faccessat, + "chmod" => SYS_fchmodat, + "chown" => SYS_fchownat, + "link" => SYS_linkat, + "mkdir" => SYS_mkdirat, + "mknod" => SYS_mknodat, + "rename" => SYS_renameat, + "rmdir" => SYS_unlinkat, + "symlink" => SYS_symlinkat, + "unlink" => SYS_unlinkat, } diff --git a/pinchy-common/src/syscalls/mod.rs b/pinchy-common/src/syscalls/mod.rs index 906616c..cb4d07c 100644 --- a/pinchy-common/src/syscalls/mod.rs +++ b/pinchy-common/src/syscalls/mod.rs @@ -21,9 +21,20 @@ pub const SYS_generic_parse_test: i64 = i64::MAX; // for testing only macro_rules! declare_syscalls { ( $( $name:ident = $num:expr ),* $(,)? + $(; aliases: $( $alias:literal => $target:ident ),* $(,)? )? ) => { $(pub const $name: i64 = $num;)* pub fn syscall_nr_from_name(name: &str) -> Option { + // First check aliases + $( + $( + if name == $alias { + return Some($target); + } + )* + )? + + // Then check canonical names match name { $( x if x == &stringify!($name)[4..] => Some($name), )* _ => None, diff --git a/pinchy-common/src/syscalls/x86_64.rs b/pinchy-common/src/syscalls/x86_64.rs index 06efd7c..cba26a5 100644 --- a/pinchy-common/src/syscalls/x86_64.rs +++ b/pinchy-common/src/syscalls/x86_64.rs @@ -369,4 +369,14 @@ declare_syscalls! { SYS_set_mempolicy_home_node = 450, SYS_fchmodat2 = 452, SYS_mseal = 462, +; + aliases: + // Signal-related syscalls that libc users might know by their non-rt_ names + "sigaction" => SYS_rt_sigaction, + "sigprocmask" => SYS_rt_sigprocmask, + "sigreturn" => SYS_rt_sigreturn, + "sigpending" => SYS_rt_sigpending, + "sigtimedwait" => SYS_rt_sigtimedwait, + "sigqueueinfo" => SYS_rt_sigqueueinfo, + "sigsuspend" => SYS_rt_sigsuspend, } diff --git a/pinchy/Cargo.toml b/pinchy/Cargo.toml index 33cfab9..861151c 100644 --- a/pinchy/Cargo.toml +++ b/pinchy/Cargo.toml @@ -29,7 +29,7 @@ bytes = "1.10.1" zbus = { version = "5.7", features = ["tokio"] } zbus_macros = "5.7" clap = { workspace = true, default-features = true, features = ["derive"] } -nix = "0.30.1" +nix = { version = "0.30.1", features = ["user"] } [build-dependencies] anyhow = { workspace = true } diff --git a/pinchy/src/client.rs b/pinchy/src/client.rs index faa872a..f807183 100644 --- a/pinchy/src/client.rs +++ b/pinchy/src/client.rs @@ -40,7 +40,7 @@ fn parse_syscall_names(names: &[String]) -> Result, String> { #[derive(Parser, Debug)] #[command(author, version, about, trailing_var_arg = true)] struct Args { - /// Syscall(s) to trace (can be repeated or comma-separated) + /// Syscall(s) to trace (can be repeated or comma-separated). Supports aliases like 'sigaction' for 'rt_sigaction'. #[arg(short = 'e', long = "event", value_delimiter = ',', action = clap::ArgAction::Append)] syscalls: Vec, diff --git a/pinchy/src/tests/client.rs b/pinchy/src/tests/client.rs new file mode 100644 index 0000000..01d2228 --- /dev/null +++ b/pinchy/src/tests/client.rs @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2025 Gustavo Noronha Silva + +use pinchy_common::syscalls::{syscall_nr_from_name, ALL_SYSCALLS}; + +fn parse_syscall_names(names: &[String]) -> Result, String> { + let mut out = Vec::new(); + for name in names { + match syscall_nr_from_name(name) { + Some(nr) if ALL_SYSCALLS.contains(&nr) => out.push(nr), + Some(_) => return Err(format!("Syscall '{name}' is not supported by this build")), + None => return Err(format!("Unknown syscall name: {name}")), + } + } + Ok(out) +} + +#[test] +fn test_parse_syscall_names_canonical() { + // Test canonical syscall names work + let result = parse_syscall_names(&["read".to_string(), "write".to_string()]); + assert!(result.is_ok()); + let syscalls = result.unwrap(); + assert_eq!(syscalls.len(), 2); +} + +#[test] +fn test_parse_syscall_names_with_rt_prefix() { + // Test that rt_ prefixed names work + let result = parse_syscall_names(&["rt_sigaction".to_string()]); + assert!(result.is_ok()); + let syscalls = result.unwrap(); + assert_eq!(syscalls.len(), 1); +} + +#[test] +fn test_parse_syscall_names_aliases() { + // Test that aliases work (non-rt_ versions should map to rt_ versions) + let result = parse_syscall_names(&["sigaction".to_string()]); + assert!(result.is_ok()); + let syscalls = result.unwrap(); + assert_eq!(syscalls.len(), 1); + + // Verify it resolves to the same number as rt_sigaction + let rt_result = parse_syscall_names(&["rt_sigaction".to_string()]); + assert!(rt_result.is_ok()); + assert_eq!(syscalls[0], rt_result.unwrap()[0]); +} + +#[test] +fn test_parse_syscall_names_multiple_aliases() { + // Test multiple aliases at once + let result = parse_syscall_names(&[ + "sigaction".to_string(), + "sigprocmask".to_string(), + "sigreturn".to_string(), + ]); + assert!(result.is_ok()); + let syscalls = result.unwrap(); + assert_eq!(syscalls.len(), 3); +} + +#[test] +fn test_parse_syscall_names_mixed() { + // Test mixing canonical names and aliases + let result = parse_syscall_names(&[ + "read".to_string(), + "sigaction".to_string(), + "write".to_string(), + ]); + assert!(result.is_ok()); + let syscalls = result.unwrap(); + assert_eq!(syscalls.len(), 3); +} + +#[test] +fn test_parse_syscall_names_unknown() { + // Test that unknown syscall names produce an error + let result = parse_syscall_names(&["nonexistent_syscall".to_string()]); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Unknown syscall name: nonexistent_syscall")); +} + +#[test] +#[cfg(target_arch = "aarch64")] +fn test_parse_syscall_names_aarch64_aliases() { + // Test aarch64-specific aliases that map to *at variants + let result = parse_syscall_names(&[ + "open".to_string(), + "stat".to_string(), + "poll".to_string(), + ]); + assert!(result.is_ok()); + let syscalls = result.unwrap(); + assert_eq!(syscalls.len(), 3); + + // Verify aliases resolve to the *at variants + let open_nr = syscall_nr_from_name("open"); + let openat_nr = syscall_nr_from_name("openat"); + assert_eq!(open_nr, openat_nr); +} + +#[test] +#[cfg(target_arch = "x86_64")] +fn test_parse_syscall_names_x86_64_direct() { + // On x86_64, these syscalls exist directly (not as aliases) + let result = parse_syscall_names(&[ + "open".to_string(), + "stat".to_string(), + "poll".to_string(), + ]); + assert!(result.is_ok()); + let syscalls = result.unwrap(); + assert_eq!(syscalls.len(), 3); +} diff --git a/pinchy/src/tests/mod.rs b/pinchy/src/tests/mod.rs index c380d1e..3603274 100644 --- a/pinchy/src/tests/mod.rs +++ b/pinchy/src/tests/mod.rs @@ -2,6 +2,7 @@ // Copyright (c) 2025 Gustavo Noronha Silva mod basic_io; +mod client; mod filesystem; mod ipc; mod memory; diff --git a/pinchy/tests/integration.rs b/pinchy/tests/integration.rs index ff3ad90..1ff767b 100644 --- a/pinchy/tests/integration.rs +++ b/pinchy/tests/integration.rs @@ -2190,3 +2190,145 @@ fn process_identity_test() { .success() .stdout(predicate::str::ends_with("Exiting...\n")); } + +// Tests for syscall aliases - these verify that users can use aliased names +// and get the same behavior as using canonical names + +#[test] +#[serial] +#[ignore = "runs in special environment"] +fn signal_alias_sigprocmask() { + let pinchy = PinchyTest::new(None, None); + + // Use the alias "sigprocmask" instead of "rt_sigprocmask" + let handle = run_workload(&["sigprocmask"], "rt_sig"); + + // Expected output should show rt_sigprocmask (the canonical name) + let expected_output = escaped_regex(indoc! {r#" + @PID@ rt_sigprocmask(how: SIG_BLOCK, set: [SIGUSR1], oldset: [], sigsetsize: 8) = 0 (success) + @PID@ rt_sigprocmask(how: SIG_SETMASK, set: NULL, oldset: [SIGUSR1], sigsetsize: 8) = 0 (success) + @PID@ rt_sigprocmask(how: SIG_UNBLOCK, set: [SIGUSR1], oldset: NULL, sigsetsize: 8) = 0 (success) + @PID@ rt_sigprocmask(how: SIG_SETMASK, set: [], oldset: NULL, sigsetsize: 8) = 0 (success) + "#}); + + let output = handle.join().unwrap(); + Assert::new(output) + .success() + .stdout(predicate::str::is_match(&expected_output).unwrap()); + + let output = pinchy.wait(); + Assert::new(output) + .success() + .stdout(predicate::str::ends_with("Exiting...\n")); +} + +#[test] +#[serial] +#[ignore = "runs in special environment"] +fn signal_alias_sigaction() { + let pinchy = PinchyTest::new(None, None); + + // Use the alias "sigaction" instead of "rt_sigaction" + let handle = run_workload(&["sigaction"], "rt_sigaction_standard"); + + // Expected output should show rt_sigaction (the canonical name) + let expected_output = escaped_regex(indoc! {r#" + @PID@ rt_sigaction(signum: SIGUSR1, act: @ADDR@, oldact: @ADDR@, sigsetsize: 8) = 0 (success) + @PID@ rt_sigaction(signum: SIGUSR1, act: 0x0, oldact: @ADDR@, sigsetsize: 8) = 0 (success) + @PID@ rt_sigaction(signum: SIGUSR1, act: @ADDR@, oldact: 0x0, sigsetsize: 8) = 0 (success) + "#}); + + let output = handle.join().unwrap(); + Assert::new(output) + .success() + .stdout(predicate::str::is_match(&expected_output).unwrap()); + + let output = pinchy.wait(); + Assert::new(output) + .success() + .stdout(predicate::str::ends_with("Exiting...\n")); +} + +#[test] +#[serial] +#[ignore = "runs in special environment"] +#[cfg(target_arch = "aarch64")] +fn aarch64_alias_open() { + let pinchy = PinchyTest::new(None, None); + + // On aarch64, use the alias "open" which maps to "openat" + let handle = run_workload(&["open", "read", "lseek"], "pinchy_reads"); + + // Expected output should show openat (the canonical name on aarch64) + let expected_output = escaped_regex(indoc! {r#" + @PID@ openat(dirfd: AT_FDCWD, pathname: "pinchy/tests/GPLv2", flags: 0x0 (O_RDONLY), mode: 0) = 3 + @PID@ read(fd: 3, buf: @READBUF@, count: 128) = 128 (bytes) + @PID@ lseek(fd: 3, offset: 0, whence: SEEK_SET) = 0 (bytes) + "#}); + + let output = handle.join().unwrap(); + Assert::new(output) + .success() + .stdout(predicate::str::is_match(&expected_output).unwrap()); + + let output = pinchy.wait(); + Assert::new(output) + .success() + .stdout(predicate::str::ends_with("Exiting...\n")); +} + +#[test] +#[serial] +#[ignore = "runs in special environment"] +#[cfg(target_arch = "aarch64")] +fn aarch64_alias_stat() { + let pinchy = PinchyTest::new(None, None); + + // On aarch64, use the alias "stat" which maps to "newfstatat" + let handle = run_workload(&["stat", "fstat"], "filesystem_syscalls_test"); + + // Expected output should show newfstatat (the canonical name on aarch64) + // Note: We only filter for stat/newfstatat, so we won't see getdents64 + let expected_output = escaped_regex(indoc! {r#" + @PID@ fstat(fd: @NUMBER@, struct stat: { mode: 0o@NUMBER@ (@MODE@), ino: @NUMBER@, dev: @NUMBER@, nlink: @NUMBER@, uid: @NUMBER@, gid: @NUMBER@, size: 18092, blksize: @NUMBER@, blocks: @NUMBER@, atime: @NUMBER@, mtime: @NUMBER@, ctime: @NUMBER@ }) = 0 (success) + @PID@ newfstatat(dirfd: AT_FDCWD, pathname: "pinchy/tests/GPLv2", struct stat: { mode: 0o@NUMBER@ (@MODE@), ino: @NUMBER@, dev: @NUMBER@, nlink: @NUMBER@, uid: @NUMBER@, gid: @NUMBER@, size: 18092, blksize: @NUMBER@, blocks: @NUMBER@, atime: @NUMBER@, mtime: @NUMBER@, ctime: @NUMBER@ }, flags: 0) = 0 (success) + "#}); + + let output = handle.join().unwrap(); + Assert::new(output) + .success() + .stdout(predicate::str::is_match(&expected_output).unwrap()); + + let output = pinchy.wait(); + Assert::new(output) + .success() + .stdout(predicate::str::ends_with("Exiting...\n")); +} + +#[test] +#[serial] +#[ignore = "runs in special environment"] +fn mixed_aliases_and_canonical() { + let pinchy = PinchyTest::new(None, None); + + // Mix aliases and canonical names in the same filter + let handle = run_workload(&["sigprocmask", "rt_sigaction"], "rt_sig"); + + // Should capture rt_sigprocmask calls (from sigprocmask alias) + let expected_output = escaped_regex(indoc! {r#" + @PID@ rt_sigprocmask(how: SIG_BLOCK, set: [SIGUSR1], oldset: [], sigsetsize: 8) = 0 (success) + @PID@ rt_sigprocmask(how: SIG_SETMASK, set: NULL, oldset: [SIGUSR1], sigsetsize: 8) = 0 (success) + @PID@ rt_sigprocmask(how: SIG_UNBLOCK, set: [SIGUSR1], oldset: NULL, sigsetsize: 8) = 0 (success) + @PID@ rt_sigprocmask(how: SIG_SETMASK, set: [], oldset: NULL, sigsetsize: 8) = 0 (success) + "#}); + + let output = handle.join().unwrap(); + Assert::new(output) + .success() + .stdout(predicate::str::is_match(&expected_output).unwrap()); + + let output = pinchy.wait(); + Assert::new(output) + .success() + .stdout(predicate::str::ends_with("Exiting...\n")); +}