From 69b967be58025136715dd0b139abfdc570e973b6 Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 9 Sep 2026 11:55:41 +0200 Subject: [PATCH 1/5] feat(wasm-debug-files): Add prepare combining WASM-split split and upload - Add debug-files prepare to automate WebAssembly debug setup for Sentry - Scan .wasm files or directories and classify each module's debug quality - Split modules with embedded DWARF into a deployable .wasm and a *.debug.wasm companion - Ensure stripped and companion artifacts share a build_id for matching in Sentry - Keep line-level debug info in the companion while removing it from the deploy artifact - Skip symtab-only modules with a warning when line-level symbolication is unavailable - Detect already-prepared pairs and avoid re-splitting them - Upload companions to Sentry by default, with options to split-only or dry-run - Support CI workflows that fail when expected DWARF debug info is missing --- CHANGELOG.md | 1 + Cargo.lock | 90 ++++ Cargo.toml | 1 + src/commands/debug_files/mod.rs | 2 + src/commands/debug_files/prepare.rs | 378 +++++++++++++++++ src/utils/mod.rs | 1 + src/utils/wasm.rs | 622 ++++++++++++++++++++++++++++ 7 files changed, 1095 insertions(+) create mode 100644 src/commands/debug_files/prepare.rs create mode 100644 src/utils/wasm.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e178c7e537..c0ab17b922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- (debug-files) Add `debug-files prepare` to split WASM DWARF companions and upload them - (build) Add dSYM support to IPA uploads ([#3393](https://github.com/getsentry/sentry-cli/pull/3393)) ### Fixes diff --git a/Cargo.lock b/Cargo.lock index 6c4b6355f2..9391ef18ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -753,6 +753,63 @@ dependencies = [ "syn", ] +[[package]] +name = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -1704,6 +1761,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3668,6 +3731,7 @@ dependencies = [ "url", "uuid", "walkdir", + "wasmbin", "which", "whoami", "windows-sys 0.59.0", @@ -4747,6 +4811,32 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmbin" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311322c49474a7490feac58b26e6b6b41fb0d4c6d4f3dae0c9736a9dcd77007a" +dependencies = [ + "custom_debug", + "leb128", + "once_cell", + "thiserror 1.0.69", + "wasmbin-derive", +] + +[[package]] +name = "wasmbin-derive" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b847635e9205027f154d358ac389d0d5b40b1f0832a30965c86020fd6e4f961" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", + "thiserror 2.0.18", +] + [[package]] name = "wasmparser" version = "0.243.0" diff --git a/Cargo.toml b/Cargo.toml index 9801fb768f..6ac9fd522d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ tokio = { version = "1.47", features = ["rt"] } url = "2.3.1" uuid = { version = "1.3.0", features = ["v4", "serde"] } walkdir = "2.3.2" +wasmbin = { version = "0.8.1", features = ["exception-handling"] } which = "4.4.0" whoami = "1.5.2" zip = "2.4.2" diff --git a/src/commands/debug_files/mod.rs b/src/commands/debug_files/mod.rs index dd692c1760..fc45c1e57f 100644 --- a/src/commands/debug_files/mod.rs +++ b/src/commands/debug_files/mod.rs @@ -5,6 +5,7 @@ pub mod bundle_jvm; pub mod bundle_sources; pub mod check; pub mod find; +pub mod prepare; pub mod print_sources; pub mod upload; @@ -12,6 +13,7 @@ macro_rules! each_subcommand { ($mac:ident) => { $mac!(bundle_sources); $mac!(check); + $mac!(prepare); $mac!(bundle_jvm); $mac!(find); $mac!(print_sources); diff --git a/src/commands/debug_files/prepare.rs b/src/commands/debug_files/prepare.rs new file mode 100644 index 0000000000..14ebb33b33 --- /dev/null +++ b/src/commands/debug_files/prepare.rs @@ -0,0 +1,378 @@ +use std::io; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{bail, Result}; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use console::style; +use ignore::overrides::OverrideBuilder; +use ignore::types::TypesBuilder; +use ignore::WalkBuilder; +use log::info; +use serde::Serialize; +use symbolic::debuginfo::FileFormat; +use uuid::Uuid; + +use crate::config::Config; +use crate::constants::DEFAULT_MAX_WAIT; +use crate::utils::args::ArgExt as _; +use crate::utils::dif::ObjectDifFeatures; +use crate::utils::dif_upload::{DifFormat, DifUpload}; +use crate::utils::logging::is_quiet_mode; +use crate::utils::system::QuietExit; +use crate::utils::wasm::{ + is_debug_companion_path, is_wasm_path, prepare_wasm_file, PrepareAction, PrepareOptions, + PrepareResult, +}; + +pub fn make_command(command: Command) -> Command { + command + .about("Prepare WebAssembly files for Sentry: split DWARF, then upload companions.") + .long_about( + "Prepare WebAssembly debug files for Sentry.{n}{n}\ + Scans PATH for .wasm modules, classifies debug quality, and for \ + modules with DWARF runs the same split as wasm-split: inject a \ + build_id if missing, write a *.debug.wasm companion that keeps \ + the Code section, and strip DWARF from the deployable .wasm.{n}{n}\ + Companions are uploaded with debug-files upload --type wasm \ + unless --no-upload or --dry-run is set.{n}{n}\ + Compiler-agnostic: operates on module sections only. Build with \ + DWARF (Emscripten -g, wasm-pack dwarf-debug-info) and optionally \ + --build-id at link. Name/symtab-only modules are skipped with a \ + warning (no line-level symbolication).", + ) + .org_arg() + .project_arg(false) + .arg( + Arg::new("paths") + .value_name("PATH") + .help("A .wasm file or a directory to search recursively.") + .num_args(1..) + .required(true) + .action(ArgAction::Append), + ) + .arg( + Arg::new("ignore") + .long("ignore") + .short('i') + .value_name("IGNORE") + .action(ArgAction::Append) + .help("Ignores all files and folders matching the given glob."), + ) + .arg( + Arg::new("ignore_file") + .long("ignore-file") + .short('I') + .value_name("IGNORE_FILE") + .help( + "Ignore all files and folders specified in the given \ + ignore file, e.g. .gitignore.", + ), + ) + .arg( + Arg::new("out_dir") + .long("out-dir") + .value_name("DIR") + .help( + "Write *.debug.wasm companions into this directory. \ + The stripped .wasm is still written next to the input \ + (in-place) so the original path stays the deploy artifact.", + ), + ) + .arg( + Arg::new("no_upload") + .long("no-upload") + .action(ArgAction::SetTrue) + .help("Split only; do not upload companions."), + ) + .arg( + Arg::new("dry_run") + .long("dry-run") + .action(ArgAction::SetTrue) + .help("Inspect and classify modules without writing or uploading."), + ) + .arg( + Arg::new("require_dwarf") + .long("require-dwarf") + .action(ArgAction::SetTrue) + .help("Exit with an error if any scanned .wasm lacks DWARF (or a matching companion)."), + ) + .arg( + Arg::new("include_sources") + .long("include-sources") + .action(ArgAction::SetTrue) + .help( + "Include sources from the local file system and upload them as source bundles.", + ), + ) + .arg( + Arg::new("wait") + .long("wait") + .action(ArgAction::SetTrue) + .conflicts_with("wait_for") + .help( + "Wait for the server to fully process uploaded files. Errors \ + can only be displayed if --wait or --wait-for is specified, but this will \ + significantly slow down the upload process.", + ), + ) + .arg( + Arg::new("wait_for") + .long("wait-for") + .value_name("SECS") + .value_parser(clap::value_parser!(u64)) + .conflicts_with("wait") + .help( + "Wait for the server to fully process uploaded files, \ + but at most for the given number of seconds. Errors \ + can only be displayed if --wait or --wait-for is specified, but this will \ + significantly slow down the upload process.", + ), + ) + .arg( + Arg::new("json") + .long("json") + .action(ArgAction::SetTrue) + .help("Format outputs as JSON."), + ) + .arg( + Arg::new("build_id") + .long("build-id") + .value_name("UUID") + .value_parser(Uuid::parse_str) + .help( + "Explicit build_id to inject when a module has none. \ + Defaults to a random UUID.", + ), + ) +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + + // Read CLI arguments + #[expect(clippy::unwrap_used, reason = "required clap argument")] + let paths: Vec = matches + .get_many::("paths") + .unwrap() + .map(PathBuf::from) + .collect(); + + let dry_run = matches.get_flag("dry_run"); + let no_upload = matches.get_flag("no_upload") || dry_run; + let require_dwarf = matches.get_flag("require_dwarf"); + let json = matches.get_flag("json"); + let quiet = is_quiet_mode(); + let out_dir = matches.get_one::("out_dir").map(Path::new); + let build_id = matches.get_one::("build_id").copied(); + + let ignore_file = matches + .get_one::("ignore_file") + .map(String::as_str) + .unwrap_or_default(); + let ignores: Vec<_> = matches + .get_many::("ignore") + .map(|ignores| ignores.map(|i| format!("!{i}")).collect()) + .unwrap_or_default(); + + // Collect WASM files from the given paths + let mut wasm_files = Vec::new(); + for path in &paths { + if !path.exists() { + bail!("Given path does not exist: {}", path.display()); + } + if !json && !quiet { + println!("{} Searching {}", style(">").dim(), path.display()); + } + wasm_files.extend(collect_wasm_files(path, ignore_file, &ignores)?); + } + wasm_files.sort(); + wasm_files.dedup(); + + if !json && !quiet { + println!( + "{} Found {} {}", + style(">").dim(), + style(wasm_files.len()).yellow(), + match wasm_files.len() { + 1 => "wasm file", + _ => "wasm files", + } + ); + } + + // Prepare WASM files + let options = PrepareOptions { + dry_run, + out_dir, + build_id, + }; + + let mut results = Vec::new(); + for wasm_path in &wasm_files { + info!("preparing {}", wasm_path.display()); + let result = prepare_wasm_file(wasm_path, options)?; + if !json && !quiet { + print_result(&result); + } + results.push(result); + } + + // JSON + strict checks + let dwarf_missing = results + .iter() + .any(|result| result.action == PrepareAction::Skipped && !result.quality.has_dwarf()); + + if json { + serde_json::to_writer_pretty(&mut io::stdout(), &PrepareReport { files: &results })?; + println!(); + } + + if require_dwarf && dwarf_missing { + if !json && !quiet { + eprintln!( + "{}", + style("Error: some .wasm files lack DWARF (--require-dwarf)").red() + ); + } + return Err(QuietExit(1).into()); + } + + // Upload companions or stop + if no_upload { + return Ok(()); + } + + let companions: Vec = results + .iter() + .filter(|result| { + matches!( + result.action, + PrepareAction::Split | PrepareAction::AlreadyPrepared + ) + }) + .filter_map(|result| result.companion.clone()) + .collect(); + + if companions.is_empty() { + if !json && !quiet { + println!("{} No companions to upload", style(">").dim()); + } + return Ok(()); + } + + upload_companions(matches, &companions) +} + +#[derive(Serialize)] +struct PrepareReport<'a> { + files: &'a [PrepareResult], +} + +fn print_result(result: &PrepareResult) { + let header = match result.action { + PrepareAction::Split => format!("{} Split {}", style(">").dim(), result.path.display()), + PrepareAction::WouldSplit => { + format!("{} Would split {}", style(">").dim(), result.path.display()) + } + PrepareAction::AlreadyPrepared => format!( + "{} Already prepared {}", + style(">").dim(), + result.path.display() + ), + PrepareAction::Skipped => { + format!("{} Skipping {}", style(">").dim(), result.path.display()) + } + }; + println!("{header}"); + println!(" Debug quality: {}", result.quality.as_str()); + if let Some(build_id) = &result.build_id { + println!(" Build ID: {build_id}"); + } + if let Some(companion) = &result.companion { + println!(" Companion: {}", companion.display()); + } + if let Some(warning) = &result.warning { + println!(" {}: {warning}", style("Warning").yellow()); + } +} + +fn collect_wasm_files(path: &Path, ignore_file: &str, ignores: &[String]) -> Result> { + if path.is_file() { + if is_wasm_path(path) { + return Ok(vec![path.to_path_buf()]); + } + bail!( + "Expected a .wasm file or a directory, but got {}", + path.display() + ); + } + + let mut builder = WalkBuilder::new(path); + builder.follow_links(true); + builder.sort_by_file_name(|a, b| a.cmp(b)); + builder.git_exclude(false).git_ignore(false).ignore(false); + + let mut types_builder = TypesBuilder::new(); + types_builder.add("wasm", "*.wasm")?; + builder.types(types_builder.select("wasm").build()?); + + if !ignore_file.is_empty() { + builder.add_ignore(ignore_file); + } + + if !ignores.is_empty() { + let mut override_builder = OverrideBuilder::new(path); + for ignore in ignores { + override_builder.add(ignore)?; + } + builder.overrides(override_builder.build()?); + } + + let mut files = Vec::new(); + for entry in builder.build() { + let file = entry?; + if file.file_type().is_some_and(|t| t.is_dir()) { + continue; + } + let file_path = file.path(); + if is_debug_companion_path(file_path) { + continue; + } + if is_wasm_path(file_path) { + files.push(file_path.to_path_buf()); + } + } + Ok(files) +} + +fn upload_companions(matches: &ArgMatches, companions: &[PathBuf]) -> Result<()> { + let config = Config::current(); + let (org, project) = config.get_org_and_project(matches)?; + + let wait_for_secs = matches.get_one::("wait_for").copied(); + let wait = matches.get_flag("wait") || wait_for_secs.is_some(); + let max_wait = wait_for_secs.map_or(DEFAULT_MAX_WAIT, Duration::from_secs); + + let mut upload = DifUpload::new(&org, &project); + upload + .wait(wait) + .max_wait(max_wait) + .search_paths(companions.iter().cloned()) + .allow_zips(false) + .filter_format(DifFormat::Object(FileFormat::Wasm)) + .filter_features(ObjectDifFeatures { + debug: true, + symtab: true, + unwind: true, + sources: true, + }) + .include_sources(matches.get_flag("include_sources")); + + let (_uploaded, has_processing_errors) = upload.upload()?; + if has_processing_errors { + eprintln!(); + eprintln!("{}", style("Error: some symbols did not process correctly")); + return Err(QuietExit(1).into()); + } + Ok(()) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 98ca272da2..6d1c2b940a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -29,4 +29,5 @@ pub mod ui; pub mod update; pub mod value_parsers; pub mod vcs; +pub mod wasm; pub mod xcode; diff --git a/src/utils/wasm.rs b/src/utils/wasm.rs new file mode 100644 index 0000000000..0a25448919 --- /dev/null +++ b/src/utils/wasm.rs @@ -0,0 +1,622 @@ +//! WebAssembly debug-file helpers. +//! +//! Split/strip logic matches `wasm-split` from Symbolicator +//! (`crates/wasm-split`). That crate is a binary, not a library, so the +//! algorithm lives here and uses the same `wasmbin` types. Do not invent a +//! different split: the companion must keep the Code section (DWARF addresses +//! are relative to it) and both files must share a spec `build_id`. + +use std::fs::File; +use std::io::{BufReader, BufWriter}; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context as _, Result}; +use data_encoding::HEXLOWER; +use serde::Serialize; +use uuid::Uuid; +use wasmbin::io::{Decode as _, Encode as _}; +use wasmbin::sections::{CustomSection, Section}; +use wasmbin::Module; + +/// How much debug information a WASM module actually contains. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DebugQuality { + /// Embedded DWARF (`.debug_*` custom sections). Splittable. + Dwarf, + /// `external_debug_info` points at a separate debug file. + ExternalDebugInfo, + /// Name / symbol table only — function names, no file/line. + Symtab, + /// No debug information at all. + None, +} + +impl DebugQuality { + pub fn has_dwarf(self) -> bool { + matches!(self, Self::Dwarf | Self::ExternalDebugInfo) + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Dwarf => "dwarf", + Self::ExternalDebugInfo => "external_debug_info", + Self::Symtab => "symtab", + Self::None => "none", + } + } +} + +/// Inspected WASM module: section facts used to decide split vs skip. +#[derive(Debug)] +pub struct WasmInspection { + pub quality: DebugQuality, + pub build_id: Option>, + pub has_code: bool, + pub external_debug_info: Option, +} + +/// What `prepare` did (or would do) for one file. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PrepareAction { + Split, + WouldSplit, + AlreadyPrepared, + Skipped, +} + +/// Outcome of preparing a single `.wasm` file. +#[derive(Debug, Serialize)] +pub struct PrepareResult { + pub path: PathBuf, + pub action: PrepareAction, + pub quality: DebugQuality, + pub build_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stripped: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub companion: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub warning: Option, +} + +impl PrepareResult { + fn skipped(path: PathBuf, quality: DebugQuality, warning: impl Into) -> Self { + Self { + path, + action: PrepareAction::Skipped, + quality, + build_id: None, + stripped: None, + companion: None, + warning: Some(warning.into()), + } + } +} + +/// Options for [`prepare_wasm_file`]. +#[derive(Clone, Copy, Debug, Default)] +pub struct PrepareOptions<'a> { + pub dry_run: bool, + pub out_dir: Option<&'a Path>, + pub build_id: Option, +} + +pub fn is_wasm_path(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("wasm")) +} + +/// `foo.debug.wasm` companions produced by this command (and by `wasm-split`). +pub fn is_debug_companion_path(path: &Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.to_ascii_lowercase().ends_with(".debug.wasm")) +} + +/// Companion path for `app.wasm` → `app.debug.wasm` (next to the input, or in `out_dir`). +pub fn companion_path(wasm_path: &Path, out_dir: Option<&Path>) -> PathBuf { + let name = wasm_path.file_stem().map_or_else( + || "module.debug.wasm".to_owned(), + |stem| format!("{}.debug.wasm", stem.to_string_lossy()), + ); + match out_dir { + Some(dir) => dir.join(name), + None => wasm_path.with_file_name(name), + } +} + +fn as_custom_section(section: &Section) -> Option<&CustomSection> { + section.try_as()?.try_contents().ok() +} + +/// Returns `true` if this section should be stripped. +/// +/// Copied from `wasm-split`: only `.debug_*` (and optionally the name section). +/// Code, `build_id`, and `external_debug_info` stay. +fn is_strippable_section(section: &Section, strip_names: bool) -> bool { + as_custom_section(section).is_some_and(|section| match section { + CustomSection::Name(_) => strip_names, + other => other.name().starts_with(".debug_"), + }) +} + +pub fn inspect_module(module: &Module) -> WasmInspection { + let mut has_dwarf = false; + let mut has_name_section = false; + let mut has_code = false; + let mut build_id = None; + let mut external_debug_info = None; + + for section in &module.sections { + if matches!(section, Section::Code(_)) { + has_code = true; + } + if let Some(custom) = as_custom_section(section) { + match custom { + CustomSection::BuildId(id) => build_id = Some(id.clone()), + CustomSection::Name(_) => has_name_section = true, + CustomSection::ExternalDebugInfo(url) => { + if let Ok(url) = url.try_contents() { + external_debug_info = Some(url.clone()); + } + } + other if other.name().starts_with(".debug_") => has_dwarf = true, + _ => {} + } + } + } + + let quality = if has_dwarf { + DebugQuality::Dwarf + } else if external_debug_info.is_some() { + DebugQuality::ExternalDebugInfo + } else if has_name_section { + DebugQuality::Symtab + } else { + DebugQuality::None + }; + + WasmInspection { + quality, + build_id, + has_code, + external_debug_info, + } +} + +pub fn decode_module(path: &Path) -> Result { + let file = File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; + Module::decode(&mut BufReader::new(file)) + .with_context(|| format!("Failed to parse WASM module {}", path.display())) +} + +fn encode_module(module: &Module, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + } + let file = + File::create(path).with_context(|| format!("Failed to create {}", path.display()))?; + module + .encode(&mut BufWriter::new(file)) + .with_context(|| format!("Failed to write WASM module {}", path.display())) +} + +fn format_build_id(build_id: &[u8]) -> String { + HEXLOWER.encode(build_id) +} + +fn resolve_external_debug_path(wasm_path: &Path, url: &str) -> Option { + if url.starts_with("http://") || url.starts_with("https://") { + return None; + } + let referenced = Path::new(url); + if referenced.is_absolute() { + Some(referenced.to_path_buf()) + } else { + Some( + wasm_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(referenced), + ) + } +} + +fn read_build_id(path: &Path) -> Result>> { + let module = decode_module(path)?; + Ok(inspect_module(&module).build_id) +} + +/// Split `input` the way `wasm-split -d --strip` does. +/// +/// Reimplemented here instead of shelling out to `wasm-split` because Symbolicator +/// ships it as a standalone binary, not a Rust library sentry-cli can depend on. +/// Behavior must match `wasm-split` exactly (see module docs). +/// +/// Injects `build_id` if missing, writes the full module (Code + DWARF) to +/// `companion`, strips `.debug_*` from the deployable copy, and adds +/// `external_debug_info` pointing at the companion filename. +pub fn split_wasm( + input: &Path, + companion: &Path, + stripped_out: &Path, + build_id: Option, +) -> Result> { + let mut module = decode_module(input)?; + let inspection = inspect_module(&module); + + let build_id = match inspection.build_id { + Some(existing) => existing, + None => { + let new_id = build_id.unwrap_or_else(Uuid::new_v4).as_bytes().to_vec(); + module + .sections + .push(CustomSection::BuildId(new_id.clone()).into()); + new_id + } + }; + + // Write the companion first, while DWARF and Code are still in the module. + encode_module(&module, companion)?; + + module + .sections + .retain(|section| !is_strippable_section(section, false)); + + let debug_file_name = companion + .file_name() + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) + .context("Companion path has no file name")?; + + module + .sections + .push(CustomSection::ExternalDebugInfo(debug_file_name.into()).into()); + + encode_module(&module, stripped_out)?; + verify_split(stripped_out, companion, &build_id, inspection.has_code)?; + Ok(build_id) +} + +fn verify_split( + stripped: &Path, + companion: &Path, + expected_build_id: &[u8], + original_had_code: bool, +) -> Result<()> { + let stripped_module = decode_module(stripped)?; + let companion_module = decode_module(companion)?; + let stripped_info = inspect_module(&stripped_module); + let companion_info = inspect_module(&companion_module); + + match (&stripped_info.build_id, &companion_info.build_id) { + (Some(a), Some(b)) if a == b && a == expected_build_id => {} + _ => bail!( + "build_id mismatch after split (stripped={}, companion={})", + stripped.display(), + companion.display() + ), + } + + if companion_info.quality != DebugQuality::Dwarf { + bail!( + "Companion {} is missing DWARF debug sections", + companion.display() + ); + } + + if original_had_code && !companion_info.has_code { + bail!( + "Companion {} is missing the Code section (required for DWARF address mapping)", + companion.display() + ); + } + + if stripped_info.quality == DebugQuality::Dwarf { + bail!( + "Stripped module {} still contains DWARF sections", + stripped.display() + ); + } + + Ok(()) +} + +/// Classify and optionally split one `.wasm` file. +/// +/// Higher-level wrapper around [`split_wasm`] for the `debug-files prepare` +/// command: inspects debug quality, skips unsuitable inputs, detects already- +/// prepared modules, supports dry-run and `--out-dir`, and returns a structured +/// [`PrepareResult`] instead of just writing files. +pub fn prepare_wasm_file(path: &Path, options: PrepareOptions<'_>) -> Result { + if is_debug_companion_path(path) { + return Ok(PrepareResult::skipped( + path.to_path_buf(), + DebugQuality::Dwarf, + "already a debug companion (*.debug.wasm); skipping".to_owned(), + )); + } + + let module = match decode_module(path) { + Ok(module) => module, + Err(err) => { + return Ok(PrepareResult::skipped( + path.to_path_buf(), + DebugQuality::None, + format!("not a valid WASM module: {err:#}"), + )); + } + }; + + let inspection = inspect_module(&module); + let expected_companion = companion_path(path, options.out_dir); + let stripped_out = match options.out_dir { + Some(dir) => dir.join(path.file_name().unwrap_or(path.as_os_str())), + None => path.to_path_buf(), + }; + + // Already split: stripped module + companion with the same build_id. + if inspection.quality != DebugQuality::Dwarf { + if let Some(url) = inspection.external_debug_info.as_deref() { + if let Some(existing) = resolve_external_debug_path(path, url) { + if existing.is_file() { + let companion_id = read_build_id(&existing).ok().flatten(); + if companion_id.is_some() && companion_id == inspection.build_id { + return Ok(PrepareResult { + path: path.to_path_buf(), + action: PrepareAction::AlreadyPrepared, + quality: DebugQuality::ExternalDebugInfo, + build_id: inspection.build_id.as_deref().map(format_build_id), + stripped: Some(path.to_path_buf()), + companion: Some(existing), + warning: None, + }); + } + } + } + } + + if expected_companion.is_file() && inspection.build_id.is_some() { + if let Ok(Some(companion_id)) = read_build_id(&expected_companion) { + if Some(&companion_id) == inspection.build_id.as_ref() { + return Ok(PrepareResult { + path: path.to_path_buf(), + action: PrepareAction::AlreadyPrepared, + quality: inspection.quality, + build_id: Some(format_build_id(&companion_id)), + stripped: Some(path.to_path_buf()), + companion: Some(expected_companion), + warning: None, + }); + } + } + } + } + + match inspection.quality { + DebugQuality::Dwarf => {} + DebugQuality::ExternalDebugInfo => { + return Ok(PrepareResult::skipped( + path.to_path_buf(), + inspection.quality, + "has external_debug_info but no local companion with matching build_id".to_owned(), + )); + } + DebugQuality::Symtab => { + return Ok(PrepareResult::skipped( + path.to_path_buf(), + inspection.quality, + "no line-level symbolication (name/symtab only)".to_owned(), + )); + } + DebugQuality::None => { + // A build_id without debug sections means someone already stripped + // this module, so re-splitting would overwrite a good companion + // with an empty one. Without a build_id it was simply built + // without debug info. + let warning = if inspection.build_id.is_some() { + "already stripped (build_id present, no debug sections); \ + splitting would produce a useless companion" + } else { + "no debug information; rebuild with DWARF \ + (Emscripten -g, wasm-pack dwarf-debug-info)" + }; + return Ok(PrepareResult::skipped( + path.to_path_buf(), + inspection.quality, + warning.to_owned(), + )); + } + } + + if options.dry_run { + return Ok(PrepareResult { + path: path.to_path_buf(), + action: PrepareAction::WouldSplit, + quality: inspection.quality, + build_id: inspection.build_id.as_deref().map(format_build_id), + stripped: Some(stripped_out), + companion: Some(expected_companion), + warning: None, + }); + } + + let build_id = split_wasm(path, &expected_companion, &stripped_out, options.build_id)?; + + Ok(PrepareResult { + path: path.to_path_buf(), + action: PrepareAction::Split, + quality: inspection.quality, + build_id: Some(format_build_id(&build_id)), + stripped: Some(stripped_out), + companion: Some(expected_companion), + warning: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use wasmbin::sections::RawCustomSection; + + fn dwarf_module() -> Module { + Module { + sections: vec![CustomSection::Other(RawCustomSection { + name: ".debug_info".into(), + data: vec![0, 1, 2, 3].into(), + }) + .into()], + } + } + + fn name_only_module() -> Module { + Module { + sections: vec![CustomSection::Name(Default::default()).into()], + } + } + + fn empty_module() -> Module { + Module { sections: vec![] } + } + + /// Module that already went through a split: build_id, no debug sections. + fn stripped_module() -> Module { + Module { + sections: vec![CustomSection::BuildId(vec![7; 16]).into()], + } + } + + fn write_module(dir: &Path, name: &str, module: &Module) -> PathBuf { + let path = dir.join(name); + encode_module(module, &path).unwrap(); + path + } + + #[test] + fn companion_name_is_predictable() { + assert_eq!( + companion_path(Path::new("dist/app.wasm"), None), + PathBuf::from("dist/app.debug.wasm") + ); + assert_eq!( + companion_path(Path::new("pkg/demo_bg.wasm"), None), + PathBuf::from("pkg/demo_bg.debug.wasm") + ); + assert_eq!( + companion_path(Path::new("app.wasm"), Some(Path::new("symbols"))), + PathBuf::from("symbols/app.debug.wasm") + ); + } + + #[test] + fn debug_companion_path_detection() { + assert!(is_debug_companion_path(Path::new("app.debug.wasm"))); + assert!(!is_debug_companion_path(Path::new("app.wasm"))); + assert!(!is_debug_companion_path(Path::new("demo_bg.wasm"))); + } + + #[test] + fn classifies_dwarf_name_and_empty() { + let dwarf = inspect_module(&dwarf_module()); + assert_eq!(dwarf.quality, DebugQuality::Dwarf); + + let names = inspect_module(&name_only_module()); + assert_eq!(names.quality, DebugQuality::Symtab); + + assert_eq!(inspect_module(&empty_module()).quality, DebugQuality::None); + } + + #[test] + fn split_injects_matching_build_id_and_keeps_dwarf_on_companion() { + let dir = tempfile::tempdir().unwrap(); + let input = write_module(dir.path(), "app.wasm", &dwarf_module()); + let companion = dir.path().join("app.debug.wasm"); + let build_id = split_wasm(&input, &companion, &input, None).unwrap(); + + let stripped = inspect_module(&decode_module(&input).unwrap()); + let debug = inspect_module(&decode_module(&companion).unwrap()); + + assert_eq!(stripped.build_id.as_deref(), Some(build_id.as_slice())); + assert_eq!(debug.build_id.as_deref(), Some(build_id.as_slice())); + assert_eq!(debug.quality, DebugQuality::Dwarf); + assert_ne!(stripped.quality, DebugQuality::Dwarf); + assert_eq!( + stripped.external_debug_info.as_deref(), + Some("app.debug.wasm") + ); + } + + #[test] + fn prepare_skips_symtab_only() { + let dir = tempfile::tempdir().unwrap(); + let input = write_module(dir.path(), "unity.wasm", &name_only_module()); + let result = prepare_wasm_file(&input, PrepareOptions::default()).unwrap(); + assert_eq!(result.action, PrepareAction::Skipped); + assert_eq!(result.quality, DebugQuality::Symtab); + assert!(result.warning.unwrap().contains("no line-level")); + assert!(!companion_path(&input, None).exists()); + } + + #[test] + fn prepare_warns_on_module_without_debug_info() { + let dir = tempfile::tempdir().unwrap(); + let input = write_module(dir.path(), "app.wasm", &empty_module()); + let result = prepare_wasm_file(&input, PrepareOptions::default()).unwrap(); + assert_eq!(result.action, PrepareAction::Skipped); + assert_eq!(result.quality, DebugQuality::None); + assert!(result.warning.unwrap().contains("no debug information")); + assert!(!companion_path(&input, None).exists()); + } + + #[test] + fn prepare_skips_already_stripped() { + let dir = tempfile::tempdir().unwrap(); + let input = write_module(dir.path(), "app.wasm", &stripped_module()); + let result = prepare_wasm_file(&input, PrepareOptions::default()).unwrap(); + assert_eq!(result.action, PrepareAction::Skipped); + assert!(result.warning.unwrap().contains("already stripped")); + assert!(!companion_path(&input, None).exists()); + } + + #[test] + fn prepare_skips_re_split_of_prepared_pair() { + let dir = tempfile::tempdir().unwrap(); + let input = write_module(dir.path(), "app.wasm", &dwarf_module()); + let first = prepare_wasm_file(&input, PrepareOptions::default()).unwrap(); + assert_eq!(first.action, PrepareAction::Split); + + let second = prepare_wasm_file(&input, PrepareOptions::default()).unwrap(); + assert_eq!(second.action, PrepareAction::AlreadyPrepared); + assert_eq!(second.build_id, first.build_id); + } + + #[test] + fn dry_run_does_not_write() { + let dir = tempfile::tempdir().unwrap(); + let input = write_module(dir.path(), "app.wasm", &dwarf_module()); + let result = prepare_wasm_file( + &input, + PrepareOptions { + dry_run: true, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(result.action, PrepareAction::WouldSplit); + assert!(!companion_path(&input, None).exists()); + } + + #[test] + fn skips_debug_companion_inputs() { + let dir = tempfile::tempdir().unwrap(); + let input = write_module(dir.path(), "app.debug.wasm", &dwarf_module()); + let result = prepare_wasm_file(&input, PrepareOptions::default()).unwrap(); + assert_eq!(result.action, PrepareAction::Skipped); + } +} From 697453b956ef743ed5da74b7547d561c98a255e3 Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 9 Sep 2026 11:56:39 +0200 Subject: [PATCH 2/5] test(debug-files): Add prepare integration tests --- .../debug_files/debug_files-help.trycmd | 1 + .../debug_files-no-subcommand.trycmd | 1 + .../debug_files-prepare-dry-run.trycmd | 10 ++ .../prepare/debug_files-prepare-help.trycmd | 86 +++++++++++++ .../prepare/debug_files-prepare-json.trycmd | 17 +++ .../debug_files-prepare-missing-path.trycmd | 9 ++ .../debug_files-prepare-no-debug-info.trycmd | 10 ++ .../debug_files-prepare-no-upload.trycmd | 11 ++ .../debug_files-prepare-require-dwarf.trycmd | 11 ++ .../debug_files-prepare-stripped.trycmd | 10 ++ .../prepare/debug_files-prepare-symtab.trycmd | 10 ++ tests/integration/debug_files/mod.rs | 1 + tests/integration/debug_files/prepare.rs | 120 ++++++++++++++++++ 13 files changed, 297 insertions(+) create mode 100644 tests/integration/_cases/debug_files/prepare/debug_files-prepare-dry-run.trycmd create mode 100644 tests/integration/_cases/debug_files/prepare/debug_files-prepare-help.trycmd create mode 100644 tests/integration/_cases/debug_files/prepare/debug_files-prepare-json.trycmd create mode 100644 tests/integration/_cases/debug_files/prepare/debug_files-prepare-missing-path.trycmd create mode 100644 tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-debug-info.trycmd create mode 100644 tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-upload.trycmd create mode 100644 tests/integration/_cases/debug_files/prepare/debug_files-prepare-require-dwarf.trycmd create mode 100644 tests/integration/_cases/debug_files/prepare/debug_files-prepare-stripped.trycmd create mode 100644 tests/integration/_cases/debug_files/prepare/debug_files-prepare-symtab.trycmd create mode 100644 tests/integration/debug_files/prepare.rs diff --git a/tests/integration/_cases/debug_files/debug_files-help.trycmd b/tests/integration/_cases/debug_files/debug_files-help.trycmd index 1412febae2..88d4b19110 100644 --- a/tests/integration/_cases/debug_files/debug_files-help.trycmd +++ b/tests/integration/_cases/debug_files/debug_files-help.trycmd @@ -8,6 +8,7 @@ Usage: sentry-cli[EXE] debug-files [OPTIONS] Commands: bundle-sources Create a source bundle for a given debug information file check Check the debug info file at a given path. + prepare Prepare WebAssembly files for Sentry: split DWARF, then upload companions. find Locate debug information files for given debug identifiers. print-sources Print source files linked by the given debug info file. upload Upload debugging information files. diff --git a/tests/integration/_cases/debug_files/debug_files-no-subcommand.trycmd b/tests/integration/_cases/debug_files/debug_files-no-subcommand.trycmd index 0c73c2fd90..e73698f6dc 100644 --- a/tests/integration/_cases/debug_files/debug_files-no-subcommand.trycmd +++ b/tests/integration/_cases/debug_files/debug_files-no-subcommand.trycmd @@ -8,6 +8,7 @@ Usage: sentry-cli[EXE] debug-files [OPTIONS] Commands: bundle-sources Create a source bundle for a given debug information file check Check the debug info file at a given path. + prepare Prepare WebAssembly files for Sentry: split DWARF, then upload companions. find Locate debug information files for given debug identifiers. print-sources Print source files linked by the given debug info file. upload Upload debugging information files. diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-dry-run.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-dry-run.trycmd new file mode 100644 index 0000000000..de2b114c0a --- /dev/null +++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-dry-run.trycmd @@ -0,0 +1,10 @@ +``` +$ sentry-cli debug-files prepare . --dry-run +? success +> Searching . +> Found 1 wasm file +> Would split ./app.wasm + Debug quality: dwarf + Companion: ./app.debug.wasm + +``` diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-help.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-help.trycmd new file mode 100644 index 0000000000..e11110ef36 --- /dev/null +++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-help.trycmd @@ -0,0 +1,86 @@ +``` +$ sentry-cli debug-files prepare --help +? success +Prepare WebAssembly debug files for Sentry. + +Scans PATH for .wasm modules, classifies debug quality, and for modules with DWARF runs the same +split as wasm-split: inject a build_id if missing, write a *.debug.wasm companion that keeps the +Code section, and strip DWARF from the deployable .wasm. + +Companions are uploaded with debug-files upload --type wasm unless --no-upload or --dry-run is set. + +Compiler-agnostic: operates on module sections only. Build with DWARF (Emscripten -g, wasm-pack +dwarf-debug-info) and optionally --build-id at link. Name/symtab-only modules are skipped with a +warning (no line-level symbolication). + +Usage: sentry-cli[EXE] debug-files prepare [OPTIONS] ... + +Arguments: + ... + A .wasm file or a directory to search recursively. + +Options: + -o, --org + The organization ID or slug. + + --header + Custom headers that should be attached to all requests + in key:value format. + + -p, --project + The project ID or slug. + + --auth-token + Use the given Sentry auth token. + + -i, --ignore + Ignores all files and folders matching the given glob. + + -I, --ignore-file + Ignore all files and folders specified in the given ignore file, e.g. .gitignore. + + --log-level + Set the log output verbosity. [possible values: trace, debug, info, warn, error] + + --out-dir + Write *.debug.wasm companions into this directory. The stripped .wasm is still written + next to the input (in-place) so the original path stays the deploy artifact. + + --quiet + Do not print any output while preserving correct exit code. This flag is currently + implemented only for selected subcommands. + + [aliases: --silent] + + --no-upload + Split only; do not upload companions. + + --dry-run + Inspect and classify modules without writing or uploading. + + --require-dwarf + Exit with an error if any scanned .wasm lacks DWARF (or a matching companion). + + --include-sources + Include sources from the local file system and upload them as source bundles. + + --wait + Wait for the server to fully process uploaded files. Errors can only be displayed if + --wait or --wait-for is specified, but this will significantly slow down the upload + process. + + --wait-for + Wait for the server to fully process uploaded files, but at most for the given number of + seconds. Errors can only be displayed if --wait or --wait-for is specified, but this will + significantly slow down the upload process. + + --json + Format outputs as JSON. + + --build-id + Explicit build_id to inject when a module has none. Defaults to a random UUID. + + -h, --help + Print help (see a summary with '-h') + +``` diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-json.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-json.trycmd new file mode 100644 index 0000000000..2b15a054eb --- /dev/null +++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-json.trycmd @@ -0,0 +1,17 @@ +``` +$ sentry-cli debug-files prepare . --dry-run --json +? success +{ + "files": [ + { + "path": "./app.wasm", + "action": "would_split", + "quality": "dwarf", + "build_id": null, + "stripped": "./app.wasm", + "companion": "./app.debug.wasm" + } + ] +} + +``` diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-missing-path.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-missing-path.trycmd new file mode 100644 index 0000000000..ef11cb3d63 --- /dev/null +++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-missing-path.trycmd @@ -0,0 +1,9 @@ +``` +$ sentry-cli debug-files prepare does-not-exist --no-upload +? failed +error: Given path does not exist: does-not-exist + +Add --log-level=[info|debug] or export SENTRY_LOG_LEVEL=[info|debug] to see more output. +Please attach the full debug log to all bug reports. + +``` diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-debug-info.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-debug-info.trycmd new file mode 100644 index 0000000000..d67ac3f91b --- /dev/null +++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-debug-info.trycmd @@ -0,0 +1,10 @@ +``` +$ sentry-cli debug-files prepare . --no-upload +? success +> Searching . +> Found 1 wasm file +> Skipping ./app.wasm + Debug quality: none + Warning: no debug information; rebuild with DWARF (Emscripten -g, wasm-pack dwarf-debug-info) + +``` diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-upload.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-upload.trycmd new file mode 100644 index 0000000000..1e6cbc1ae7 --- /dev/null +++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-upload.trycmd @@ -0,0 +1,11 @@ +``` +$ sentry-cli debug-files prepare . --no-upload --build-id 00000000-0000-4000-8000-000000000000 +? success +> Searching . +> Found 1 wasm file +> Split ./app.wasm + Debug quality: dwarf + Build ID: 00000000000040008000000000000000 + Companion: ./app.debug.wasm + +``` diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-require-dwarf.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-require-dwarf.trycmd new file mode 100644 index 0000000000..9c47eaf52c --- /dev/null +++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-require-dwarf.trycmd @@ -0,0 +1,11 @@ +``` +$ sentry-cli debug-files prepare . --no-upload --require-dwarf +? failed +> Searching . +> Found 1 wasm file +> Skipping ./unity.wasm + Debug quality: symtab + Warning: no line-level symbolication (name/symtab only) +Error: some .wasm files lack DWARF (--require-dwarf) + +``` diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-stripped.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-stripped.trycmd new file mode 100644 index 0000000000..72dd546a94 --- /dev/null +++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-stripped.trycmd @@ -0,0 +1,10 @@ +``` +$ sentry-cli debug-files prepare . --no-upload +? success +> Searching . +> Found 1 wasm file +> Skipping ./app.wasm + Debug quality: none + Warning: already stripped (build_id present, no debug sections); splitting would produce a useless companion + +``` diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-symtab.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-symtab.trycmd new file mode 100644 index 0000000000..72280aef34 --- /dev/null +++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-symtab.trycmd @@ -0,0 +1,10 @@ +``` +$ sentry-cli debug-files prepare . --no-upload +? success +> Searching . +> Found 1 wasm file +> Skipping ./unity.wasm + Debug quality: symtab + Warning: no line-level symbolication (name/symtab only) + +``` diff --git a/tests/integration/debug_files/mod.rs b/tests/integration/debug_files/mod.rs index 6b5c192cff..e3aacbf7ad 100644 --- a/tests/integration/debug_files/mod.rs +++ b/tests/integration/debug_files/mod.rs @@ -1,6 +1,7 @@ use crate::integration::TestManager; mod bundle_jvm; +mod prepare; mod upload; #[test] diff --git a/tests/integration/debug_files/prepare.rs b/tests/integration/debug_files/prepare.rs new file mode 100644 index 0000000000..4c0fd96ae4 --- /dev/null +++ b/tests/integration/debug_files/prepare.rs @@ -0,0 +1,120 @@ +use std::fs::{create_dir_all, remove_dir_all, write}; +use std::path::Path; + +use crate::integration::TestManager; + +/// Minimal WASM with a `.debug_info` custom section. +const WASM_WITH_DWARF: &[u8] = b"\0asm\x01\x00\x00\x00\x00\x10\x0b.debug_info\x00\x01\x02\x03"; + +/// Minimal WASM with only a `name` custom section (symtab / no DWARF). +const WASM_NAME_ONLY: &[u8] = b"\0asm\x01\x00\x00\x00\x00\x05\x04name"; + +/// WASM header only — no debug sections and no build_id. +const WASM_EMPTY: &[u8] = b"\0asm\x01\x00\x00\x00"; + +/// WASM with a `build_id` custom section but no debug sections, i.e. a module +/// that already went through a split. +fn wasm_stripped_with_build_id() -> Vec { + let mut bytes = b"\0asm\x01\x00\x00\x00\x00\x1a\x08build_id\x10".to_vec(); + bytes.extend([7u8; 16]); + bytes +} + +fn reset_dir(path: &str) { + let path = Path::new(path); + if path.exists() { + remove_dir_all(path).unwrap(); + } + create_dir_all(path).unwrap(); +} + +fn write_wasm(dir: &str, name: &str, bytes: &[u8]) { + write(Path::new(dir).join(name), bytes).unwrap(); +} + +#[test] +fn command_debug_files_prepare_missing_path() { + TestManager::new() + .register_trycmd_test("debug_files/prepare/debug_files-prepare-missing-path.trycmd"); +} + +#[test] +fn command_debug_files_prepare_dry_run() { + let cwd = "tests/integration/_cases/debug_files/prepare/debug_files-prepare-dry-run.in/"; + reset_dir(cwd); + write_wasm(cwd, "app.wasm", WASM_WITH_DWARF); + + TestManager::new() + .register_trycmd_test("debug_files/prepare/debug_files-prepare-dry-run.trycmd"); +} + +#[test] +fn command_debug_files_prepare_split_no_upload() { + let cwd = "tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-upload.in/"; + reset_dir(cwd); + write_wasm(cwd, "app.wasm", WASM_WITH_DWARF); + + TestManager::new() + .register_trycmd_test("debug_files/prepare/debug_files-prepare-no-upload.trycmd"); + + let companion = Path::new(cwd).join("app.debug.wasm"); + assert!( + companion.is_file(), + "expected companion {}", + companion.display() + ); +} + +#[test] +fn command_debug_files_prepare_symtab_warning() { + let cwd = "tests/integration/_cases/debug_files/prepare/debug_files-prepare-symtab.in/"; + reset_dir(cwd); + write_wasm(cwd, "unity.wasm", WASM_NAME_ONLY); + + TestManager::new() + .register_trycmd_test("debug_files/prepare/debug_files-prepare-symtab.trycmd"); +} + +#[test] +fn command_debug_files_prepare_require_dwarf() { + let cwd = "tests/integration/_cases/debug_files/prepare/debug_files-prepare-require-dwarf.in/"; + reset_dir(cwd); + write_wasm(cwd, "unity.wasm", WASM_NAME_ONLY); + + TestManager::new() + .register_trycmd_test("debug_files/prepare/debug_files-prepare-require-dwarf.trycmd"); +} + +#[test] +fn command_debug_files_prepare_no_debug_info() { + let cwd = "tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-debug-info.in/"; + reset_dir(cwd); + write_wasm(cwd, "app.wasm", WASM_EMPTY); + + TestManager::new() + .register_trycmd_test("debug_files/prepare/debug_files-prepare-no-debug-info.trycmd"); +} + +#[test] +fn command_debug_files_prepare_already_stripped() { + let cwd = "tests/integration/_cases/debug_files/prepare/debug_files-prepare-stripped.in/"; + reset_dir(cwd); + write_wasm(cwd, "app.wasm", &wasm_stripped_with_build_id()); + + TestManager::new() + .register_trycmd_test("debug_files/prepare/debug_files-prepare-stripped.trycmd"); +} + +#[test] +fn command_debug_files_prepare_json() { + let cwd = "tests/integration/_cases/debug_files/prepare/debug_files-prepare-json.in/"; + reset_dir(cwd); + write_wasm(cwd, "app.wasm", WASM_WITH_DWARF); + + TestManager::new().register_trycmd_test("debug_files/prepare/debug_files-prepare-json.trycmd"); +} + +#[test] +fn command_debug_files_prepare_help() { + TestManager::new().register_trycmd_test("debug_files/prepare/debug_files-prepare-help.trycmd"); +} From 0b15509348617ab1c9ce67938e19188b47d0787f Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 9 Sep 2026 12:48:09 +0200 Subject: [PATCH 3/5] feat(debug-files): Expose prepare on npm DebugFiles API --- lib/debugFiles/index.ts | 87 +++++++++++++++++++++++++++++++ lib/debugFiles/options/prepare.ts | 43 +++++++++++++++ lib/index.ts | 4 ++ lib/types.ts | 53 +++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 lib/debugFiles/index.ts create mode 100644 lib/debugFiles/options/prepare.ts diff --git a/lib/debugFiles/index.ts b/lib/debugFiles/index.ts new file mode 100644 index 0000000000..2c07338dc8 --- /dev/null +++ b/lib/debugFiles/index.ts @@ -0,0 +1,87 @@ +'use strict'; + +import { SentryCliDebugFilesPrepareOptions, SentryCliOptions } from '../types'; +import { PREPARE_OPTIONS } from './options/prepare'; +import * as helper from '../helper'; + +/** + * Default arguments for the `--ignore` option. + */ +const DEFAULT_IGNORE: string[] = ['node_modules']; + +/** + * Manages debug information file operations on Sentry. + */ +export class DebugFiles { + constructor( + public options: SentryCliOptions = {}, + private configFile: string | null + ) {} + + /** + * Split WebAssembly DWARF into `*.debug.wasm` companions and upload them. + * + * For every `.wasm` with DWARF, injects a `build_id` if missing, writes a + * debug companion that keeps the Code section, and strips DWARF from the + * deployable module. Name/symtab-only modules are skipped with a warning. + * + * @example + * await cli.debugFiles.prepare({ + * path: './dist', + * upload: true, + * includeSources: true, + * wait: true, + * }); + * + * @param options Options to configure prepare and upload. + * @returns A promise that resolves when prepare (and optional upload) has completed. + */ + async prepare(options: SentryCliDebugFilesPrepareOptions): Promise { + const paths = normalizePreparePaths(options); + if (paths.length === 0) { + throw new Error('`options.path` or `options.paths` must contain at least one path.'); + } + + const newOptions: Record = { ...options }; + if (!newOptions.ignoreFile && !newOptions.ignore) { + newOptions.ignore = DEFAULT_IGNORE; + } + + const args = helper.prepareCommand( + ['debug-files', 'prepare', ...paths], + PREPARE_OPTIONS, + newOptions + ); + + return this.execute(args, true); + } + + /** + * See {helper.execute} docs. + */ + async execute(args: string[], live: boolean): Promise { + return helper.execute(args, live, this.options.silent, this.configFile, this.options); + } +} + +function normalizePreparePaths(options: SentryCliDebugFilesPrepareOptions | undefined): string[] { + if (!options) { + return []; + } + + const fromPath = options.path; + const fromPaths = options.paths; + + const collected: string[] = []; + if (typeof fromPath === 'string') { + collected.push(fromPath); + } else if (Array.isArray(fromPath)) { + collected.push(...fromPath); + } + + if (Array.isArray(fromPaths)) { + collected.push(...fromPaths); + } + + return collected; +} diff --git a/lib/debugFiles/options/prepare.ts b/lib/debugFiles/options/prepare.ts new file mode 100644 index 0000000000..6032cfffa7 --- /dev/null +++ b/lib/debugFiles/options/prepare.ts @@ -0,0 +1,43 @@ +import { OptionsSchema } from '../../helper'; + +/** + * Schema for the `debug-files prepare` command. + */ +export const PREPARE_OPTIONS = { + ignore: { + param: '--ignore', + type: 'array', + }, + ignoreFile: { + param: '--ignore-file', + type: 'string', + }, + outDir: { + param: '--out-dir', + type: 'string', + }, + upload: { + invertedParam: '--no-upload', + type: 'boolean', + }, + dryRun: { + param: '--dry-run', + type: 'boolean', + }, + requireDwarf: { + param: '--require-dwarf', + type: 'boolean', + }, + includeSources: { + param: '--include-sources', + type: 'boolean', + }, + wait: { + param: '--wait', + type: 'boolean', + }, + waitFor: { + param: '--wait-for', + type: 'number', + }, +} satisfies OptionsSchema; diff --git a/lib/index.ts b/lib/index.ts index 3c232eb135..9ebbc14d40 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -2,6 +2,7 @@ import * as pkgInfo from '../package.json'; import * as helper from './helper'; +import { DebugFiles } from './debugFiles'; import { Releases } from './releases'; import { SourceMaps } from './sourceMaps'; import type { SentryCliOptions } from './types'; @@ -13,6 +14,7 @@ export type { SentryCliNewDeployOptions, SentryCliCommitsOptions, SentryCliInjectOptions, + SentryCliDebugFilesPrepareOptions, } from './types'; /** @@ -33,6 +35,7 @@ export type { export class SentryCli { public releases: Releases; public sourceMaps: SourceMaps; + public debugFiles: DebugFiles; /** * Creates a new `SentryCli` instance. @@ -54,6 +57,7 @@ export class SentryCli { this.options = options || { silent: false }; this.releases = new Releases(this.options, configFile); this.sourceMaps = new SourceMaps(this.options, configFile); + this.debugFiles = new DebugFiles(this.options, configFile); } /** diff --git a/lib/types.ts b/lib/types.ts index e4d123a8f7..9f869678a9 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -234,3 +234,56 @@ export type SentryCliInjectOptions = { */ dryRun?: boolean; } + +/** + * Options for preparing WebAssembly debug files (`debug-files prepare`). + */ +export type SentryCliDebugFilesPrepareOptions = { + /** + * A `.wasm` file or directory to scan. Can be a single path or an array. + */ + path?: string | string[]; + /** + * Additional paths to scan. Merged with `path` when both are set. + */ + paths?: string[]; + /** + * One or more globs to ignore during the scan. + * Defaults to `['node_modules']` if neither ignore nor ignoreFile is specified. + */ + ignore?: string[]; + /** + * Path to a file containing list of files/directories to ignore. + */ + ignoreFile?: string; + /** + * Directory to write `*.debug.wasm` companions into. The stripped `.wasm` + * is still written next to the input. + */ + outDir?: string; + /** + * Upload companions after splitting. Defaults to `true`. Set to `false` for + * `--no-upload` (split only). + */ + upload?: boolean; + /** + * Inspect and classify modules without writing or uploading. + */ + dryRun?: boolean; + /** + * Fail if any scanned `.wasm` lacks DWARF (or a matching companion). + */ + requireDwarf?: boolean; + /** + * Include sources from the local file system and upload them as source bundles. + */ + includeSources?: boolean; + /** + * Wait for the server to fully process uploaded files. + */ + wait?: boolean; + /** + * Wait at most this many seconds for processing. + */ + waitFor?: number; +} From 2f4de4c884121496d504ea09d310785a6fa1e16d Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 9 Sep 2026 12:48:19 +0200 Subject: [PATCH 4/5] test(debug-files): Add prepare npm wrapper tests --- lib/debugFiles/__tests__/index.test.js | 110 +++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 lib/debugFiles/__tests__/index.test.js diff --git a/lib/debugFiles/__tests__/index.test.js b/lib/debugFiles/__tests__/index.test.js new file mode 100644 index 0000000000..e78d156aa0 --- /dev/null +++ b/lib/debugFiles/__tests__/index.test.js @@ -0,0 +1,110 @@ +describe('SentryCli debug files', () => { + afterEach(() => { + jest.resetModules(); + }); + + describe('with mock', () => { + let cli; + let mockExecute; + beforeAll(() => { + mockExecute = jest.fn(async () => {}); + jest.doMock('../../helper', () => ({ + ...jest.requireActual('../../helper'), + execute: mockExecute, + })); + }); + beforeEach(() => { + mockExecute.mockClear(); + // eslint-disable-next-line global-require + const { SentryCli: SentryCliLocal } = require('../..'); + cli = new SentryCliLocal(); + }); + + describe('prepare', () => { + test('with single path', async () => { + await cli.debugFiles.prepare({ path: './dist' }); + expect(mockExecute).toHaveBeenCalledWith( + ['debug-files', 'prepare', './dist', '--ignore', 'node_modules'], + true, + false, + undefined, + { silent: false } + ); + }); + + test('with paths array', async () => { + await cli.debugFiles.prepare({ paths: ['./dist', './pkg'] }); + expect(mockExecute).toHaveBeenCalledWith( + ['debug-files', 'prepare', './dist', './pkg', '--ignore', 'node_modules'], + true, + false, + undefined, + { silent: false } + ); + }); + + test('with upload false adds --no-upload', async () => { + await cli.debugFiles.prepare({ path: './dist', upload: false }); + expect(mockExecute).toHaveBeenCalledWith( + ['debug-files', 'prepare', './dist', '--ignore', 'node_modules', '--no-upload'], + true, + false, + undefined, + { silent: false } + ); + }); + + test('with includeSources and wait', async () => { + await cli.debugFiles.prepare({ + path: './dist', + includeSources: true, + wait: true, + }); + expect(mockExecute).toHaveBeenCalledWith( + [ + 'debug-files', + 'prepare', + './dist', + '--ignore', + 'node_modules', + '--include-sources', + '--wait', + ], + true, + false, + undefined, + { silent: false } + ); + }); + + test('with dryRun and requireDwarf', async () => { + await cli.debugFiles.prepare({ + path: './app.wasm', + dryRun: true, + requireDwarf: true, + }); + expect(mockExecute).toHaveBeenCalledWith( + [ + 'debug-files', + 'prepare', + './app.wasm', + '--ignore', + 'node_modules', + '--dry-run', + '--require-dwarf', + ], + true, + false, + undefined, + { silent: false } + ); + }); + + test('throws when path is missing', async () => { + await expect(cli.debugFiles.prepare({})).rejects.toThrow( + '`options.path` or `options.paths` must contain at least one path.' + ); + }); + }); + }); +}); From 9cd2f9d983c98e02cd8f05e9c0413a4d195e82af Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 9 Sep 2026 14:51:54 +0200 Subject: [PATCH 5/5] feat(debug-files): Bring prepare closer to wasm-split behavior - Stamp a build_id into every inspected module, not only the ones that get split; dry runs stay read-only - Add --strip-names to drop the name section from the deployable while the companion keeps it - Strip the module in place when --out-dir is set, so the flag redirects only the companion - Skip uploads for symtab-only modules, whose name section stays readable in the deployable - Expose stripNames in the npm wrapper --- lib/debugFiles/options/prepare.ts | 4 + lib/types.ts | 5 + src/commands/debug_files/prepare.rs | 24 +- src/utils/wasm.rs | 241 +++++++++++++++--- .../prepare/debug_files-prepare-help.trycmd | 4 + .../debug_files-prepare-no-debug-info.trycmd | 3 +- .../debug_files-prepare-require-dwarf.trycmd | 3 +- .../debug_files-prepare-stripped.trycmd | 1 + .../prepare/debug_files-prepare-symtab.trycmd | 3 +- 9 files changed, 245 insertions(+), 43 deletions(-) diff --git a/lib/debugFiles/options/prepare.ts b/lib/debugFiles/options/prepare.ts index 6032cfffa7..4bc74b3bd0 100644 --- a/lib/debugFiles/options/prepare.ts +++ b/lib/debugFiles/options/prepare.ts @@ -16,6 +16,10 @@ export const PREPARE_OPTIONS = { param: '--out-dir', type: 'string', }, + stripNames: { + param: '--strip-names', + type: 'boolean', + }, upload: { invertedParam: '--no-upload', type: 'boolean', diff --git a/lib/types.ts b/lib/types.ts index 9f869678a9..c2a3e3cbad 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -261,6 +261,11 @@ export type SentryCliDebugFilesPrepareOptions = { * is still written next to the input. */ outDir?: string; + /** + * Also strip the name section from the deployable `.wasm`. The companion + * keeps it, so symbolication is unaffected. + */ + stripNames?: boolean; /** * Upload companions after splitting. Defaults to `true`. Set to `false` for * `--no-upload` (split only). diff --git a/src/commands/debug_files/prepare.rs b/src/commands/debug_files/prepare.rs index 14ebb33b33..798afab11b 100644 --- a/src/commands/debug_files/prepare.rs +++ b/src/commands/debug_files/prepare.rs @@ -79,6 +79,16 @@ pub fn make_command(command: Command) -> Command { (in-place) so the original path stays the deploy artifact.", ), ) + .arg( + Arg::new("strip_names") + .long("strip-names") + .action(ArgAction::SetTrue) + .help( + "Also strip the name section from the deployable .wasm. \ + The companion keeps it, so symbolication is unaffected. \ + Only applies to modules that are split.", + ), + ) .arg( Arg::new("no_upload") .long("no-upload") @@ -148,8 +158,7 @@ pub fn make_command(command: Command) -> Command { } pub fn execute(matches: &ArgMatches) -> Result<()> { - - // Read CLI arguments + // Read CLI arguments #[expect(clippy::unwrap_used, reason = "required clap argument")] let paths: Vec = matches .get_many::("paths") @@ -205,6 +214,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { dry_run, out_dir, build_id, + strip_names: matches.get_flag("strip_names"), }; let mut results = Vec::new(); @@ -237,20 +247,14 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { return Err(QuietExit(1).into()); } - // Upload companions or stop + // Upload companions or stop if no_upload { return Ok(()); } let companions: Vec = results .iter() - .filter(|result| { - matches!( - result.action, - PrepareAction::Split | PrepareAction::AlreadyPrepared - ) - }) - .filter_map(|result| result.companion.clone()) + .filter_map(|result| result.upload_path().map(Path::to_path_buf)) .collect(); if companions.is_empty() { diff --git a/src/utils/wasm.rs b/src/utils/wasm.rs index 0a25448919..cbf7b1b166 100644 --- a/src/utils/wasm.rs +++ b/src/utils/wasm.rs @@ -93,6 +93,24 @@ impl PrepareResult { warning: Some(warning.into()), } } + + fn with_build_id(mut self, build_id: Option<&[u8]>) -> Self { + self.build_id = build_id.map(format_build_id); + self + } + + /// Companion to upload to Sentry for this result, if it produced one. + /// + /// Only split modules yield a debug file. A name/symtab-only module is not + /// worth uploading: its `name` section stays in the deployable module, and + /// runtimes resolve function names from it directly, so a DIF built from it + /// would carry nothing the stack trace does not already have. + pub fn upload_path(&self) -> Option<&Path> { + match self.action { + PrepareAction::Split | PrepareAction::AlreadyPrepared => self.companion.as_deref(), + _ => None, + } + } } /// Options for [`prepare_wasm_file`]. @@ -101,6 +119,7 @@ pub struct PrepareOptions<'a> { pub dry_run: bool, pub out_dir: Option<&'a Path>, pub build_id: Option, + pub strip_names: bool, } pub fn is_wasm_path(path: &Path) -> bool { @@ -242,11 +261,16 @@ fn read_build_id(path: &Path) -> Result>> { /// Injects `build_id` if missing, writes the full module (Code + DWARF) to /// `companion`, strips `.debug_*` from the deployable copy, and adds /// `external_debug_info` pointing at the companion filename. +/// +/// `strip_names` additionally drops the name section from the deployable copy. +/// Safe only here: the companion is written first and keeps every section, so +/// the names survive for symbolication. pub fn split_wasm( input: &Path, companion: &Path, stripped_out: &Path, build_id: Option, + strip_names: bool, ) -> Result> { let mut module = decode_module(input)?; let inspection = inspect_module(&module); @@ -267,7 +291,7 @@ pub fn split_wasm( module .sections - .retain(|section| !is_strippable_section(section, false)); + .retain(|section| !is_strippable_section(section, strip_names)); let debug_file_name = companion .file_name() @@ -328,6 +352,38 @@ fn verify_split( Ok(()) } +/// Give a module that will not be split a `build_id`, writing it back in place. +/// +/// `wasm-split` stamps every module it processes, regardless of debug quality. +/// Sentry matches a module in a stack trace to its debug file by `build_id`, so +/// a module without one can never be symbolicated, even from a debug file +/// uploaded later. Returns the effective id, or `None` when a dry run leaves an +/// unstamped module untouched. +fn ensure_build_id( + path: &Path, + module: &mut Module, + existing: Option>, + options: PrepareOptions<'_>, +) -> Result>> { + if existing.is_some() { + return Ok(existing); + } + if options.dry_run { + return Ok(None); + } + + let new_id = options + .build_id + .unwrap_or_else(Uuid::new_v4) + .as_bytes() + .to_vec(); + module + .sections + .push(CustomSection::BuildId(new_id.clone()).into()); + encode_module(module, path)?; + Ok(Some(new_id)) +} + /// Classify and optionally split one `.wasm` file. /// /// Higher-level wrapper around [`split_wasm`] for the `debug-files prepare` @@ -343,7 +399,7 @@ pub fn prepare_wasm_file(path: &Path, options: PrepareOptions<'_>) -> Result
 module,
         Err(err) => {
             return Ok(PrepareResult::skipped(
@@ -356,10 +412,10 @@ pub fn prepare_wasm_file(path: &Path, options: PrepareOptions<'_>) -> Result
 dir.join(path.file_name().unwrap_or(path.as_os_str())),
-        None => path.to_path_buf(),
-    };
+    // `out_dir` redirects the companion only. The deployable module is always
+    // stripped in place, so the path the caller deploys is the one that ends up
+    // stamped and stripped.
+    let stripped_out = path.to_path_buf();
 
     // Already split: stripped module + companion with the same build_id.
     if inspection.quality != DebugQuality::Dwarf {
@@ -399,40 +455,35 @@ pub fn prepare_wasm_file(path: &Path, options: PrepareOptions<'_>) -> Result
 {}
+    let skip_warning = match inspection.quality {
+        DebugQuality::Dwarf => None,
         DebugQuality::ExternalDebugInfo => {
-            return Ok(PrepareResult::skipped(
-                path.to_path_buf(),
-                inspection.quality,
-                "has external_debug_info but no local companion with matching build_id".to_owned(),
-            ));
-        }
-        DebugQuality::Symtab => {
-            return Ok(PrepareResult::skipped(
-                path.to_path_buf(),
-                inspection.quality,
-                "no line-level symbolication (name/symtab only)".to_owned(),
-            ));
+            Some("has external_debug_info but no local companion with matching build_id")
         }
+        DebugQuality::Symtab => Some("no line-level symbolication (name/symtab only)"),
         DebugQuality::None => {
             // A build_id without debug sections means someone already stripped
             // this module, so re-splitting would overwrite a good companion
             // with an empty one. Without a build_id it was simply built
             // without debug info.
-            let warning = if inspection.build_id.is_some() {
+            Some(if inspection.build_id.is_some() {
                 "already stripped (build_id present, no debug sections); \
                  splitting would produce a useless companion"
             } else {
                 "no debug information; rebuild with DWARF \
                  (Emscripten -g, wasm-pack dwarf-debug-info)"
-            };
-            return Ok(PrepareResult::skipped(
-                path.to_path_buf(),
-                inspection.quality,
-                warning.to_owned(),
-            ));
+            })
         }
+    };
+
+    if let Some(warning) = skip_warning {
+        let build_id = ensure_build_id(path, &mut module, inspection.build_id.clone(), options)?;
+        return Ok(PrepareResult::skipped(
+            path.to_path_buf(),
+            inspection.quality,
+            warning.to_owned(),
+        )
+        .with_build_id(build_id.as_deref()));
     }
 
     if options.dry_run {
@@ -447,7 +498,13 @@ pub fn prepare_wasm_file(path: &Path, options: PrepareOptions<'_>) -> Result
 Module {
+        Module {
+            sections: vec![
+                CustomSection::Other(RawCustomSection {
+                    name: ".debug_info".into(),
+                    data: vec![0, 1, 2, 3].into(),
+                })
+                .into(),
+                CustomSection::Name(Default::default()).into(),
+            ],
+        }
+    }
+
     fn empty_module() -> Module {
         Module { sections: vec![] }
     }
@@ -537,7 +609,7 @@ mod tests {
         let dir = tempfile::tempdir().unwrap();
         let input = write_module(dir.path(), "app.wasm", &dwarf_module());
         let companion = dir.path().join("app.debug.wasm");
-        let build_id = split_wasm(&input, &companion, &input, None).unwrap();
+        let build_id = split_wasm(&input, &companion, &input, None, false).unwrap();
 
         let stripped = inspect_module(&decode_module(&input).unwrap());
         let debug = inspect_module(&decode_module(&companion).unwrap());
@@ -552,6 +624,79 @@ mod tests {
         );
     }
 
+    #[test]
+    fn out_dir_redirects_companion_but_strips_in_place() {
+        let dir = tempfile::tempdir().unwrap();
+        let input = write_module(dir.path(), "app.wasm", &dwarf_module());
+        let out_dir = dir.path().join("symbols");
+
+        let result = prepare_wasm_file(
+            &input,
+            PrepareOptions {
+                out_dir: Some(&out_dir),
+                ..Default::default()
+            },
+        )
+        .unwrap();
+
+        assert_eq!(result.action, PrepareAction::Split);
+        assert_eq!(
+            result.companion.as_deref(),
+            Some(out_dir.join("app.debug.wasm").as_path())
+        );
+        assert!(out_dir.join("app.debug.wasm").is_file());
+
+        // The deployed path, not a copy in `out_dir`, is what gets stripped.
+        assert_eq!(result.stripped.as_deref(), Some(input.as_path()));
+        assert!(!out_dir.join("app.wasm").exists());
+
+        let deployed = inspect_module(&decode_module(&input).unwrap());
+        assert!(deployed.build_id.is_some());
+        assert_ne!(deployed.quality, DebugQuality::Dwarf);
+    }
+
+    fn has_name_section(path: &Path) -> bool {
+        decode_module(path)
+            .unwrap()
+            .sections
+            .iter()
+            .filter_map(as_custom_section)
+            .any(|section| matches!(section, CustomSection::Name(_)))
+    }
+
+    #[test]
+    fn strip_names_trims_the_deployable_only() {
+        let dir = tempfile::tempdir().unwrap();
+        let input = write_module(dir.path(), "app.wasm", &dwarf_and_names_module());
+
+        prepare_wasm_file(
+            &input,
+            PrepareOptions {
+                strip_names: true,
+                ..Default::default()
+            },
+        )
+        .unwrap();
+
+        let companion = companion_path(&input, None);
+        assert_eq!(
+            inspect_module(&decode_module(&companion).unwrap()).quality,
+            DebugQuality::Dwarf
+        );
+        assert!(has_name_section(&companion));
+        assert!(!has_name_section(&input));
+    }
+
+    #[test]
+    fn names_are_kept_by_default() {
+        let dir = tempfile::tempdir().unwrap();
+        let input = write_module(dir.path(), "app.wasm", &dwarf_and_names_module());
+
+        prepare_wasm_file(&input, PrepareOptions::default()).unwrap();
+
+        assert!(has_name_section(&input));
+    }
+
     #[test]
     fn prepare_skips_symtab_only() {
         let dir = tempfile::tempdir().unwrap();
@@ -559,8 +704,44 @@ mod tests {
         let result = prepare_wasm_file(&input, PrepareOptions::default()).unwrap();
         assert_eq!(result.action, PrepareAction::Skipped);
         assert_eq!(result.quality, DebugQuality::Symtab);
-        assert!(result.warning.unwrap().contains("no line-level"));
+        assert!(result.warning.as_deref().unwrap().contains("no line-level"));
         assert!(!companion_path(&input, None).exists());
+        // Stamped so a DWARF build of the same module can be matched later, but
+        // nothing to upload: the name section stays in the deployable module.
+        assert!(result.build_id.is_some());
+        assert!(result.upload_path().is_none());
+        assert!(read_build_id(&input).unwrap().is_some());
+    }
+
+    #[test]
+    fn stamping_a_skipped_module_is_stable_across_runs() {
+        let dir = tempfile::tempdir().unwrap();
+        let input = write_module(dir.path(), "unity.wasm", &name_only_module());
+
+        let first = prepare_wasm_file(&input, PrepareOptions::default()).unwrap();
+        let second = prepare_wasm_file(&input, PrepareOptions::default()).unwrap();
+
+        assert!(first.build_id.is_some());
+        assert_eq!(first.build_id, second.build_id);
+    }
+
+    #[test]
+    fn dry_run_does_not_stamp_a_skipped_module() {
+        let dir = tempfile::tempdir().unwrap();
+        let input = write_module(dir.path(), "unity.wasm", &name_only_module());
+
+        let result = prepare_wasm_file(
+            &input,
+            PrepareOptions {
+                dry_run: true,
+                ..Default::default()
+            },
+        )
+        .unwrap();
+
+        assert_eq!(result.action, PrepareAction::Skipped);
+        assert!(result.build_id.is_none());
+        assert!(read_build_id(&input).unwrap().is_none());
     }
 
     #[test]
diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-help.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-help.trycmd
index e11110ef36..f044dbda79 100644
--- a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-help.trycmd
+++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-help.trycmd
@@ -52,6 +52,10 @@ Options:
           
           [aliases: --silent]
 
+      --strip-names
+          Also strip the name section from the deployable .wasm. The companion keeps it, so
+          symbolication is unaffected. Only applies to modules that are split.
+
       --no-upload
           Split only; do not upload companions.
 
diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-debug-info.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-debug-info.trycmd
index d67ac3f91b..31c9aad396 100644
--- a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-debug-info.trycmd
+++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-no-debug-info.trycmd
@@ -1,10 +1,11 @@
 ```
-$ sentry-cli debug-files prepare . --no-upload
+$ sentry-cli debug-files prepare . --no-upload --build-id 00000000-0000-4000-8000-000000000000
 ? success
 > Searching .
 > Found 1 wasm file
 > Skipping ./app.wasm
     Debug quality: none
+    Build ID: 00000000000040008000000000000000
     Warning: no debug information; rebuild with DWARF (Emscripten -g, wasm-pack dwarf-debug-info)
 
 ```
diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-require-dwarf.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-require-dwarf.trycmd
index 9c47eaf52c..74f6304c4b 100644
--- a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-require-dwarf.trycmd
+++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-require-dwarf.trycmd
@@ -1,10 +1,11 @@
 ```
-$ sentry-cli debug-files prepare . --no-upload --require-dwarf
+$ sentry-cli debug-files prepare . --no-upload --require-dwarf --build-id 00000000-0000-4000-8000-000000000000
 ? failed
 > Searching .
 > Found 1 wasm file
 > Skipping ./unity.wasm
     Debug quality: symtab
+    Build ID: 00000000000040008000000000000000
     Warning: no line-level symbolication (name/symtab only)
 Error: some .wasm files lack DWARF (--require-dwarf)
 
diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-stripped.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-stripped.trycmd
index 72dd546a94..1280594d4d 100644
--- a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-stripped.trycmd
+++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-stripped.trycmd
@@ -5,6 +5,7 @@ $ sentry-cli debug-files prepare . --no-upload
 > Found 1 wasm file
 > Skipping ./app.wasm
     Debug quality: none
+    Build ID: 07070707070707070707070707070707
     Warning: already stripped (build_id present, no debug sections); splitting would produce a useless companion
 
 ```
diff --git a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-symtab.trycmd b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-symtab.trycmd
index 72280aef34..0b1b8c9ca1 100644
--- a/tests/integration/_cases/debug_files/prepare/debug_files-prepare-symtab.trycmd
+++ b/tests/integration/_cases/debug_files/prepare/debug_files-prepare-symtab.trycmd
@@ -1,10 +1,11 @@
 ```
-$ sentry-cli debug-files prepare . --no-upload
+$ sentry-cli debug-files prepare . --no-upload --build-id 00000000-0000-4000-8000-000000000000
 ? success
 > Searching .
 > Found 1 wasm file
 > Skipping ./unity.wasm
     Debug quality: symtab
+    Build ID: 00000000000040008000000000000000
     Warning: no line-level symbolication (name/symtab only)
 
 ```