diff --git a/Cargo.lock b/Cargo.lock index c20979e098..9322bd0bf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1738,6 +1738,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "elf" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4445909572dbd556c457c849c4ca58623d84b27c8fff1e74b0b4227d8b90d17b" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -3177,8 +3183,10 @@ dependencies = [ name = "libdd-otel-thread-ctx" version = "1.0.0" dependencies = [ + "anyhow", "build_common", "cc", + "elf", ] [[package]] diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 16d8a34b9e..332e65ee3a 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -125,6 +125,7 @@ dispatch2,https://github.com/madsmtm/objc2,Zlib OR Apache-2.0 OR MIT,"Mads Marqu displaydoc,https://github.com/yaahc/displaydoc,MIT OR Apache-2.0,Jane Lusby dyn-clone,https://github.com/dtolnay/dyn-clone,MIT OR Apache-2.0,David Tolnay either,https://github.com/rayon-rs/either,MIT OR Apache-2.0,bluss +elf,https://github.com/cole14/rust-elf,MIT OR Apache-2.0,Christopher Cole encoding_rs,https://github.com/hsivonen/encoding_rs,(Apache-2.0 OR MIT) AND BSD-3-Clause,Henri Sivonen enum-as-inner,https://github.com/bluejekyll/enum-as-inner,MIT OR Apache-2.0,Benjamin Fry equivalent,https://github.com/cuviper/equivalent,Apache-2.0 OR MIT,The equivalent Authors diff --git a/libdd-otel-thread-ctx-ffi/Cargo.toml b/libdd-otel-thread-ctx-ffi/Cargo.toml index 8001933cd6..1c0f288628 100644 --- a/libdd-otel-thread-ctx-ffi/Cargo.toml +++ b/libdd-otel-thread-ctx-ffi/Cargo.toml @@ -15,12 +15,16 @@ crate-type = ["staticlib", "cdylib", "lib"] bench = false [dependencies] -libdd-common-ffi = { path = "../libdd-common-ffi", default-features = false } +libdd-common-ffi = { path = "../libdd-common-ffi", default-features = false, optional = true } libdd-otel-thread-ctx = { path = "../libdd-otel-thread-ctx" } [features] default = ["cbindgen"] cbindgen = ["build_common/cbindgen", "libdd-common-ffi/cbindgen"] +sanity-check = ["dep:libdd-common-ffi", "libdd-otel-thread-ctx/sanity-check"] + +[dev-dependencies] +libdd-otel-thread-ctx = { path = "../libdd-otel-thread-ctx", features = ["sanity-check"] } [build-dependencies] build_common = { path = "../build-common" } diff --git a/libdd-otel-thread-ctx-ffi/cbindgen.toml b/libdd-otel-thread-ctx-ffi/cbindgen.toml index b4f06019b5..526631d58c 100644 --- a/libdd-otel-thread-ctx-ffi/cbindgen.toml +++ b/libdd-otel-thread-ctx-ffi/cbindgen.toml @@ -21,6 +21,10 @@ include = ["libdd-common-ffi", "libdd-otel-thread-ctx"] prefix = "ddog_" renaming_overrides_prefixing = true +[export.rename] +"VoidResult" = "ddog_VoidResult" +"Error" = "ddog_Error" + [export.mangle] rename_types = "PascalCase" diff --git a/libdd-otel-thread-ctx-ffi/src/lib.rs b/libdd-otel-thread-ctx-ffi/src/lib.rs index bc96e5b020..ec77d043d7 100644 --- a/libdd-otel-thread-ctx-ffi/src/lib.rs +++ b/libdd-otel-thread-ctx-ffi/src/lib.rs @@ -8,6 +8,20 @@ #[cfg(target_os = "linux")] pub use linux::*; +/// Verify that this binary was linked with the correct options such that the thread contexts are +/// visible to an external reader (typically the eBPF profiler). +/// +/// Returns `VoidResult::Ok` if all checks pass, or a `VoidResult::Err` with a +/// diagnostic message on failure. +#[cfg(all(target_os = "linux", feature = "sanity-check"))] +#[no_mangle] +pub extern "C" fn ddog_otel_thread_ctx_sanity_check() -> libdd_common_ffi::VoidResult { + match libdd_otel_thread_ctx::sanity_check::sanity_check() { + Ok(()) => libdd_common_ffi::VoidResult::Ok, + Err(e) => libdd_common_ffi::VoidResult::Err(libdd_common_ffi::Error::from(e)), + } +} + #[cfg(target_os = "linux")] mod linux { use libdd_otel_thread_ctx::linux::{ThreadContext, ThreadContextHandle}; diff --git a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs index a429a29a00..3281ca1d2d 100644 --- a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs +++ b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs @@ -3,10 +3,11 @@ //! Verify ELF properties of the built cdylib on Linux. //! -//! These tests check that: +//! Delegates to [`libdd_otel_thread_ctx::autocheck::check_tls_slot_in`] which +//! checks that: //! - `otel_thread_ctx_v1` is exported in the dynamic symbol table as a TLS GLOBAL symbol. -//! - `otel_thread_ctx_v1` is accessed via TLSDESC relocations (R_X86_64_TLSDESC or -//! R_AARCH64_TLSDESC), as required by the OTel thread-level context sharing spec. +//! - `otel_thread_ctx_v1` follows the TLSDESC access model (if there's a relocation, it's a TLSDESC +//! one). //! //! The cdylib path is derived at runtime from the test executable location. //! Both the test binary and the cdylib live in `target/<[triple/]profile>/deps/`. @@ -14,71 +15,17 @@ #![cfg(target_os = "linux")] use std::path::PathBuf; -use std::process::Command; - -const SYMBOL: &str = "otel_thread_ctx_v1"; fn cdylib_path() -> PathBuf { - // test binary: target/<[triple/]profile>/deps/ - // cdylib: target/<[triple/]profile>/deps/liblibdd_otel_thread_ctx_ffi.so let exe = std::env::current_exe().expect("failed to read current executable path"); exe.parent() .expect("unexpected test executable path structure") .join("liblibdd_otel_thread_ctx_ffi.so") } -fn check_cdylib_readable(path: &PathBuf) { - assert!( - std::fs::File::open(path).is_ok(), - "cdylib at {} could not be opened for reading", - path.display() - ); -} - -fn readelf(args: &[&str], path: &PathBuf) -> String { - let out = Command::new("readelf") - .args(args) - .arg(path) - .output() - .expect("failed to run readelf. Is binutils installed?"); - String::from_utf8_lossy(&out.stdout).into_owned() -} - -#[test] -#[cfg_attr(miri, ignore)] -fn otel_thread_ctx_v1_in_dynsym() { - let path = cdylib_path(); - check_cdylib_readable(&path); - let output = readelf(&["-W", "--dyn-syms"], &path); - let line = output - .lines() - .find(|l| l.contains(SYMBOL)) - .unwrap_or_else(|| panic!("'{SYMBOL}' not found in dynsym of {}", path.display())); - assert!( - line.contains("TLS") && line.contains("GLOBAL"), - "'{SYMBOL}' is in dynsym but not as TLS GLOBAL — got:\n {line}" - ); -} - #[test] #[cfg_attr(miri, ignore)] -fn otel_thread_ctx_v1_tlsdesc_reloc() { +fn otel_thread_ctx_v1_tls_properties() { let path = cdylib_path(); - check_cdylib_readable(&path); - let output = readelf(&["-W", "--relocs"], &path); - let found = output.lines().any(|l| { - l.contains(SYMBOL) && (l.contains("R_X86_64_TLSDESC") || l.contains("R_AARCH64_TLSDESC")) - }); - assert!( - found, - "No TLSDESC relocation found for '{SYMBOL}' in {}\n\ - All relocations mentioning the symbol:\n{}", - path.display(), - output - .lines() - .filter(|l| l.contains(SYMBOL)) - .map(|l| format!(" {l}")) - .collect::>() - .join("\n") - ); + libdd_otel_thread_ctx::sanity_check::check_tls_slot_in(&path).unwrap(); } diff --git a/libdd-otel-thread-ctx/Cargo.toml b/libdd-otel-thread-ctx/Cargo.toml index bd8ce4c372..0580970a96 100644 --- a/libdd-otel-thread-ctx/Cargo.toml +++ b/libdd-otel-thread-ctx/Cargo.toml @@ -16,6 +16,13 @@ publish = false crate-type = ["lib"] bench = false +[dependencies] +anyhow = { version = "1.0", optional = true } +elf = { version = "0.7", optional = true } + +[features] +sanity-check = ["dep:elf", "dep:anyhow"] + [build-dependencies] build_common = { path = "../build-common" } cc = "1.1.31" diff --git a/libdd-otel-thread-ctx/src/lib.rs b/libdd-otel-thread-ctx/src/lib.rs index 649a5fc18c..10142a4c12 100644 --- a/libdd-otel-thread-ctx/src/lib.rs +++ b/libdd-otel-thread-ctx/src/lib.rs @@ -64,6 +64,9 @@ //! `atomic_signal_fence`) to keep field writes boxed between the `valid = 0` and `valid = 1` //! stores during in-place updates. +#[cfg(all(target_os = "linux", feature = "sanity-check"))] +pub mod sanity_check; + #[cfg(target_os = "linux")] pub mod linux { use std::{ diff --git a/libdd-otel-thread-ctx/src/sanity_check.rs b/libdd-otel-thread-ctx/src/sanity_check.rs new file mode 100644 index 0000000000..f71b5c0e2f --- /dev/null +++ b/libdd-otel-thread-ctx/src/sanity_check.rs @@ -0,0 +1,142 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Runtime ELF self-inspection for shared library. Verifies that the OTel thread context symbol is +//! discoverable by an out-of-process reader as required by the OTel thread-level context sharing +//! specification. +//! +//! Call [`sanity_check`] from within a shared object or a statically linked executables to verify +//! that the binary was linked with the correct option: +//! +//! - `otel_thread_ctx_v1` is exported as TLS GLOBAL in the dynamic symbol table. +//! - `otel_thread_ctx_v1` follows the TLSDESC model: there's either no relocation in `.rela.dyn` +//! (Local Exec), or a TLSDESC one. All other TLS relocation types (DTPMOD, DTPOFF, TPOFF, +//! GOTTPOFF, etc.) are rejected. +//! +//! This module is only available on Linux (the only platform that supports the TLSDESC dialect used +//! by this crate) and only when the `sanity-check` feature is enabled. + +use anyhow::{bail, Context}; +use elf::{abi, endian::AnyEndian, ElfBytes}; +use std::path::{Path, PathBuf}; + +const SYMBOL: &str = "otel_thread_ctx_v1"; + +/// Safe as [sanity_check], but takes the object file as an argument. Useful for a test setting +/// where the test code is separate from the artifact to validate. +pub fn check_tls_slot_in(path: &Path) -> anyhow::Result<()> { + let data = std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; + let elf = ElfBytes::::minimal_parse(&data) + .with_context(|| format!("failed to parse ELF at {}", path.display()))?; + check_dynsym(&elf)?; + check_tlsdesc_reloc_only(&elf)?; + Ok(()) +} + +/// Check that the current running module has been linked appropriately to make the OTel shared +/// thread context discoverable. +/// +/// Checks that `otel_thread_ctx_v1` is exported as a TLS GLOBAL symbol and that any TLS +/// relocations targeting it are TLSDESC. No relocation (Local Exec/static binary) is also +/// acceptable. +pub fn sanity_check() -> anyhow::Result<()> { + check_tls_slot_in(&own_elf_path()?) +} + +/// Locate the current running module (shared or not) via `/proc/self/maps`. +fn own_elf_path() -> anyhow::Result { + // We use the address of an arbitrary function of this module. + let addr = sanity_check as *const () as usize; + let maps = + std::fs::read_to_string("/proc/self/maps").context("failed to read /proc/self/maps")?; + for line in maps.lines() { + // Format: address perms offset dev inode [pathname] + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 6 { + continue; + } + let path = fields[5]; + if !path.starts_with('/') { + continue; + } + if let Some((start_str, end_str)) = fields[0].split_once('-') { + let start = usize::from_str_radix(start_str, 16).unwrap_or(0); + let end = usize::from_str_radix(end_str, 16).unwrap_or(0); + if addr >= start && addr < end { + return Ok(PathBuf::from(path)); + } + } + } + bail!("could not find our own object file in /proc/self/maps") +} + +/// Check that [SYMBOL] is present in the `.dynsym` table of the ELF data. +fn check_dynsym(elf: &ElfBytes<'_, AnyEndian>) -> anyhow::Result<()> { + let (symtab, strtab) = elf + .dynamic_symbol_table() + .context("failed to read .dynsym")? + .context("no dynamic symbol table found")?; + let found = symtab.iter().any(|sym| { + strtab + .get(sym.st_name as usize) + .map(|name| { + name == SYMBOL + && sym.st_symtype() == abi::STT_TLS + && sym.st_bind() == abi::STB_GLOBAL + }) + .unwrap_or(false) + }); + if !found { + bail!("'{SYMBOL}' not found as TLS GLOBAL in dynamic symbol table"); + } + Ok(()) +} + +/// Check that any relocation for [SYMBOL] in `.rela.dyn` is a TLSDESC relocation. No relocation at +/// all (Local Exec / static binary) is also acceptable. All other TLS relocation types (DTPMOD, +/// DTPOFF, TPOFF, GOTTPOFF, etc.) are rejected. +fn check_tlsdesc_reloc_only(elf: &ElfBytes<'_, AnyEndian>) -> anyhow::Result<()> { + #[cfg(target_arch = "x86_64")] + const TLSDESC_RELOC: u32 = 36; // R_X86_64_TLSDESC + #[cfg(target_arch = "aarch64")] + const TLSDESC_RELOC: u32 = 1031; // R_AARCH64_TLSDESC + + let (symtab, strtab) = elf + .dynamic_symbol_table() + .context("failed to read .dynsym")? + .context("no dynamic symbol table found")?; + let sym_idx = symtab + .iter() + .enumerate() + .find(|(_, sym)| { + strtab + .get(sym.st_name as usize) + .map(|n| n == SYMBOL) + .unwrap_or(false) + }) + .map(|(i, _)| i as u32) + .with_context(|| format!("'{SYMBOL}' not found in .dynsym"))?; + + let rela_shdr = elf + .section_header_by_name(".rela.dyn") + .context("failed to read section headers")?; + + if let Some(rela_shdr) = rela_shdr { + let bad: Vec = elf + .section_data_as_relas(&rela_shdr) + .context("failed to read .rela.dyn")? + .filter(|r| r.r_sym == sym_idx && r.r_type != TLSDESC_RELOC) + .map(|r| r.r_type) + .collect(); + if !bad.is_empty() { + let types: Vec = bad.iter().map(|t| format!("type {t}")).collect(); + bail!( + "'{SYMBOL}' has non-TLSDESC relocations in .rela.dyn: {}. \ + Only TLSDESC or no relocation (Local Exec) is accepted.", + types.join(", ") + ); + } + } + + Ok(()) +}