diff --git a/bin_tests/Cargo.toml b/bin_tests/Cargo.toml index 7e5e9c3c45..4e91b94665 100644 --- a/bin_tests/Cargo.toml +++ b/bin_tests/Cargo.toml @@ -6,6 +6,7 @@ name = "bin_tests" version = "0.1.0" edition = "2021" publish = false +build = "build.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/bin_tests/build.rs b/bin_tests/build.rs new file mode 100644 index 0000000000..ee292834f9 --- /dev/null +++ b/bin_tests/build.rs @@ -0,0 +1,30 @@ +// Copyright 2025-Present Datadog, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(unix)] +fn main() { + use std::env; + use std::path::PathBuf; + use std::process::Command; + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set")); + let src = PathBuf::from("preload/preload.c"); + let so_path = out_dir.join("libpreload_logger.so"); + + let status = Command::new("cc") + .args(["-std=gnu11", "-fPIC", "-shared", "-Wall", "-Wextra", "-o"]) + .arg(&so_path) + .arg(&src) + .status() + .expect("failed to spawn cc"); + + if !status.success() { + panic!("compiling preload.c failed with status {status}"); + } + + // Make the built shared object path available at compile time for tests/tools. + println!("cargo:rustc-env=PRELOAD_LOGGER_SO={}", so_path.display()); +} + +#[cfg(not(unix))] +fn main() {} diff --git a/bin_tests/preload/preload.c b/bin_tests/preload/preload.c new file mode 100644 index 0000000000..677a2a2596 --- /dev/null +++ b/bin_tests/preload/preload.c @@ -0,0 +1,162 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __GLIBC__ +#include +#define DD_HAVE_EXECINFO 1 +#else +#define DD_HAVE_EXECINFO 0 +#endif + +static void *(*real_malloc)(size_t) = NULL; +static void (*real_free)(void *) = NULL; +static void *(*real_calloc)(size_t, size_t) = NULL; +static void *(*real_realloc)(void *, size_t) = NULL; +static pthread_once_t init_once = PTHREAD_ONCE_INIT; + +// We should load all the real symbols on library load +static void init_function_ptrs(void) { + if (real_malloc == NULL) { + real_malloc = dlsym(RTLD_NEXT, "malloc"); + real_free = dlsym(RTLD_NEXT, "free"); + real_calloc = dlsym(RTLD_NEXT, "calloc"); + real_realloc = dlsym(RTLD_NEXT, "realloc"); + } +} + +__attribute__((constructor)) static void preload_ctor(void) { + pthread_once(&init_once, init_function_ptrs); +} + +static int log_fd = -1; +// Flag to indicate we are currently in the collector; we should only +// detect allocations when we are in the collector. +// Must be thread-local: the collector work runs on a single thread; other +// threads in the process should not be considered "collector" and should +// not trip the detector. +static __thread int collector_marked = 0; + +// Called by the collector process to enable detection in the collector only +void dd_preload_logger_mark_collector(void) { + collector_marked = 1; + if (log_fd >= 0 || !collector_marked) { + // Already initialized or not a collector + return; + } +} + +static void write_int(int fd, long value) { + char buf[32]; + int i = 0; + + if (value == 0) { + write(fd, "0", 1); + return; + } + + if (value < 0) { + write(fd, "-", 1); + value = -value; + } + + while (value > 0 && i < (int)sizeof(buf)) { + buf[i++] = '0' + (value % 10); + value /= 10; + } + + for (int j = i - 1; j >= 0; j--) { + write(fd, &buf[j], 1); + } +} + +// This function MUST be async signal safe +static void capture_and_report_allocation(const char *func_name) { + const char *path = "/tmp/preload_detector.log"; + log_fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0644); + if (log_fd >= 0) { + pid_t pid = getpid(); + long tid = syscall(SYS_gettid); + + write(log_fd, + "[FATAL] Dangerous allocation detected in collector!\n", + 52); + + write(log_fd, " Function: ", 12); + write(log_fd, func_name, strlen(func_name)); + + write(log_fd, "\n PID: ", 8); + write_int(log_fd, pid); + + write(log_fd, "\n TID: ", 8); + write_int(log_fd, tid); + + close(log_fd); + log_fd = -1; + abort(); + } +} + +void *malloc(size_t size) { + if (real_malloc == NULL) { + errno = ENOMEM; + return NULL; + } + + if (collector_marked) { + capture_and_report_allocation("malloc"); + } + + void *ptr = real_malloc(size); + return ptr; +} + +void free(void *ptr) { + if (real_free == NULL) { + return; + } + + // free is generally safe; we'll allow free operations without failing + real_free(ptr); +} + +void *calloc(size_t nmemb, size_t size) { + if (real_calloc == NULL) { + errno = ENOMEM; + return NULL; + } + + if (collector_marked) { + capture_and_report_allocation("calloc"); + } + + void *ptr = real_calloc(nmemb, size); + return ptr; +} + +void *realloc(void *ptr, size_t size) { + if (real_realloc == NULL) { + errno = ENOMEM; + return NULL; + } + + if (collector_marked) { + capture_and_report_allocation("realloc"); + } + + void *new_ptr = real_realloc(ptr, size); + return new_ptr; +} diff --git a/bin_tests/src/bin/crashtracker_bin_test.rs b/bin_tests/src/bin/crashtracker_bin_test.rs index 2de19292db..6ab95b6512 100644 --- a/bin_tests/src/bin/crashtracker_bin_test.rs +++ b/bin_tests/src/bin/crashtracker_bin_test.rs @@ -19,6 +19,7 @@ mod unix { }; use std::env; use std::path::Path; + use std::process; use std::time::Duration; use libdd_common::{tag, Endpoint}; @@ -43,7 +44,8 @@ mod unix { } pub fn main() -> anyhow::Result<()> { - let mut args = env::args().skip(1); + let raw_args: Vec = env::args().collect(); + let mut args = raw_args.iter().skip(1); let output_url = args.next().context("Unexpected number of arguments")?; let receiver_binary = args.next().context("Unexpected number of arguments")?; let output_dir = args.next().context("Unexpected number of arguments")?; @@ -51,6 +53,21 @@ mod unix { let crash_typ = args.next().context("Missing crash type")?; anyhow::ensure!(args.next().is_none(), "unexpected extra arguments"); + // For preload logger mode, ensure we actually start with LD_PRELOAD applied. + // Setting LD_PRELOAD after startup has no effect on the current process, + // so re-exec only if we weren't born with it + if mode_str == "runtime_preload_logger" && env::var_os("LD_PRELOAD").is_none() { + if let Some(so_path) = option_env!("PRELOAD_LOGGER_SO") { + let status = process::Command::new(&raw_args[0]) + .args(&raw_args[1..]) + .env("LD_PRELOAD", so_path) + .status() + .context("failed to re-exec with LD_PRELOAD")?; + let code = status.code().unwrap_or(1); + process::exit(code); + } + } + let stderr_filename = format!("{output_dir}/out.stderr"); let stdout_filename = format!("{output_dir}/out.stdout"); let output_dir: &Path = output_dir.as_ref(); @@ -58,18 +75,33 @@ mod unix { let endpoint = if output_url.is_empty() { None } else { - Some(Endpoint::from_slice(&output_url)) + Some(Endpoint::from_slice(output_url)) }; // The configuration can be modified by a Behavior (testing plan), so it is mut here. // Unlike a normal harness, in this harness tests are run in individual processes, so race // issues are avoided. + let stacktrace_collection = match env::var("DD_TEST_STACKTRACE_COLLECTION") { + Ok(val) => match val.as_str() { + "disabled" => crashtracker::StacktraceCollection::Disabled, + "without_symbols" => crashtracker::StacktraceCollection::WithoutSymbols, + "inprocess_symbols" => { + crashtracker::StacktraceCollection::EnabledWithInprocessSymbols + } + "receiver_symbols" => { + crashtracker::StacktraceCollection::EnabledWithSymbolsInReceiver + } + _ => crashtracker::StacktraceCollection::WithoutSymbols, + }, + Err(_) => crashtracker::StacktraceCollection::WithoutSymbols, + }; + let mut config = CrashtrackerConfiguration::new( vec![], true, true, endpoint, - crashtracker::StacktraceCollection::WithoutSymbols, + stacktrace_collection, crashtracker::default_signals(), Some(TEST_COLLECTOR_TIMEOUT), Some("".to_string()), @@ -92,7 +124,7 @@ mod unix { }; // Set the behavior of the test, run setup, and do the pre-init test - let behavior = get_behavior(&mode_str); + let behavior = get_behavior(mode_str); behavior.setup(output_dir, &mut config)?; behavior.pre(output_dir)?; @@ -101,7 +133,7 @@ mod unix { CrashtrackerReceiverConfig::new( vec![], env::vars().collect(), - receiver_binary, + receiver_binary.to_string(), Some(stderr_filename), Some(stdout_filename), )?, diff --git a/bin_tests/src/modes/behavior.rs b/bin_tests/src/modes/behavior.rs index c5bdc51102..3099f49678 100644 --- a/bin_tests/src/modes/behavior.rs +++ b/bin_tests/src/modes/behavior.rs @@ -137,6 +137,7 @@ pub fn get_behavior(mode_str: &str) -> Box { "panic_hook_after_fork" => Box::new(test_013_panic_hook_after_fork::Test), "panic_hook_string" => Box::new(test_014_panic_hook_string::Test), "panic_hook_unknown_type" => Box::new(test_015_panic_hook_unknown_type::Test), + "runtime_preload_logger" => Box::new(test_000_donothing::Test), _ => panic!("Unknown mode: {mode_str}"), } } diff --git a/bin_tests/src/test_runner.rs b/bin_tests/src/test_runner.rs index 1f20dcb53b..af47bcc63d 100644 --- a/bin_tests/src/test_runner.rs +++ b/bin_tests/src/test_runner.rs @@ -6,6 +6,7 @@ //! across different test scenarios. use crate::{ + build_artifacts, test_types::{CrashType, TestMode}, validation::{read_and_parse_crash_payload, validate_std_outputs, PayloadValidator}, ArtifactType, ArtifactsBuild, BuildProfile, @@ -270,6 +271,30 @@ where Ok(()) } +/// Minimal runner for scenarios where the process may not emit a crash report +/// (preload allocation detector). It just runs the binary and waits. +pub fn run_crash_no_op(config: &CrashTestConfig) -> Result<()> { + let artifacts = StandardArtifacts::new(config.profile); + let artifacts_map = build_artifacts(&artifacts.as_slice())?; + let fixtures = TestFixtures::new()?; + + let mut cmd = process::Command::new(&artifacts_map[&artifacts.crashtracker_bin]); + cmd.arg(format!("file://{}", fixtures.crash_profile_path.display())) + .arg(&artifacts_map[&artifacts.crashtracker_receiver]) + .arg(&fixtures.output_dir) + .arg(config.mode.as_str()) + .arg(config.crash_type.as_str()); + + for (key, val) in &config.env_vars { + cmd.env(key, val); + } + + let mut child = cmd.spawn().context("Failed to spawn test process")?; + let _ = child.wait(); + + Ok(()) +} + /// Validates the process exit status matches expectations for the crash type. fn assert_exit_status(exit_status: process::ExitStatus, crash_type: CrashType) -> Result<()> { let expected_success = crash_type.expects_success(); diff --git a/bin_tests/src/test_types.rs b/bin_tests/src/test_types.rs index 26331c8551..1666264f77 100644 --- a/bin_tests/src/test_types.rs +++ b/bin_tests/src/test_types.rs @@ -18,6 +18,7 @@ pub enum TestMode { RuntimeCallbackFrame, RuntimeCallbackString, RuntimeCallbackFrameInvalidUtf8, + RuntimePreloadLogger, } impl TestMode { @@ -37,6 +38,7 @@ impl TestMode { Self::RuntimeCallbackFrame => "runtime_callback_frame", Self::RuntimeCallbackString => "runtime_callback_string", Self::RuntimeCallbackFrameInvalidUtf8 => "runtime_callback_frame_invalid_utf8", + Self::RuntimePreloadLogger => "runtime_preload_logger", } } @@ -56,6 +58,7 @@ impl TestMode { Self::RuntimeCallbackFrame, Self::RuntimeCallbackString, Self::RuntimeCallbackFrameInvalidUtf8, + Self::RuntimePreloadLogger, ] } } @@ -84,6 +87,7 @@ impl std::str::FromStr for TestMode { "runtime_callback_frame" => Ok(Self::RuntimeCallbackFrame), "runtime_callback_string" => Ok(Self::RuntimeCallbackString), "runtime_callback_frame_invalid_utf8" => Ok(Self::RuntimeCallbackFrameInvalidUtf8), + "runtime_preload_logger" => Ok(Self::RuntimePreloadLogger), _ => Err(format!("Unknown test mode: {}", s)), } } diff --git a/bin_tests/tests/crashtracker_bin_test.rs b/bin_tests/tests/crashtracker_bin_test.rs index 887f9362f0..78cb44eabd 100644 --- a/bin_tests/tests/crashtracker_bin_test.rs +++ b/bin_tests/tests/crashtracker_bin_test.rs @@ -12,7 +12,10 @@ use std::{fs, path::PathBuf}; use anyhow::Context; use bin_tests::{ build_artifacts, - test_runner::{run_crash_test_with_artifacts, CrashTestConfig, StandardArtifacts, ValidatorFn}, + test_runner::{ + run_crash_no_op, run_crash_test_with_artifacts, CrashTestConfig, StandardArtifacts, + ValidatorFn, + }, test_types::{CrashType, TestMode}, validation::PayloadValidator, ArtifactType, ArtifactsBuild, BuildProfile, @@ -180,6 +183,55 @@ fn test_crash_tracking_bin_no_runtime_callback() { run_crash_test_with_artifacts(&config, &artifacts_map, &artifacts, validator).unwrap(); } +#[test] +#[cfg_attr(miri, ignore)] +#[cfg(target_os = "linux")] +fn test_collector_no_allocations_stacktrace_modes() { + // (env_value, should_expect_log) + let cases = [ + ("disabled", false), + ("without_symbols", false), + ("receiver_symbols", false), + ("inprocess_symbols", true), + ]; + + for (env_value, expect_log) in cases { + let detector_log_path = PathBuf::from("/tmp/preload_detector.log"); + + // Clean up + let _ = fs::remove_file(&detector_log_path); + + let config = CrashTestConfig::new( + BuildProfile::Debug, + TestMode::RuntimePreloadLogger, + CrashType::NullDeref, + ) + .with_env("DD_TEST_STACKTRACE_COLLECTION", env_value); + + let result = run_crash_no_op(&config); + + let log_exists = detector_log_path.exists(); + + if expect_log { + assert!( + log_exists, + "Expected allocation detection log for mode {env_value}" + ); + if log_exists { + if let Ok(bytes) = fs::read(&detector_log_path) { + eprintln!("{}", String::from_utf8_lossy(&bytes)); + } + } + } else { + result.unwrap(); + assert!( + !log_exists, + "Did not expect allocation detection log for mode {env_value}" + ); + } + } +} + #[test] #[cfg_attr(miri, ignore)] fn test_crash_tracking_bin_runtime_callback_frame_invalid_utf8() { diff --git a/libdd-crashtracker/src/collector/api.rs b/libdd-crashtracker/src/collector/api.rs index 91e1e78a10..72789d311e 100644 --- a/libdd-crashtracker/src/collector/api.rs +++ b/libdd-crashtracker/src/collector/api.rs @@ -17,6 +17,19 @@ pub fn default_signals() -> Vec { Vec::from(DEFAULT_SYMBOLS) } +pub(super) fn mark_preload_logger_collector() { + // This function is specific only for LD_PRELOAD testing + // Best effort; this symbol exists only when the preload logger preload is present. + const SYMBOL: &[u8] = b"dd_preload_logger_mark_collector\0"; + unsafe { + let sym = libc::dlsym(libc::RTLD_DEFAULT, SYMBOL.as_ptr() as *const _); + if !sym.is_null() { + let func: extern "C" fn() = std::mem::transmute(sym); + func(); + } + } +} + /// Reinitialize the crash-tracking infrastructure after a fork. /// This should be one of the first things done after a fork, to minimize the /// chance that a crash occurs between the fork, and this call. diff --git a/libdd-crashtracker/src/collector/crash_handler.rs b/libdd-crashtracker/src/collector/crash_handler.rs index 413b78224e..917329295a 100644 --- a/libdd-crashtracker/src/collector/crash_handler.rs +++ b/libdd-crashtracker/src/collector/crash_handler.rs @@ -234,6 +234,12 @@ fn handle_posix_signal_impl( return Ok(()); } + // Mark this process as a collector for the preload logger + #[cfg(target_os = "linux")] + { + super::api::mark_preload_logger_collector(); + } + // If this code hits a stack overflow, then it will result in a segfault. That situation is // protected by the one-time guard. diff --git a/tools/docker/Dockerfile.build b/tools/docker/Dockerfile.build index e0246a59c5..7ce7eeadf1 100644 --- a/tools/docker/Dockerfile.build +++ b/tools/docker/Dockerfile.build @@ -112,6 +112,8 @@ COPY "datadog-ipc/plugins/Cargo.toml" "datadog-ipc/plugins/" COPY "libdd-data-pipeline/Cargo.toml" "libdd-data-pipeline/" COPY "libdd-data-pipeline-ffi/Cargo.toml" "libdd-data-pipeline-ffi/" COPY "bin_tests/Cargo.toml" "bin_tests/" +COPY "bin_tests/build.rs" "bin_tests/" +COPY "bin_tests/preload/preload.c" "bin_tests/preload/" COPY "libdd-tinybytes/Cargo.toml" "libdd-tinybytes/" COPY "builder/Cargo.toml" "builder/" COPY "datadog-ffe/Cargo.toml" "datadog-ffe/"