Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion samply-mac-preload/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions samply-mac-preload/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,7 @@ panic = 'abort'

[dependencies]
libc = { version = "0.2", default-features = false }
heapless = { version = "0.8", default-features = false }
log = { version = "0.4", default-features = false }
spin = "0.9.8"

Binary file modified samply-mac-preload/binaries/libsamply_mac_preload.dylib
Binary file not shown.
Binary file modified samply-mac-preload/binaries/libsamply_mac_preload_arm64.dylib
Binary file not shown.
Binary file modified samply-mac-preload/binaries/libsamply_mac_preload_arm64e.dylib
Binary file not shown.
Binary file modified samply-mac-preload/binaries/libsamply_mac_preload_x86_64.dylib
Binary file not shown.
4 changes: 2 additions & 2 deletions samply-mac-preload/build.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
export SDKROOT=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk
export SDKROOT="$(xcrun --show-sdk-path)"
MACOSX_DEPLOYMENT_TARGET=10.12 cargo build --release --target=x86_64-apple-darwin
mv target/x86_64-apple-darwin/release/libsamply_mac_preload.dylib binaries/libsamply_mac_preload_x86_64.dylib
MACOSX_DEPLOYMENT_TARGET=11.0 cargo build --release --target=aarch64-apple-darwin
mv target/aarch64-apple-darwin/release/libsamply_mac_preload.dylib binaries/libsamply_mac_preload_arm64.dylib
MACOSX_DEPLOYMENT_TARGET=11.0 RUSTC_BOOTSTRAP=1 cargo build --release --target=arm64e-apple-darwin -Zbuild-std=core
MACOSX_DEPLOYMENT_TARGET=11.0 RUSTC_BOOTSTRAP=1 CARGO_TARGET_ARM64E_APPLE_DARWIN_LINKER=clang cargo build --release --target=arm64e-apple-darwin -Zbuild-std=core
mv target/arm64e-apple-darwin/release/libsamply_mac_preload.dylib binaries/libsamply_mac_preload_arm64e.dylib
lipo binaries/libsamply_mac_preload_* -create -output binaries/libsamply_mac_preload.dylib
gzip -cvf binaries/libsamply_mac_preload.dylib > ../samply/resources/libsamply_mac_preload.dylib.gz
59 changes: 59 additions & 0 deletions samply-mac-preload/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ use libc::{c_char, c_int, mode_t, FILE};

use core::ffi::CStr;

mod logging;
mod mach_ipc;
mod mach_sys;
mod sip_warn;

use mach_ipc::{channel, mach_task_self, OsIpcChannel, OsIpcSender};

Expand All @@ -30,12 +32,59 @@ fn panic(_panic: &core::panic::PanicInfo<'_>) -> ! {
#[cfg_attr(target_os = "macos", link_section = "__DATA,__mod_init_func")]
static __SETUP_SAMPLY_CONNECTION: unsafe extern "C" fn() = {
unsafe extern "C" fn __load_samply_lib() {
logging::init();
let _ = set_up_samply_connection();
}
__load_samply_lib
};

/// Returns true if the current process is an Apple "platform binary"
/// (`CS_PLATFORM_BINARY`). Such processes are given an *immovable* task-self
/// mach port by the kernel: any attempt to transfer that port to another
/// process — which is exactly what samply's task handoff below does — raises a
/// fatal `EXC_GUARD` (`ILLEGAL_MOVE`) and the kernel SIGKILLs the process.
///
/// This is how `samply record -- <build>` would otherwise kill `dsymutil` (and
/// other Apple toolchain binaries) that a build invokes: they inherit samply's
/// `DYLD_INSERT_LIBRARIES`, load this preload, and crash in the handoff.
///
/// samply cannot profile platform binaries through this mechanism regardless
/// (their task port is protected), so detecting this case and skipping the
/// handoff loses nothing and keeps the process alive.
fn is_platform_binary() -> bool {
// `csops(getpid(), CS_OPS_STATUS, &flags, sizeof(flags))` reports the
// process's code-signing status flags. It works on the calling process
// without any privilege. CS_PLATFORM_BINARY == 0x04000000.
const CS_OPS_STATUS: u32 = 0;
const CS_PLATFORM_BINARY: u32 = 0x0400_0000;
extern "C" {
fn csops(
pid: libc::pid_t,
ops: u32,
useraddr: *mut libc::c_void,
usersize: libc::size_t,
) -> libc::c_int;
}
let mut flags: u32 = 0;
let r = unsafe {
csops(
libc::getpid(),
CS_OPS_STATUS,
&mut flags as *mut u32 as *mut libc::c_void,
core::mem::size_of::<u32>() as libc::size_t,
)
};
r == 0 && (flags & CS_PLATFORM_BINARY) != 0
}

fn set_up_samply_connection() -> Option<()> {
// Don't hand our task port to samply if we're a platform binary: the port
// is immovable and sending it would get us SIGKILLed. See
// `is_platform_binary`.
if is_platform_binary() {
log::debug!("samply_preload: skipping handoff (platform binary)");
return None;
}
let (tx0, rx0) = channel().ok()?;
// Safety:
// - b"SAMPLY_BOOTSTRAP_SERVER_NAME\0" is a nul-terminated c string
Expand Down Expand Up @@ -64,6 +113,7 @@ fn set_up_samply_connection() -> Option<()> {
let mut recv_buf = [0; 256];
let result = rx0.recv(&mut recv_buf).ok()?;
assert_eq!(b"Proceed", &result);
log::debug!("samply_preload: ready");
Some(())
}

Expand Down Expand Up @@ -159,6 +209,15 @@ pub struct InterposeEntry {
_old: *const (),
}

impl InterposeEntry {
pub const fn new(new: *const (), old: *const ()) -> Self {
InterposeEntry {
_new: new,
_old: old,
}
}
}

#[used]
#[allow(dead_code)]
#[allow(non_upper_case_globals)]
Expand Down
38 changes: 38 additions & 0 deletions samply-mac-preload/src/logging.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use core::fmt::Write;
use libc::c_void;

struct StderrLogger;

impl log::Log for StderrLogger {
fn enabled(&self, _: &log::Metadata) -> bool {
true
}

fn log(&self, record: &log::Record) {
// Format into a fixed stack buffer — there is no allocator in a preload.
// Messages are short; on overflow we just write the truncated prefix.
let mut buf = heapless::String::<512>::new();
let _ = writeln!(buf, "{}", record.args());
unsafe {
libc::write(
libc::STDERR_FILENO,
buf.as_ptr() as *const c_void,
buf.len(),
);
}
}

fn flush(&self) {}
}

static LOGGER: StderrLogger = StderrLogger;

/// Install the stderr logger iff `SAMPLY_PRELOAD_DEBUG` is set. Idempotent
/// (a second `set_logger` simply fails and is ignored). Call once, early, from
/// the preload constructor; if never called the max level stays `Off`.
pub(crate) fn init() {
let enabled = unsafe { !libc::getenv(c"SAMPLY_PRELOAD_DEBUG".as_ptr()).is_null() };
if enabled && log::set_logger(&LOGGER).is_ok() {
log::set_max_level(log::LevelFilter::Trace);
}
}
150 changes: 150 additions & 0 deletions samply-mac-preload/src/sip_warn/detect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! Decide whether an exec will drop into a SIP-protected (un-injectable)
//! process, and if so warn the user. We never modify the exec — we only observe
//! and print.
//!
//! The warning explains *why* the process can't be profiled (SIP strips the
//! `DYLD_*` variable samply relies on) and what the user can do about it, and is
//! emitted as a GitHub Actions `::warning::` annotation when running in CI so it
//! surfaces in the workflow log.

use core::ffi::{c_void, CStr};
use core::fmt::Write;
use libc::{c_char, c_int};

/// Called from the exec/posix_spawn interposers. If SIP is active and the
/// effective binary (the target itself, or — for a `#!` script — its shebang
/// interpreter) is SIP-protected, print a warning. Always returns; the caller
/// then performs the real, unmodified exec.
pub(super) unsafe fn check_and_warn(path: *const c_char) {
// If SIP is off, DYLD_* survives even for protected binaries, so the preload
// still loads and there's nothing to warn about.
if path.is_null() || !sip_enabled() {
return;
}
let path = CStr::from_ptr(path);

if is_sip_protected(path) {
warn(path);
return;
}

// Not a protected binary itself — but it may be a script whose shebang
// interpreter is one (e.g. `pnpm` -> `#!/usr/bin/env node`). The kernel
// execs that interpreter, and that's where DYLD_* gets stripped.
let mut buf = [0u8; libc::PATH_MAX as usize];
if let Some(interp) = shebang_interpreter(path, &mut buf) {
if is_sip_protected(interp) {
warn(interp);
}
}
}

/// Is System Integrity Protection enabled?
fn sip_enabled() -> bool {
// `csr_check(mask)` returns 0 when the active configuration *allows* the
// operation in `mask` (i.e. that protection is off); non-zero means the
// protection is active. If unrestricted filesystem access is not allowed,
// SIP is on.
const CSR_ALLOW_UNRESTRICTED_FS: u32 = 1 << 1;
extern "C" {
fn csr_check(mask: u32) -> c_int;
}
unsafe { csr_check(CSR_ALLOW_UNRESTRICTED_FS) != 0 }
}

/// Is `path` a SIP-protected system binary? Such binaries carry the restricted
/// file flag, which is what makes the kernel strip `DYLD_*` on exec.
unsafe fn is_sip_protected(path: &CStr) -> bool {
const SF_RESTRICTED: u32 = 0x0008_0000; // from <sys/stat.h>
let mut st: libc::stat = core::mem::zeroed();
if libc::stat(path.as_ptr(), &mut st) != 0 {
return false;
}
st.st_flags & SF_RESTRICTED != 0
}

const SHEBANG_READ_SZ: usize = 256;

/// If `path` is a regular file with a `#!` shebang, return the interpreter path
/// (written into `buf`). Only the interpreter is needed — we don't rebuild argv.
unsafe fn shebang_interpreter<'a>(path: &CStr, buf: &'a mut [u8]) -> Option<&'a CStr> {
// Only regular files can be scripts (also resolves relative paths via CWD).
let mut st: libc::stat = core::mem::zeroed();
if libc::stat(path.as_ptr(), &mut st) != 0 || (st.st_mode & libc::S_IFMT) != libc::S_IFREG {
return None;
}

// Read the first bytes looking for a shebang. `libc::open` here is NOT routed
// back through our own interpose hook: dyld does not re-interpose an image's
// references to symbols it itself interposes.
let mut read_buf = [0u8; SHEBANG_READ_SZ];
let fd = libc::open(path.as_ptr(), libc::O_RDONLY);
if fd < 0 {
return None;
}
let nread = libc::read(fd, read_buf.as_mut_ptr() as *mut c_void, read_buf.len());
libc::close(fd);
if nread < 2 || read_buf[0] != b'#' || read_buf[1] != b'!' {
return None;
}

// Parse "#! <interpreter> [<arg>] \n" — we only want <interpreter>.
let line = match read_buf[..nread as usize].iter().position(|&b| b == b'\n') {
Some(pos) => &read_buf[..pos],
None => return None, // truncated / no newline within the window
};

let mut start = 2;
while start < line.len() && (line[start] == b' ' || line[start] == b'\t') {
start += 1;
}
if start >= line.len() {
return None;
}
let interp_end = line[start..]
.iter()
.position(|&b| b == b' ' || b == b'\t')
.map(|p| start + p)
.unwrap_or(line.len());

let interp = &line[start..interp_end];
if interp.is_empty() || interp.len() >= buf.len() {
return None;
}
buf[..interp.len()].copy_from_slice(interp);
buf[interp.len()] = 0;
CStr::from_bytes_with_nul(&buf[..=interp.len()]).ok()
}

/// Print the warning to stderr, as a GitHub Actions annotation when in CI.
unsafe fn warn(binary: &CStr) {
let bin = core::str::from_utf8(binary.to_bytes()).unwrap_or("<unknown>");

let mut msg = heapless::String::<1024>::new();
let prefix = if in_github_actions() {
"::warning title=CodSpeed cannot profile a system process::"
} else {
"[CodSpeed] warning: "
};

const SIP_DOCS_URL: &str = "https://codspeed.io/docs/instruments/walltime/macos-profiling";

let _ = writeln!(
msg,
"{prefix}CodSpeed could not profile the system process `{bin}`. System Integrity Protection (SIP) removes the DYLD_INSERT_LIBRARIES \
environment variable that samply uses to attach to a process whenever a protected Apple system binary is executed, so this process and its children \
are invisible to the profiler. See {SIP_DOCS_URL} for more information."
);

libc::write(
libc::STDERR_FILENO,
msg.as_ptr() as *const c_void,
msg.len(),
);
}

/// True iff `GITHUB_ACTIONS=true` in the environment.
unsafe fn in_github_actions() -> bool {
let v = libc::getenv(c"GITHUB_ACTIONS".as_ptr());
!v.is_null() && CStr::from_ptr(v).to_bytes() == b"true"
}
Loading