diff --git a/Cargo.lock b/Cargo.lock index 0f02210f..e9f5d196 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1775,7 +1775,10 @@ version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ + "crc32fast", "flate2", + "hashbrown 0.15.5", + "indexmap", "memchr", "ruzstd", ] diff --git a/config-zh.toml b/config-zh.toml index 3462f7c0..ec9e8e2e 100644 --- a/config-zh.toml +++ b/config-zh.toml @@ -57,23 +57,18 @@ timestamp = "local" color = "auto" [dwarf] -# DWARF 调试信息搜索路径(用于 .gnu_debuglink 文件) +# 本地独立 DWARF 调试信息的全局搜索目录 # -# 当二进制文件使用 .gnu_debuglink 引用独立的调试文件时, -# GhostScope 会在这些路径中搜索调试文件。 +# GhostScope 使用这些目录搜索 Build-ID 和 .gnu_debuglink 调试文件。 +# 命令行 --debug-file/-d 会直接指定一个调试文件,并绕过该搜索列表用于 +# 选中的目标模块。 # -# 这些路径只影响自动 .gnu_debuglink 发现。命令行 --debug-file/-d -# 会直接指定一个调试文件,并绕过该搜索列表用于选中的目标模块。 -# -# 搜索顺序(优先级从高到低): -# 1. 绝对路径(如果 .gnu_debuglink 包含绝对路径 - 罕见) -# 2. 用户配置的 search_paths + basename(此处配置) -# 3. 二进制文件所在目录 + basename -# 4. 二进制文件所在目录的 .debug 子目录 + basename -# -# 对于每个用户配置的路径,会检查两种位置: +# 支持的位置包括: +# - <路径>/.build-id/xx/yyyy.debug +# - <路径>/<二进制绝对目录>/ # - <路径>/debug_文件名 # - <路径>/.debug/debug_文件名 +# - 二进制文件所在目录及其 .debug 子目录 # # 特性: # - 主目录展开:"~/" 会被替换为你的主目录 @@ -86,9 +81,6 @@ color = "auto" # - 用户特定: "~/.local/lib/debug" # - 自定义构建输出:"/path/to/build/debug" # -# 注意:.gnu_debuglink 通常使用 basename(相对路径),但也支持绝对路径。 -# 如需使用系统范围的调试目录(如 /usr/lib/debug),请添加到 search_paths。 -# # 默认值:["/usr/lib/debug", "/usr/local/lib/debug"] search_paths = [ "/usr/lib/debug", diff --git a/config.toml b/config.toml index e6aa5042..19ca7244 100644 --- a/config.toml +++ b/config.toml @@ -61,24 +61,18 @@ timestamp = "local" color = "auto" [dwarf] -# DWARF debug information search paths for .gnu_debuglink files +# Global directories for local separate DWARF debug information # -# When a binary uses .gnu_debuglink to reference separate debug files, -# GhostScope searches these paths to locate the debug file. +# GhostScope uses these roots for Build-ID and .gnu_debuglink discovery. +# The --debug-file/-d CLI option points directly at one debug file and +# bypasses this search list for the selected target module. # -# These paths only affect automatic .gnu_debuglink discovery. The -# --debug-file/-d CLI option points directly at one debug file and bypasses -# this search list for the selected target module. -# -# Search order (highest priority first): -# 1. Absolute path (if .gnu_debuglink contains an absolute path - rare) -# 2. User-configured search_paths + basename (configured here) -# 3. Same directory as the binary + basename -# 4. .debug subdirectory next to the binary + basename -# -# For each user-configured path, both direct and .debug subdirectory are checked: +# Supported locations include: +# - /.build-id/xx/yyyy.debug +# - // # - /debug_filename # - /.debug/debug_filename +# - the binary directory and its .debug subdirectory # # Features: # - Home directory expansion: "~/" is replaced with your home directory @@ -91,10 +85,6 @@ color = "auto" # - User-specific: "~/.local/lib/debug" # - Custom build output: "/path/to/build/debug" # -# Note: .gnu_debuglink typically uses basename (relative path), but absolute paths -# are also supported. If you need system-wide debug directories like /usr/lib/debug, -# add them to search_paths. -# # Default: ["/usr/lib/debug", "/usr/local/lib/debug"] search_paths = [ "/usr/lib/debug", diff --git a/docs/configuration.md b/docs/configuration.md index 0cc41a66..95acb24a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -109,19 +109,19 @@ ghostscope -p 1234 --debug-file /path/to/binary.debug # Auto-detection searches in order: # 1. Binary itself (.debug_info sections) -# 2. .gnu_debuglink section (see search paths below) +# 2. Local separate debug files by Build-ID or .gnu_debuglink # 3. debuginfod by Build-ID, when [dwarf.debuginfod] is enabled # -# Local Build-ID directory layouts are not searched directly. .gnu_debugdata -# is not loaded currently. Use .gnu_debuglink search_paths or debuginfod for -# separate debug information. The default search_paths include /usr/lib/debug -# and /usr/local/lib/debug. +# .gnu_debugdata is not loaded currently. The default search_paths include +# /usr/lib/debug and /usr/local/lib/debug. -# .gnu_debuglink search paths (configurable in config.toml): +# Local separate-debug search paths (configurable in config.toml): # 1. Absolute path (if .gnu_debuglink contains absolute path - rare) -# 2. User-configured search_paths + basename (highest priority) -# 3. Same directory as the binary + basename -# 4. .debug subdirectory next to the binary + basename +# 2. /.build-id/xx/yyyy.debug, when a Build-ID is available +# 3. User-configured search_paths + basename (GhostScope compatibility path) +# 4. Same directory as the binary + basename +# 5. .debug subdirectory next to the binary + basename +# 6. // # # Note: If you override search_paths in config.toml, keep any system-wide debug # directories that you still rely on. @@ -334,23 +334,26 @@ timestamp = "local" color = "auto" [dwarf] -# Debug information search paths for .gnu_debuglink files -# When a binary uses .gnu_debuglink to reference separate debug files, -# GhostScope searches these paths to locate the debug file. +# Global directories for local separate debug information. +# GhostScope uses these roots for Build-ID and .gnu_debuglink discovery. # -# These paths only affect automatic .gnu_debuglink discovery. The -# --debug-file/-d CLI option points directly at one debug file and bypasses +# The --debug-file/-d CLI option points directly at one debug file and bypasses # this search list for the selected target module. # # Search order (highest priority first): # 1. Absolute path (if .gnu_debuglink contains an absolute path - rare) -# 2. User-configured search_paths + basename (configured here) -# 3. Same directory as the binary + basename -# 4. .debug subdirectory next to the binary + basename +# 2. /.build-id/xx/yyyy.debug, when a Build-ID is available +# 3. User-configured search_paths + basename (GhostScope compatibility path) +# 4. Same directory as the binary + basename +# 5. .debug subdirectory next to the binary + basename +# 6. // # -# For each user-configured path, both direct and .debug subdirectory are checked: +# For each user-configured path, GhostScope also checks the existing flat paths: # - /debug_filename # - /.debug/debug_filename +# Standard GDB directory layouts are checked as well: +# - /.build-id/xx/yyyy.debug +# - //debug_filename # # Features: # - Home directory expansion: "~/" is replaced with your home directory diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index f441a7f4..e62d72e6 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -110,18 +110,19 @@ ghostscope -p 1234 --debug-file /path/to/binary.debug # 自动检测按以下顺序搜索: # 1. 二进制文件本身(.debug_info 节) -# 2. .gnu_debuglink 节(参见下方搜索路径) +# 2. 通过 Build-ID 或 .gnu_debuglink 搜索本地独立调试文件 # 3. 启用 [dwarf.debuginfod] 时,通过 Build-ID 使用 debuginfod # -# GhostScope 不会直接搜索本地 Build-ID 目录布局;当前也不会加载 -# .gnu_debugdata。如需使用独立调试信息,请使用 .gnu_debuglink search_paths -# 或 debuginfod。默认 search_paths 包含 /usr/lib/debug 和 /usr/local/lib/debug。 +# GhostScope 当前不会加载 .gnu_debugdata。默认 search_paths 包含 +# /usr/lib/debug 和 /usr/local/lib/debug。 -# .gnu_debuglink 搜索路径(可在 config.toml 中配置): +# 本地独立调试文件搜索路径(可在 config.toml 中配置): # 1. 绝对路径(如果 .gnu_debuglink 包含绝对路径 - 罕见) -# 2. 用户配置的 search_paths + basename(最高优先级) -# 3. 二进制文件同目录 + basename -# 4. 二进制文件同目录的 .debug 子目录 + basename +# 2. 有 Build-ID 时,/.build-id/xx/yyyy.debug +# 3. 用户配置的 search_paths + basename(GhostScope 兼容路径) +# 4. 二进制文件同目录 + basename +# 5. 二进制文件同目录的 .debug 子目录 + basename +# 6. /<二进制绝对目录>/ # # 注意:如果在 config.toml 中覆盖 search_paths,请保留仍然依赖的系统调试目录。 ``` @@ -332,22 +333,26 @@ timestamp = "local" color = "auto" [dwarf] -# DWARF 调试信息搜索路径(用于 .gnu_debuglink 文件) -# 当二进制文件使用 .gnu_debuglink 引用独立的调试文件时, -# GhostScope 会在这些路径中搜索调试文件。 +# 本地独立 DWARF 调试信息的全局搜索目录。 +# GhostScope 使用这些目录进行 Build-ID 和 .gnu_debuglink 查找。 # -# 这些路径只影响自动 .gnu_debuglink 发现。命令行 --debug-file/-d -# 会直接指定一个调试文件,并绕过该搜索列表用于选中的目标模块。 +# 命令行 --debug-file/-d 会直接指定一个调试文件,并绕过该搜索列表用于 +# 选中的目标模块。 # # 搜索顺序(优先级从高到低): # 1. 绝对路径(如果 .gnu_debuglink 包含绝对路径 - 罕见) -# 2. 用户配置的 search_paths + basename(此处配置) -# 3. 二进制文件所在目录 + basename -# 4. 二进制文件所在目录的 .debug 子目录 + basename +# 2. 有 Build-ID 时,/.build-id/xx/yyyy.debug +# 3. 用户配置的 search_paths + basename(GhostScope 兼容路径) +# 4. 二进制文件所在目录 + basename +# 5. 二进制文件所在目录的 .debug 子目录 + basename +# 6. /<二进制绝对目录>/ # -# 对于每个用户配置的路径,会检查两种位置: +# 对于每个用户配置的路径,仍会检查原有的两种平铺位置: # - <路径>/debug_文件名 # - <路径>/.debug/debug_文件名 +# 同时也会检查标准 GDB 目录布局: +# - <路径>/.build-id/xx/yyyy.debug +# - <路径>/<二进制绝对目录>/debug_文件名 # # 特性: # - 主目录展开:"~/" 会被替换为你的主目录 diff --git a/e2e-tests/tests/startup_load_report_execution.rs b/e2e-tests/tests/startup_load_report_execution.rs index d4b4eb6c..d286d954 100644 --- a/e2e-tests/tests/startup_load_report_execution.rs +++ b/e2e-tests/tests/startup_load_report_execution.rs @@ -4,6 +4,7 @@ mod common; use anyhow::{bail, Context, Result}; use common::init; +use object::Object; use std::ffi::{OsStr, OsString}; use std::fs; use std::path::{Path, PathBuf}; @@ -17,6 +18,7 @@ const FIXTURE_BINARY: &str = "debug_source_report"; const FIXTURE_DEBUG_FILE: &str = "debug_source_report.debug"; const EMBEDDED_BINARY: &str = "debug_source_report_embedded"; const NO_DEBUGLINK_BINARY: &str = "debug_source_report_no_debuglink"; +const NO_DEBUGLINK_UNSTRIPPED: &str = "debug_source_report_no_debuglink.unstripped"; const NO_DWARF_DEBUGLINK_BINARY: &str = "debug_source_report_no_dwarf_debuglink"; const NO_DWARF_DEBUG_FILE: &str = "debug_source_report_no_dwarf.debug"; const MISSING_BINARY: &str = "debug_source_report_missing"; @@ -99,6 +101,129 @@ async fn test_startup_report_shows_debuglink_source() -> Result<()> { Ok(()) } +#[tokio::test] +#[serial_test::serial] +async fn test_startup_report_finds_gdb_mirrored_debuglink() -> Result<()> { + init(); + + if !is_host_topology() { + println!("skipping startup load report e2e outside host->host topology"); + return Ok(()); + } + + let fixture = ensure_startup_report_fixture()?; + let layout = TempDir::new().context("failed to create mirrored debuglink layout")?; + let binary_dir = layout.path().join("opt/ghostscope/bin"); + fs::create_dir_all(&binary_dir).context("failed to create temporary binary directory")?; + let binary = binary_dir.join(FIXTURE_BINARY); + fs::copy(&fixture.binary, &binary).with_context(|| { + format!( + "failed to copy stripped fixture {} to {}", + fixture.binary.display(), + binary.display() + ) + })?; + + let debug_root = layout.path().join("debug-root"); + let debug_path = gdb_mirrored_debug_path(&debug_root, &binary, FIXTURE_DEBUG_FILE)?; + fs::create_dir_all( + debug_path + .parent() + .context("mirrored debug path has no parent")?, + ) + .context("failed to create mirrored debug directory")?; + fs::copy(&fixture.debug_file, &debug_path).with_context(|| { + format!( + "failed to copy debug fixture {} to {}", + fixture.debug_file.display(), + debug_path.display() + ) + })?; + + let config = startup_report_config(&[debug_root.as_path()]); + let run = run_startup_report_command_for_binary_with_config(&fixture, &binary, &[], &config)?; + + assert!( + run.status.success(), + "mirrored debuglink startup report run failed with status {}\n{}", + run.status, + run.output + ); + assert_output_contains(&run.output, "\x1b[32mDWARF ready:\x1b[0m"); + assert_output_contains(&run.output, "\x1b[34mdebuglink:1\x1b[0m"); + assert_output_contains(&run.output, FIXTURE_DEBUG_FILE); + assert_output_contains(&run.output, "Dry run complete; no uprobes attached."); + + Ok(()) +} + +#[tokio::test] +#[serial_test::serial] +async fn test_startup_report_finds_build_id_file_without_debuglink() -> Result<()> { + init(); + + if !is_host_topology() { + println!("skipping startup load report e2e outside host->host topology"); + return Ok(()); + } + + let fixture = ensure_startup_report_fixture()?; + let layout = TempDir::new().context("failed to create Build-ID debug layout")?; + let binary_dir = layout.path().join("opt/ghostscope/bin"); + fs::create_dir_all(&binary_dir).context("failed to create temporary binary directory")?; + let binary = binary_dir.join(NO_DEBUGLINK_BINARY); + fs::copy(&fixture.no_debuglink_binary, &binary).with_context(|| { + format!( + "failed to copy stripped fixture {} to {}", + fixture.no_debuglink_binary.display(), + binary.display() + ) + })?; + + let debug_root = layout.path().join("debug-root"); + let binary_build_id = read_build_id_hex(&binary)?; + let debug_build_id = read_build_id_hex(&fixture.no_debuglink_unstripped)?; + anyhow::ensure!( + !has_debuglink(&binary)?, + "Build-ID fixture unexpectedly contains .gnu_debuglink" + ); + anyhow::ensure!( + binary_build_id == debug_build_id, + "stripped and unstripped Build-IDs differ: {binary_build_id} != {debug_build_id}" + ); + let debug_path = build_id_debug_path(&debug_root, &binary_build_id)?; + fs::create_dir_all( + debug_path + .parent() + .context("Build-ID debug path has no parent")?, + ) + .context("failed to create Build-ID debug directory")?; + fs::copy(&fixture.no_debuglink_unstripped, &debug_path).with_context(|| { + format!( + "failed to copy Build-ID debug fixture {} to {}", + fixture.no_debuglink_unstripped.display(), + debug_path.display() + ) + })?; + + let config = startup_report_config(&[debug_root.as_path()]); + let run = run_startup_report_command_for_binary_with_config(&fixture, &binary, &[], &config)?; + + assert!( + run.status.success(), + "Build-ID startup report run failed with status {}\n{}", + run.status, + run.output + ); + assert_output_contains(&run.output, "\x1b[32mDWARF ready:\x1b[0m"); + assert_output_contains(&run.output, "\x1b[34mdebuglink:1\x1b[0m"); + let debug_filename_prefix = &binary_build_id[2..binary_build_id.len().min(14)]; + assert_output_contains(&run.output, debug_filename_prefix); + assert_output_contains(&run.output, "Dry run complete; no uprobes attached."); + + Ok(()) +} + #[tokio::test] #[serial_test::serial] async fn test_loose_debuglink_search_prefers_later_strict_match() -> Result<()> { @@ -347,6 +472,7 @@ struct StartupReportFixture { binary: PathBuf, embedded_binary: PathBuf, no_debuglink_binary: PathBuf, + no_debuglink_unstripped: PathBuf, no_dwarf_debuglink_binary: PathBuf, no_dwarf_debug_file: PathBuf, missing_binary: PathBuf, @@ -369,6 +495,7 @@ fn ensure_startup_report_fixture() -> Result { binary: dir.join(FIXTURE_BINARY), embedded_binary: dir.join(EMBEDDED_BINARY), no_debuglink_binary: dir.join(NO_DEBUGLINK_BINARY), + no_debuglink_unstripped: dir.join(NO_DEBUGLINK_UNSTRIPPED), no_dwarf_debuglink_binary: dir.join(NO_DWARF_DEBUGLINK_BINARY), no_dwarf_debug_file: dir.join(NO_DWARF_DEBUG_FILE), missing_binary: dir.join(MISSING_BINARY), @@ -500,6 +627,62 @@ enabled = "off" config } +fn gdb_mirrored_debug_path( + debug_root: &Path, + binary: &Path, + debug_filename: &str, +) -> Result { + let canonical_binary = binary + .canonicalize() + .with_context(|| format!("failed to canonicalize binary {}", binary.display()))?; + let binary_dir = canonical_binary + .parent() + .context("canonical binary path has no parent")?; + let relative_binary_dir = binary_dir + .strip_prefix(Path::new("/")) + .context("canonical binary path is not absolute")?; + Ok(debug_root.join(relative_binary_dir).join(debug_filename)) +} + +fn read_build_id_hex(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("failed to read ELF {}", path.display()))?; + let object = object::File::parse(&bytes[..]) + .with_context(|| format!("failed to parse ELF {}", path.display()))?; + let build_id = object + .build_id() + .context("failed to read GNU Build-ID note")? + .with_context(|| format!("ELF has no Build-ID: {}", path.display()))?; + + let mut hex = String::with_capacity(build_id.len() * 2); + for byte in build_id { + use std::fmt::Write; + let _ = write!(&mut hex, "{byte:02x}"); + } + Ok(hex) +} + +fn has_debuglink(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("failed to read ELF {}", path.display()))?; + let object = object::File::parse(&bytes[..]) + .with_context(|| format!("failed to parse ELF {}", path.display()))?; + Ok(object + .gnu_debuglink() + .context("failed to read .gnu_debuglink")? + .is_some()) +} + +fn build_id_debug_path(debug_root: &Path, build_id: &str) -> Result { + anyhow::ensure!( + build_id.len() >= 2, + "Build-ID is too short for the standard directory layout: {build_id}" + ); + let (prefix, suffix) = build_id.split_at(2); + Ok(debug_root + .join(".build-id") + .join(prefix) + .join(format!("{suffix}.debug"))) +} + fn toml_string(path: &Path) -> String { path.to_string_lossy() .replace('\\', "\\\\") diff --git a/ghostscope-dwarf/Cargo.toml b/ghostscope-dwarf/Cargo.toml index 13dd2720..b0752a30 100644 --- a/ghostscope-dwarf/Cargo.toml +++ b/ghostscope-dwarf/Cargo.toml @@ -43,4 +43,5 @@ crc32fast = "1.4" dirs = "5.0" [dev-dependencies] +object = { workspace = true, features = ["write"] } tempfile = { workspace = true } diff --git a/ghostscope-dwarf/src/binary/debuglink.rs b/ghostscope-dwarf/src/binary/debuglink.rs index c7f14164..629c9473 100644 --- a/ghostscope-dwarf/src/binary/debuglink.rs +++ b/ghostscope-dwarf/src/binary/debuglink.rs @@ -1,21 +1,35 @@ -//! Support for .gnu_debuglink section - find separate debug info files +//! Locate local separate debug information by Build-ID or `.gnu_debuglink`. //! -//! This module implements the standard GNU debuglink mechanism for locating -//! debug information in separate files, following GDB's search strategy. +//! This module follows GDB's standard directory layouts while retaining +//! GhostScope's existing flat search-directory behavior. use crate::{binary::MappedFile, core::Result}; use anyhow::Context; use object::Object; use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::ffi::OsStr; +use std::path::{Component, Path, PathBuf}; -/// Find separate debug file using .gnu_debuglink section +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DebugFileValidation { + BuildId, + DebugLink, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DebugFileCandidate { + path: PathBuf, + validation: DebugFileValidation, +} + +/// Find separate debug information using a build ID or `.gnu_debuglink`. /// /// Search order (following GDB conventions): -/// 1. Absolute path (if .gnu_debuglink contains an absolute path) -/// 2. User-configured search paths + basename (from config file, highest priority) -/// 3. Same directory as binary + basename -/// 4. .debug subdirectory + basename +/// 1. Absolute path (if `.gnu_debuglink` contains one; a GhostScope extension) +/// 2. Build-ID paths below user-configured global debug directories +/// 3. User-configured flat search paths (a GhostScope extension) +/// 4. Same directory as the binary and its `.debug` subdirectory +/// 5. The binary's absolute directory mirrored below each global debug directory /// /// Note: If .gnu_debuglink contains an absolute path (e.g., /usr/lib/debug/foo.debug), /// the function will: @@ -27,7 +41,8 @@ use std::path::{Path, PathBuf}; /// - But custom search_paths can still provide alternatives via basename /// /// System-wide debug directories are searched when the caller includes them in -/// search_paths; the default GhostScope config includes common system paths. +/// `user_search_paths`; the default GhostScope config includes common system +/// paths. /// /// Returns the path to the debug file if a strict CRC/Build-ID match is found, /// or if loose mode falls back to the first mismatched candidate. @@ -38,61 +53,92 @@ pub fn find_debug_file>( ) -> Result> { let binary_path = binary_path.as_ref(); - // Read binary and check for .gnu_debuglink section + // Read the binary and discover its local separate-debug metadata. let binary_data = MappedFile::open(binary_path)?; let binary_obj = binary_data.parse_object()?; - // Extract build ID from binary for later verification + // Extract the build ID both for discovery and later verification. let binary_build_id = binary_obj.build_id().ok().flatten(); - // Check if .gnu_debuglink section exists - let (debug_filename, expected_crc) = match binary_obj.gnu_debuglink() { - Ok(Some((filename, crc))) => (filename, crc), - Ok(None) => { - // No .gnu_debuglink section - binary contains debug info - tracing::debug!("No .gnu_debuglink section in {}", binary_path.display()); - return Ok(None); + // A build ID is an independent discovery mechanism, so a missing or + // malformed .gnu_debuglink must not prevent build-ID lookup. + let debug_link = match binary_obj.gnu_debuglink() { + Ok(Some((filename, crc))) => { + use std::os::unix::ffi::OsStrExt; + let filename = PathBuf::from(std::ffi::OsStr::from_bytes(filename)); + Some((filename, crc)) } + Ok(None) => None, Err(e) => { tracing::warn!( "Failed to read .gnu_debuglink from {}: {}", binary_path.display(), e ); - return Ok(None); + None } }; - // Convert filename bytes to PathBuf (Linux-only, as GhostScope is an eBPF project) - use std::os::unix::ffi::OsStrExt; - let os_str = std::ffi::OsStr::from_bytes(debug_filename); - let debug_filename = Path::new(os_str); + if debug_link.is_none() && binary_build_id.is_none() { + tracing::debug!( + "No build ID or .gnu_debuglink section in {}", + binary_path.display() + ); + return Ok(None); + } - tracing::info!( - "Looking for debug file '{}' for binary '{}'", - debug_filename.display(), - binary_path.display() - ); + if let Some((debug_filename, _)) = &debug_link { + tracing::info!( + "Looking for debug file '{}' for binary '{}'", + debug_filename.display(), + binary_path.display() + ); + } else { + tracing::info!( + "Looking for separate debug information by build ID for '{}'", + binary_path.display() + ); + } // Build search paths following GDB's strategy - let search_paths = build_search_paths(binary_path, debug_filename, user_search_paths); + let search_candidates = build_search_candidates( + binary_path, + debug_link.as_ref().map(|(filename, _)| filename.as_path()), + binary_build_id, + user_search_paths, + ); // Try each path and verify CRC + build ID. Strict matches always win, even // in loose mode; only fall back to the first mismatched candidate after the // full search list has been checked. let mut first_loose_candidate = None; - for candidate_path in search_paths { + for candidate in search_candidates { + let candidate_path = &candidate.path; tracing::debug!("Checking debug file path: {}", candidate_path.display()); if candidate_path.exists() { - match verify_debug_file(&candidate_path, expected_crc, binary_build_id) { + let verified = match candidate.validation { + DebugFileValidation::BuildId => { + let Some(build_id) = binary_build_id else { + continue; + }; + verify_build_id_debug_file(candidate_path, build_id) + } + DebugFileValidation::DebugLink => { + let Some((_, expected_crc)) = &debug_link else { + continue; + }; + verify_debug_file(candidate_path, *expected_crc, binary_build_id) + } + }; + match verified { Ok(true) => { tracing::info!( - "Found matching debug file: {} (CRC: 0x{:08x})", + "Found matching debug file: {} ({:?})", candidate_path.display(), - expected_crc + candidate.validation ); - return Ok(Some(candidate_path)); + return Ok(Some(candidate.path)); } Ok(false) => { if allow_loose_debug_match { @@ -101,7 +147,7 @@ pub fn find_debug_file>( candidate_path.display() ); if first_loose_candidate.is_none() { - first_loose_candidate = Some(candidate_path); + first_loose_candidate = Some(candidate.path); } } else { tracing::error!( @@ -129,10 +175,18 @@ pub fn find_debug_file>( return Ok(Some(candidate_path)); } - tracing::warn!( - "Debug file '{}' not found in any standard location", - debug_filename.display() - ); + if let Some((debug_filename, _)) = &debug_link { + tracing::warn!( + "Debug file '{}' was not found in any standard location for '{}'", + debug_filename.display(), + binary_path.display() + ); + } else { + tracing::debug!( + "No local separate debug information found by build ID for '{}'", + binary_path.display() + ); + } Ok(None) } @@ -157,75 +211,203 @@ fn expand_home_dir(path: &str) -> PathBuf { PathBuf::from(path) } -/// Build search paths for debug file following GDB conventions +/// Build search candidates for separate debug information. /// /// Search order (highest priority first): -/// 1. Absolute path (if debug_filename is absolute) -/// 2. User-configured search paths (from config file) -/// 3. Same directory as binary -/// 4. .debug subdirectory +/// 1. Absolute `.gnu_debuglink` path, when present +/// 2. `/.build-id/xx/yyyy.debug` +/// 3. GhostScope's existing flat user-configured paths +/// 4. The binary directory and its `.debug` subdirectory +/// 5. `//` /// /// Note: /// - If debug_filename is an absolute path, it will be tried first, then basename extracted /// - Paths are deduplicated to avoid redundant filesystem checks -/// - Global debug directories are searched when the caller includes them in -/// search_paths -fn build_search_paths( +/// - Each configured search path acts as both a global debug directory and a +/// flat GhostScope search directory for backwards compatibility +fn build_search_candidates( binary_path: &Path, - debug_filename: &Path, + debug_filename: Option<&Path>, + binary_build_id: Option<&[u8]>, user_search_paths: &[String], -) -> Vec { - let mut paths = Vec::new(); +) -> Vec { + let mut candidates = Vec::new(); let mut seen = HashSet::new(); + let proc_root = split_proc_root_path(binary_path); - // Helper to add path only if not already seen - let mut add_path = |path: PathBuf| { + let mut add_candidate = |path: PathBuf, validation: DebugFileValidation| { if seen.insert(path.clone()) { - paths.push(path); + candidates.push(DebugFileCandidate { path, validation }); } }; - // 1. If debug_filename is an absolute path, try it first - if debug_filename.is_absolute() { - add_path(debug_filename.to_path_buf()); + if let Some(debug_filename) = debug_filename.filter(|path| path.is_absolute()) { + add_candidate(debug_filename.to_path_buf(), DebugFileValidation::DebugLink); + if let Some((proc_root, _)) = &proc_root { + add_candidate( + path_below_proc_root(proc_root, debug_filename), + DebugFileValidation::DebugLink, + ); + } } - // Extract basename for searching in configured paths - // This handles both absolute paths (e.g., /usr/lib/debug/foo.debug -> foo.debug) - // and relative paths (e.g., foo.debug -> foo.debug) + let expanded_search_paths = user_search_paths + .iter() + .map(|path| expand_home_dir(path)) + .collect::>(); + let mut search_paths = Vec::with_capacity(expanded_search_paths.len() * 2); + for search_path in &expanded_search_paths { + search_paths.push(search_path.clone()); + if let Some((proc_root, _)) = &proc_root { + let target_search_path = path_below_proc_root(proc_root, search_path); + if target_search_path != *search_path { + search_paths.push(target_search_path); + } + } + } + + if let Some(build_id) = binary_build_id { + for search_path in &search_paths { + if let Some(path) = build_id_debug_path(search_path, build_id) { + add_candidate(path, DebugFileValidation::BuildId); + } + } + } + + let Some(debug_filename) = debug_filename else { + return candidates; + }; let basename = debug_filename .file_name() .map(Path::new) .unwrap_or(debug_filename); - // 2. User-configured search paths (highest priority) - // For each user path, try both: - // - user_path/basename - // - user_path/.debug/basename - for user_path in user_search_paths { - let expanded = expand_home_dir(user_path); - add_path(expanded.join(basename)); - add_path(expanded.join(".debug").join(basename)); + // Preserve GhostScope's existing flat search-directory behavior. + for search_path in &search_paths { + add_candidate(search_path.join(basename), DebugFileValidation::DebugLink); + add_candidate( + search_path.join(".debug").join(basename), + DebugFileValidation::DebugLink, + ); } - // Get binary directory - let binary_dir = binary_path.parent(); + if let Some(binary_dir) = binary_path.parent() { + add_candidate(binary_dir.join(basename), DebugFileValidation::DebugLink); + add_candidate( + binary_dir.join(".debug").join(basename), + DebugFileValidation::DebugLink, + ); + } - // 3. Same directory as binary - if let Some(dir) = binary_dir { - add_path(dir.join(basename)); + // Canonicalizing a /proc//root path resolves it in GhostScope's mount + // namespace and loses the target root needed for target-local debug files. + let absolute_binary = proc_root + .as_ref() + .map(|(_, target_path)| target_path.clone()) + .unwrap_or_else(|| { + binary_path + .canonicalize() + .or_else(|_| std::path::absolute(binary_path)) + .unwrap_or_else(|_| binary_path.to_path_buf()) + }); + if let Some(binary_dir) = absolute_binary.parent() { + let relative_binary_dir = binary_dir + .strip_prefix(Path::new("/")) + .unwrap_or(binary_dir); + for search_path in &search_paths { + add_candidate( + search_path.join(relative_binary_dir).join(basename), + DebugFileValidation::DebugLink, + ); + } } - // 4. .debug subdirectory - if let Some(dir) = binary_dir { - add_path(dir.join(".debug").join(basename)); + candidates +} + +/// Split `/proc//root/` into the proc-root prefix and the +/// absolute path as seen by the target. +fn split_proc_root_path(path: &Path) -> Option<(PathBuf, PathBuf)> { + let mut components = path.components(); + if !matches!(components.next(), Some(Component::RootDir)) { + return None; } + if !matches!( + components.next(), + Some(Component::Normal(component)) if component == OsStr::new("proc") + ) { + return None; + } + let pid = match components.next() { + Some(Component::Normal(pid)) if pid.to_string_lossy().parse::().is_ok() => pid, + _ => return None, + }; + if !matches!( + components.next(), + Some(Component::Normal(component)) if component == OsStr::new("root") + ) { + return None; + } + + let proc_root = Path::new("/proc").join(pid).join("root"); + let mut target_path = PathBuf::from("/"); + let remaining = components.as_path(); + if !remaining.as_os_str().is_empty() { + target_path.push(remaining); + } + Some((proc_root, target_path)) +} + +fn path_below_proc_root(proc_root: &Path, path: &Path) -> PathBuf { + if !path.is_absolute() || path.starts_with(proc_root) { + return path.to_path_buf(); + } + + proc_root.join(path.strip_prefix(Path::new("/")).unwrap_or(path)) +} - // Note: callers provide any global debug directories through - // user_search_paths. This avoids generating nonsensical paths like - // /usr/lib/debug/mnt/500g/... for non-system binaries. +fn build_id_debug_path(global_debug_dir: &Path, build_id: &[u8]) -> Option { + let (first, remainder) = build_id.split_first()?; - paths + Some( + global_debug_dir + .join(".build-id") + .join(format!("{first:02x}")) + .join(format!("{}.debug", format_build_id(remainder))), + ) +} + +fn verify_build_id_debug_file(debug_file_path: &Path, expected_build_id: &[u8]) -> Result { + let file_data = MappedFile::open(debug_file_path)?; + let debug_obj = file_data.parse_object()?; + let debug_build_id = debug_obj.build_id().ok().flatten(); + + match debug_build_id { + Some(debug_id) if debug_id == expected_build_id => { + tracing::info!( + "Build ID verification passed for {}: {}", + debug_file_path.display(), + format_build_id(debug_id) + ); + Ok(true) + } + Some(debug_id) => { + tracing::error!( + "Build ID mismatch for {}: expected={}, actual={}", + debug_file_path.display(), + format_build_id(expected_build_id), + format_build_id(debug_id) + ); + Ok(false) + } + None => { + tracing::error!( + "Build-ID debug file {} has no build ID", + debug_file_path.display() + ); + Ok(false) + } + } } /// Verify debug file matches binary (CRC + build ID) @@ -486,6 +668,152 @@ fn format_build_id(build_id: &[u8]) -> String { mod tests { use super::*; + fn build_id_note(build_id: &[u8]) -> Vec { + let mut note = Vec::new(); + note.extend_from_slice(&4_u32.to_le_bytes()); + note.extend_from_slice(&u32::try_from(build_id.len()).unwrap().to_le_bytes()); + note.extend_from_slice(&object::elf::NT_GNU_BUILD_ID.to_le_bytes()); + note.extend_from_slice(b"GNU\0"); + note.extend_from_slice(build_id); + while note.len() % 4 != 0 { + note.push(0); + } + note + } + + fn debuglink_section(filename: &str, crc: u32) -> Vec { + let mut data = filename.as_bytes().to_vec(); + data.push(0); + while data.len() % 4 != 0 { + data.push(0); + } + data.extend_from_slice(&crc.to_le_bytes()); + data + } + + fn elf_bytes(build_id: &[u8], debug_link: Option<(&str, u32)>) -> Vec { + let mut object = object::write::Object::new( + object::BinaryFormat::Elf, + object::Architecture::X86_64, + object::Endianness::Little, + ); + let note = object.add_section( + Vec::new(), + b".note.gnu.build-id".to_vec(), + object::SectionKind::Note, + ); + object + .section_mut(note) + .set_data(build_id_note(build_id), 4); + if let Some((filename, crc)) = debug_link { + let section = object.add_section( + Vec::new(), + b".gnu_debuglink".to_vec(), + object::SectionKind::ReadOnlyData, + ); + object + .section_mut(section) + .set_data(debuglink_section(filename, crc), 4); + } + object.write().unwrap() + } + + fn build_search_paths( + binary_path: &Path, + debug_filename: &Path, + user_search_paths: &[String], + ) -> Vec { + build_search_candidates(binary_path, Some(debug_filename), None, user_search_paths) + .into_iter() + .map(|candidate| candidate.path) + .collect() + } + + #[test] + fn discovers_debuglink_in_mirrored_global_directory() { + let temp = tempfile::tempdir().unwrap(); + let binary_dir = temp.path().join("opt/example/bin"); + let global_debug_dir = temp.path().join("debug-root"); + std::fs::create_dir_all(&binary_dir).unwrap(); + + let build_id = [0x10, 0x20, 0x30, 0x40]; + let debug_bytes = elf_bytes(&build_id, None); + let crc = calculate_gnu_debuglink_crc(&debug_bytes); + let binary_path = binary_dir.join("example"); + std::fs::write( + &binary_path, + elf_bytes(&build_id, Some(("example.debug", crc))), + ) + .unwrap(); + + let relative_binary_dir = binary_path + .canonicalize() + .unwrap() + .parent() + .unwrap() + .strip_prefix(Path::new("/")) + .unwrap() + .to_path_buf(); + let debug_path = global_debug_dir + .join(relative_binary_dir) + .join("example.debug"); + std::fs::create_dir_all(debug_path.parent().unwrap()).unwrap(); + std::fs::write(&debug_path, debug_bytes).unwrap(); + + let found = find_debug_file( + &binary_path, + &[global_debug_dir.to_string_lossy().into_owned()], + false, + ) + .unwrap(); + assert_eq!(found.as_deref(), Some(debug_path.as_path())); + } + + #[test] + fn discovers_build_id_file_without_debuglink() { + let temp = tempfile::tempdir().unwrap(); + let global_debug_dir = temp.path().join("debug-root"); + let binary_path = temp.path().join("example"); + let build_id = [0xab, 0xcd, 0xef, 0x12, 0x34]; + let bytes = elf_bytes(&build_id, None); + std::fs::write(&binary_path, &bytes).unwrap(); + + let debug_path = build_id_debug_path(&global_debug_dir, &build_id).unwrap(); + std::fs::create_dir_all(debug_path.parent().unwrap()).unwrap(); + std::fs::write(&debug_path, bytes).unwrap(); + + let found = find_debug_file( + &binary_path, + &[global_debug_dir.to_string_lossy().into_owned()], + false, + ) + .unwrap(); + assert_eq!(found.as_deref(), Some(debug_path.as_path())); + } + + #[test] + fn discovers_one_byte_build_id_file_without_debuglink() { + let temp = tempfile::tempdir().unwrap(); + let global_debug_dir = temp.path().join("debug-root"); + let binary_path = temp.path().join("example"); + let build_id = [0xab]; + let bytes = elf_bytes(&build_id, None); + std::fs::write(&binary_path, &bytes).unwrap(); + + let debug_path = build_id_debug_path(&global_debug_dir, &build_id).unwrap(); + assert_eq!(debug_path, global_debug_dir.join(".build-id/ab/.debug")); + std::fs::create_dir_all(debug_path.parent().unwrap()).unwrap(); + std::fs::write(&debug_path, bytes).unwrap(); + + let found = find_debug_file( + &binary_path, + &[global_debug_dir.to_string_lossy().into_owned()], + false, + ) + .unwrap(); + assert_eq!(found.as_deref(), Some(debug_path.as_path())); + } + #[test] fn test_build_search_paths_no_user_paths() { let binary_path = Path::new("/usr/bin/my_program"); @@ -506,8 +834,9 @@ mod tests { let paths = build_search_paths(binary_path, debug_filename, &user_paths); - // Should have: 2 user paths * 2 (direct + .debug) + 2 standard paths = 6 total - assert_eq!(paths.len(), 6); + // Two flat candidates and one mirrored candidate per configured path, + // plus the two locations next to the binary. + assert_eq!(paths.len(), 8); // User paths come first (highest priority) assert_eq!(paths[0], Path::new("/opt/debug/my_program.debug")); @@ -518,9 +847,16 @@ mod tests { Path::new("/home/user/.debug/.debug/my_program.debug") ); - // Then standard paths + // Then locations next to the binary. assert_eq!(paths[4], Path::new("/usr/bin/my_program.debug")); assert_eq!(paths[5], Path::new("/usr/bin/.debug/my_program.debug")); + + // Finally, GDB-compatible mirrored global debug directories. + assert_eq!(paths[6], Path::new("/opt/debug/usr/bin/my_program.debug")); + assert_eq!( + paths[7], + Path::new("/home/user/.debug/usr/bin/my_program.debug") + ); } #[test] @@ -552,11 +888,13 @@ mod tests { // User path: /usr/bin/.debug/my_program.debug (same as standard path #2) // Standard: /usr/bin/my_program.debug (duplicate, skipped) // Standard: /usr/bin/.debug/my_program.debug (duplicate, skipped) - assert_eq!(paths.len(), 2); // Only 2 unique paths + // Mirrored: /usr/bin/usr/bin/my_program.debug + assert_eq!(paths.len(), 3); // Verify user paths come first (priority) assert_eq!(paths[0], Path::new("/usr/bin/my_program.debug")); assert_eq!(paths[1], Path::new("/usr/bin/.debug/my_program.debug")); + assert_eq!(paths[2], Path::new("/usr/bin/usr/bin/my_program.debug")); } #[test] @@ -583,6 +921,7 @@ mod tests { // Then standard paths with basename assert_eq!(paths[3], Path::new("/usr/bin/my_program.debug")); assert_eq!(paths[4], Path::new("/usr/bin/.debug/my_program.debug")); + assert_eq!(paths[5], Path::new("/opt/debug/usr/bin/my_program.debug")); // Verify basename was correctly extracted assert!(paths @@ -590,6 +929,95 @@ mod tests { .all(|p| p.file_name().unwrap() == "my_program.debug")); } + #[test] + fn test_gdb_global_debug_directory_layout() { + let binary_path = Path::new("/usr/bin/ls"); + let debug_filename = Path::new("ls.debug"); + let user_paths = vec!["/usr/lib/debug".to_string()]; + let build_id = [0xab, 0xcd, 0xef, 0x12, 0x34]; + + let candidates = build_search_candidates( + binary_path, + Some(debug_filename), + Some(&build_id), + &user_paths, + ); + + assert_eq!( + candidates[0], + DebugFileCandidate { + path: PathBuf::from("/usr/lib/debug/.build-id/ab/cdef1234.debug"), + validation: DebugFileValidation::BuildId, + } + ); + assert!(candidates.iter().any(|candidate| { + candidate.path == Path::new("/usr/lib/debug/usr/bin/ls.debug") + && candidate.validation == DebugFileValidation::DebugLink + })); + } + + #[test] + fn test_proc_root_searches_host_and_target_debug_directories() { + let binary_path = Path::new("/proc/1234/root/usr/bin/ls"); + let debug_filename = Path::new("ls.debug"); + let user_paths = vec!["/usr/lib/debug".to_string()]; + let build_id = [0xab, 0xcd, 0xef]; + + let candidates = build_search_candidates( + binary_path, + Some(debug_filename), + Some(&build_id), + &user_paths, + ); + + for (path, validation) in [ + ( + "/usr/lib/debug/.build-id/ab/cdef.debug", + DebugFileValidation::BuildId, + ), + ( + "/proc/1234/root/usr/lib/debug/.build-id/ab/cdef.debug", + DebugFileValidation::BuildId, + ), + ( + "/usr/lib/debug/usr/bin/ls.debug", + DebugFileValidation::DebugLink, + ), + ( + "/proc/1234/root/usr/lib/debug/usr/bin/ls.debug", + DebugFileValidation::DebugLink, + ), + ] { + assert!( + candidates.iter().any(|candidate| { + candidate.path == Path::new(path) && candidate.validation == validation + }), + "missing {validation:?} candidate {path} from {candidates:#?}" + ); + } + } + + #[test] + fn test_build_id_lookup_does_not_require_debuglink() { + let user_paths = vec!["/opt/debug".to_string()]; + let build_id = [0x12, 0x34, 0x56, 0x78]; + + let candidates = build_search_candidates( + Path::new("/usr/bin/example"), + None, + Some(&build_id), + &user_paths, + ); + + assert_eq!( + candidates, + vec![DebugFileCandidate { + path: PathBuf::from("/opt/debug/.build-id/12/345678.debug"), + validation: DebugFileValidation::BuildId, + }] + ); + } + #[test] fn test_crc_calculation() { // Test with known data diff --git a/ghostscope-dwarf/src/loader.rs b/ghostscope-dwarf/src/loader.rs index 7f6b44e6..b304c8c9 100644 --- a/ghostscope-dwarf/src/loader.rs +++ b/ghostscope-dwarf/src/loader.rs @@ -37,7 +37,7 @@ impl ExplicitDebugFile { pub struct LoadConfig { /// Maximum number of concurrent module loads pub max_module_concurrency: usize, - /// Debug file search paths (for .gnu_debuglink) + /// Global directories for build-ID and `.gnu_debuglink` discovery. pub debug_search_paths: Vec, /// Allow non-strict debug file matching (CRC/Build-ID) pub allow_loose_debug_match: bool, @@ -93,7 +93,7 @@ impl ModuleLoader { self } - /// Set debug search paths for .gnu_debuglink files + /// Set global directories for local separate debug information. pub fn with_debug_search_paths(mut self, paths: Vec) -> Self { self.config.debug_search_paths = paths; self diff --git a/ghostscope-dwarf/src/objfile/loading.rs b/ghostscope-dwarf/src/objfile/loading.rs index badd85d0..7304ae1d 100644 --- a/ghostscope-dwarf/src/objfile/loading.rs +++ b/ghostscope-dwarf/src/objfile/loading.rs @@ -120,7 +120,7 @@ impl LoadedObjfile { ) } else { tracing::info!( - "No debug info in binary, searching for .gnu_debuglink: {}", + "No debug info in binary, searching for local separate debug information: {}", module_mapping.path.display() ); match try_load_debug_file( diff --git a/ghostscope/src/config/args.rs b/ghostscope/src/config/args.rs index 5b27acdf..0d05ec8a 100644 --- a/ghostscope/src/config/args.rs +++ b/ghostscope/src/config/args.rs @@ -183,11 +183,10 @@ pub struct Args { /// /// Auto-detection otherwise searches: /// 1. Binary itself (.debug_info sections) - /// 2. .gnu_debuglink section + /// 2. Local separate debug files by Build-ID or .gnu_debuglink /// 3. debuginfod by Build-ID when enabled /// - /// Local Build-ID directory layouts are not searched directly. - /// .gnu_debugdata is not loaded currently. Default .gnu_debuglink search_paths + /// .gnu_debugdata is not loaded currently. Default local debug search paths /// include /usr/lib/debug and /usr/local/lib/debug unless overridden in config. #[arg(long, short = 'd', value_name = "PATH")] pub debug_file: Option, diff --git a/ghostscope/src/config/settings.rs b/ghostscope/src/config/settings.rs index 623561f6..b9e6db81 100644 --- a/ghostscope/src/config/settings.rs +++ b/ghostscope/src/config/settings.rs @@ -142,7 +142,7 @@ pub struct ScriptConfig { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct DwarfConfig { - /// DWARF debug information search paths (for future --debug-file auto-discovery) + /// Global directories for local Build-ID and `.gnu_debuglink` discovery. #[serde(default = "default_debug_search_paths")] pub search_paths: Vec, /// Allow non-strict matching of separate debug files (CRC/Build-ID)