From 7569828d7a18a79850181f3737a2f80ab05b8389 Mon Sep 17 00:00:00 2001 From: Jamie <2119834+jamieQ@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:38:17 -0500 Subject: [PATCH 1/4] feat(build): Add dSYM inputs to IPA uploads --- src/commands/build/upload.rs | 202 ++++++++++++++++++++++++++++++++++- src/utils/build/apple.rs | 99 ++++++++++++++++- 2 files changed, 294 insertions(+), 7 deletions(-) diff --git a/src/commands/build/upload.rs b/src/commands/build/upload.rs index 8b058566c0..d675ed92d3 100644 --- a/src/commands/build/upload.rs +++ b/src/commands/build/upload.rs @@ -35,7 +35,7 @@ pub fn make_command(command: Command) -> Command { #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] const HELP_TEXT: &str = "The path to the build to upload. Supported files include Apk, and Aab."; - command + let command = command .about("Upload builds to a project.") .long_about("Upload builds to a project.\n\nThis feature only works with Sentry SaaS.") .org_arg() @@ -47,7 +47,18 @@ pub fn make_command(command: Command) -> Command { .num_args(1..) .action(ArgAction::Append) .required(true), - ) + ); + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + let command = command.arg( + Arg::new("dsym") + .long("dsym") + .value_name("PATH") + .help( + "Path to a dSYM bundle or a directory containing dSYM bundles to include with an IPA upload. Can be specified multiple times.", + ) + .action(ArgAction::Append), + ); + command .git_metadata_args() .arg( Arg::new("build_configuration") @@ -104,6 +115,15 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { .get_many::("paths") .expect("paths argument is required"); + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + let dsym_paths = matches + .get_many::("dsym") + .map(|paths| paths.map(Path::new).collect::>()) + .unwrap_or_default(); + + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + validate_dsym_upload_count(path_strings.len(), &dsym_paths)?; + // Collect git metadata if running in CI, unless explicitly enabled or disabled. let should_collect_git_metadata = matches.get_flag("force_git_metadata") || (!matches.get_flag("no_git_metadata") && is_ci()); @@ -143,6 +163,13 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { return Err(anyhow!("Path does not exist: {}", path.display())); } + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + // An IPA is a ZIP file, so a directory cannot be the IPA paired with these dSYMs. + // The file's contents are validated as an IPA in `handle_file` below. + if !path.is_file() && !dsym_paths.is_empty() { + bail!("--dsym can only be used with an IPA upload"); + } + // On non-Apple Silicon, reject xcarchive/IPA early before trying to // open the path as a file (xcarchive is a directory, so ByteView::open // would fail with a confusing I/O error). @@ -167,6 +194,8 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { handle_file( path, &byteview, + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + &dsym_paths, plugin_name.as_deref(), plugin_version.as_deref(), )? @@ -260,15 +289,24 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { fn handle_file( path: &Path, byteview: &ByteView, + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] dsym_paths: &[&Path], plugin_name: Option<&str>, plugin_version: Option<&str>, ) -> Result { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + let is_ipa = is_zip_file(byteview) && is_ipa_file(byteview)?; + + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + if !is_ipa && !dsym_paths.is_empty() { + bail!("--dsym can only be used with an IPA upload"); + } + // Handle IPA files by converting them to XCArchive #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - if is_zip_file(byteview) && is_ipa_file(byteview)? { + if is_ipa { debug!("Converting IPA file to XCArchive structure"); let archive_temp_dir = TempDir::create()?; - return ipa_to_xcarchive(path, byteview, &archive_temp_dir) + return ipa_to_xcarchive(path, byteview, &archive_temp_dir, dsym_paths) .and_then(|path| handle_directory(&path, plugin_name, plugin_version)) .with_context(|| format!("Failed to process IPA file {}", path.display())); } @@ -281,6 +319,14 @@ fn handle_file( }) } +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +fn validate_dsym_upload_count(upload_count: usize, dsym_paths: &[&Path]) -> Result<()> { + if upload_count > 1 && !dsym_paths.is_empty() { + bail!("--dsym can only be used when uploading exactly one IPA file"); + } + Ok(()) +} + fn validate_is_supported_build(path: &Path, bytes: &[u8]) -> Result<()> { debug!("Validating build format for: {}", path.display()); @@ -549,7 +595,7 @@ mod tests { let byteview = ByteView::open(ipa_path)?; // Process the IPA file - this should work even without asset catalogs - let result = handle_file(ipa_path, &byteview, None, None)?; + let result = handle_file(ipa_path, &byteview, &[], None, None)?; let zip_file = fs::File::open(result.path())?; let mut archive = ZipArchive::new(zip_file)?; @@ -573,6 +619,152 @@ mod tests { Ok(()) } + #[test] + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + fn test_ipa_upload_includes_repeated_dsyms() -> Result<()> { + let temp_dir = crate::utils::fs::TempDir::create()?; + let first_dsym = temp_dir.path().join("DemoApp.app.dSYM"); + let second_dsym = temp_dir.path().join("DemoFramework.framework.dSYM"); + let first_dwarf = first_dsym.join("Contents/Resources/DWARF"); + let second_dwarf = second_dsym.join("Contents/Resources/DWARF"); + fs::create_dir_all(&first_dwarf)?; + fs::create_dir_all(&second_dwarf)?; + fs::write(first_dwarf.join("DemoApp"), "app debug symbols")?; + fs::write( + second_dwarf.join("DemoFramework"), + "framework debug symbols", + )?; + + let ipa_path = Path::new("tests/integration/_fixtures/build/ipa.ipa"); + let byteview = ByteView::open(ipa_path)?; + let result = handle_file( + ipa_path, + &byteview, + &[first_dsym.as_path(), second_dsym.as_path()], + None, + None, + )?; + + let zip_file = fs::File::open(result.path())?; + let mut archive = ZipArchive::new(zip_file)?; + assert!(archive + .by_name("archive.xcarchive/dSYMs/DemoApp.app.dSYM/Contents/Resources/DWARF/DemoApp") + .is_ok()); + assert!(archive + .by_name("archive.xcarchive/dSYMs/DemoFramework.framework.dSYM/Contents/Resources/DWARF/DemoFramework") + .is_ok()); + Ok(()) + } + + #[test] + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + fn test_ipa_upload_includes_dsyms_from_directory() -> Result<()> { + let temp_dir = crate::utils::fs::TempDir::create()?; + let dsyms_dir = temp_dir.path().join("dSYMs"); + let app_dwarf = dsyms_dir.join("DemoApp.app.dSYM/Contents/Resources/DWARF"); + let framework_dwarf = + dsyms_dir.join("DemoFramework.framework.dSYM/Contents/Resources/DWARF"); + fs::create_dir_all(&app_dwarf)?; + fs::create_dir_all(&framework_dwarf)?; + fs::write(app_dwarf.join("DemoApp"), "app debug symbols")?; + fs::write( + framework_dwarf.join("DemoFramework"), + "framework debug symbols", + )?; + fs::write(dsyms_dir.join("README.txt"), "ignored")?; + + let ipa_path = Path::new("tests/integration/_fixtures/build/ipa.ipa"); + let byteview = ByteView::open(ipa_path)?; + let result = handle_file(ipa_path, &byteview, &[dsyms_dir.as_path()], None, None)?; + + let zip_file = fs::File::open(result.path())?; + let mut archive = ZipArchive::new(zip_file)?; + assert!(archive + .by_name("archive.xcarchive/dSYMs/DemoApp.app.dSYM/Contents/Resources/DWARF/DemoApp") + .is_ok()); + assert!(archive + .by_name("archive.xcarchive/dSYMs/DemoFramework.framework.dSYM/Contents/Resources/DWARF/DemoFramework") + .is_ok()); + assert!(archive + .by_name("archive.xcarchive/dSYMs/README.txt") + .is_err()); + Ok(()) + } + + #[test] + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + fn test_dsym_arg_is_repeatable() { + let matches = make_command(Command::new("test")) + .try_get_matches_from([ + "test", + "--org", + "test-org", + "--project", + "test-project", + "--dsym", + "DemoApp.app.dSYM", + "--dsym", + "DemoFramework.framework.dSYM", + "DemoApp.ipa", + ]) + .unwrap(); + + let dsym_paths = matches + .get_many::("dsym") + .unwrap() + .collect::>(); + assert_eq!( + dsym_paths, + ["DemoApp.app.dSYM", "DemoFramework.framework.dSYM"] + ); + } + + #[test] + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + fn test_dsym_rejects_multiple_uploads() { + let error = validate_dsym_upload_count(2, &[Path::new("DemoApp.app.dSYM")]) + .unwrap_err() + .to_string(); + assert_eq!( + error, + "--dsym can only be used when uploading exactly one IPA file" + ); + } + + #[test] + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + fn test_dsym_rejects_non_ipa_upload() -> Result<()> { + let apk_path = Path::new("tests/integration/_fixtures/build/apk.apk"); + let byteview = ByteView::open(apk_path)?; + let error = handle_file( + apk_path, + &byteview, + &[Path::new("DemoApp.app.dSYM")], + None, + None, + ) + .unwrap_err() + .to_string(); + assert_eq!(error, "--dsym can only be used with an IPA upload"); + Ok(()) + } + + #[test] + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + fn test_ipa_upload_rejects_directory_without_dsyms() -> Result<()> { + let temp_dir = crate::utils::fs::TempDir::create()?; + let symbols_dir = temp_dir.path().join("Symbols"); + fs::create_dir(&symbols_dir)?; + + let ipa_path = Path::new("tests/integration/_fixtures/build/ipa.ipa"); + let byteview = ByteView::open(ipa_path)?; + let error = + handle_file(ipa_path, &byteview, &[symbols_dir.as_path()], None, None).unwrap_err(); + let error = format!("{error:#}"); + assert!(error.contains("No .dSYM bundles found in directory")); + Ok(()) + } + #[test] fn test_normalize_directory_preserves_symlinks() -> Result<()> { let temp_dir = crate::utils::fs::TempDir::create()?; diff --git a/src/utils/build/apple.rs b/src/utils/build/apple.rs index 0fe0db4c73..611129645d 100644 --- a/src/utils/build/apple.rs +++ b/src/utils/build/apple.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, bail, Context as _, Result}; use log::debug; use regex::Regex; use std::{ @@ -71,7 +71,12 @@ fn find_car_files(root: &Path) -> Vec { /// │ └── ... (other app resources) /// └── ... (other archive metadata) /// ``` -pub fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8], temp_dir: &TempDir) -> Result { +pub fn ipa_to_xcarchive( + ipa_path: &Path, + ipa_bytes: &[u8], + temp_dir: &TempDir, + dsym_paths: &[&Path], +) -> Result { debug!( "Converting IPA to XCArchive structure: {}", ipa_path.display() @@ -134,6 +139,8 @@ pub fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8], temp_dir: &TempDir) - std::fs::write(&info_plist_path, info_plist_content)?; + copy_dsyms(dsym_paths, &xcarchive_dir)?; + debug!( "Created XCArchive Info.plist at: {}", info_plist_path.display() @@ -141,6 +148,94 @@ pub fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8], temp_dir: &TempDir) - Ok(xcarchive_dir) } +fn copy_dsyms(dsym_paths: &[&Path], xcarchive_dir: &Path) -> Result<()> { + if dsym_paths.is_empty() { + return Ok(()); + } + + let dsyms_dir = xcarchive_dir.join("dSYMs"); + std::fs::create_dir(&dsyms_dir)?; + + for dsym_input in dsym_paths { + for dsym_path in resolve_dsym_bundles(dsym_input)? { + let bundle_name = dsym_path + .file_name() + .ok_or_else(|| anyhow!("dSYM path has no bundle name: {}", dsym_path.display()))?; + let destination = dsyms_dir.join(bundle_name); + if destination.exists() { + bail!( + "Cannot include multiple dSYM bundles named {}", + bundle_name.to_string_lossy() + ); + } + + for entry in WalkDir::new(&dsym_path) { + let entry = entry.with_context(|| { + format!("Failed to read dSYM bundle {}", dsym_path.display()) + })?; + let relative_path = entry.path().strip_prefix(&dsym_path)?; + let target_path = destination.join(relative_path); + + if entry.file_type().is_dir() { + std::fs::create_dir_all(&target_path)?; + } else if entry.file_type().is_file() { + std::fs::copy(entry.path(), &target_path).with_context(|| { + format!( + "Failed to copy dSYM file {} to {}", + entry.path().display(), + target_path.display() + ) + })?; + } else if entry.file_type().is_symlink() { + let link_target = std::fs::read_link(entry.path())?; + std::os::unix::fs::symlink(link_target, &target_path)?; + } + } + } + } + + Ok(()) +} + +fn resolve_dsym_bundles(path: &Path) -> Result> { + if is_dsym_bundle(path) { + return Ok(vec![path.to_owned()]); + } + + if !path.is_dir() { + bail!( + "dSYM path must be a .dSYM bundle or a directory containing .dSYM bundles: {}", + path.display() + ); + } + + let mut bundles = Vec::new(); + for entry in std::fs::read_dir(path) + .with_context(|| format!("Failed to read dSYM directory {}", path.display()))? + { + let entry = + entry.with_context(|| format!("Failed to read dSYM directory {}", path.display()))?; + let entry_path = entry.path(); + if is_dsym_bundle(&entry_path) { + bundles.push(entry_path); + } + } + + if bundles.is_empty() { + bail!("No .dSYM bundles found in directory: {}", path.display()); + } + + Ok(bundles) +} + +fn is_dsym_bundle(path: &Path) -> bool { + path.is_dir() + && path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("dsym")) +} + static PATTERN: LazyLock = LazyLock::new(|| Regex::new(r"^Payload/([^/]+)\.app/Info\.plist$").expect("regex is valid")); From 4c5f2d479232d53a287e8a43486104975eaaff6a Mon Sep 17 00:00:00 2001 From: Jamie <2119834+jamieQ@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:59:11 -0500 Subject: [PATCH 2/4] feat(build): Support dSYMs with IPA uploads --- src/commands/build/upload.rs | 160 ++----- src/utils/build/apple.rs | 391 +++++++++++++++--- .../build/build-upload-help-macos.trycmd | 4 + .../build/build-upload-ipa-with-dsym.trycmd | 7 + .../Contents/Resources/DWARF/DemoApp | 1 + tests/integration/build/upload.rs | 92 +++++ 6 files changed, 478 insertions(+), 177 deletions(-) create mode 100644 tests/integration/_cases/build/build-upload-ipa-with-dsym.trycmd create mode 100644 tests/integration/_fixtures/build/dSYMs/DemoApp.app.dSYM/Contents/Resources/DWARF/DemoApp diff --git a/src/commands/build/upload.rs b/src/commands/build/upload.rs index d675ed92d3..acf9eadad3 100644 --- a/src/commands/build/upload.rs +++ b/src/commands/build/upload.rs @@ -48,17 +48,7 @@ pub fn make_command(command: Command) -> Command { .action(ArgAction::Append) .required(true), ); - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - let command = command.arg( - Arg::new("dsym") - .long("dsym") - .value_name("PATH") - .help( - "Path to a dSYM bundle or a directory containing dSYM bundles to include with an IPA upload. Can be specified multiple times.", - ) - .action(ArgAction::Append), - ); - command + let command = command .git_metadata_args() .arg( Arg::new("build_configuration") @@ -79,7 +69,18 @@ pub fn make_command(command: Command) -> Command { Builds with at least one matching install group will be shown updates \ for each other.", ) - ) + ); + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + let command = command.arg( + Arg::new("dsym") + .long("dsym") + .value_name("PATH") + .help( + "Path to a dSYM bundle, a directory containing dSYM bundles, or a ZIP of either to include with an IPA upload. Can be specified multiple times.", + ) + .action(ArgAction::Append), + ); + command } /// Parse plugin info from SENTRY_PIPELINE environment variable. @@ -120,8 +121,9 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { .get_many::("dsym") .map(|paths| paths.map(Path::new).collect::>()) .unwrap_or_default(); + #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] + let dsym_paths = Vec::<&Path>::new(); - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] validate_dsym_upload_count(path_strings.len(), &dsym_paths)?; // Collect git metadata if running in CI, unless explicitly enabled or disabled. @@ -163,13 +165,6 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { return Err(anyhow!("Path does not exist: {}", path.display())); } - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - // An IPA is a ZIP file, so a directory cannot be the IPA paired with these dSYMs. - // The file's contents are validated as an IPA in `handle_file` below. - if !path.is_file() && !dsym_paths.is_empty() { - bail!("--dsym can only be used with an IPA upload"); - } - // On non-Apple Silicon, reject xcarchive/IPA early before trying to // open the path as a file (xcarchive is a directory, so ByteView::open // would fail with a confusing I/O error). @@ -194,12 +189,15 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { handle_file( path, &byteview, - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] &dsym_paths, plugin_name.as_deref(), plugin_version.as_deref(), )? } else if path.is_dir() { + if !dsym_paths.is_empty() { + bail!("--dsym can only be used with an IPA upload"); + } + debug!("Normalizing directory: {}", path.display()); handle_directory(path, plugin_name.as_deref(), plugin_version.as_deref()).with_context( || { @@ -289,26 +287,25 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { fn handle_file( path: &Path, byteview: &ByteView, - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] dsym_paths: &[&Path], + _dsym_paths: &[&Path], plugin_name: Option<&str>, plugin_version: Option<&str>, ) -> Result { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - let is_ipa = is_zip_file(byteview) && is_ipa_file(byteview)?; - - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - if !is_ipa && !dsym_paths.is_empty() { - bail!("--dsym can only be used with an IPA upload"); - } + { + let is_ipa = is_zip_file(byteview) && is_ipa_file(byteview)?; + if !is_ipa && !_dsym_paths.is_empty() { + bail!("--dsym can only be used with an IPA upload"); + } - // Handle IPA files by converting them to XCArchive - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - if is_ipa { - debug!("Converting IPA file to XCArchive structure"); - let archive_temp_dir = TempDir::create()?; - return ipa_to_xcarchive(path, byteview, &archive_temp_dir, dsym_paths) - .and_then(|path| handle_directory(&path, plugin_name, plugin_version)) - .with_context(|| format!("Failed to process IPA file {}", path.display())); + // Handle IPA files by converting them to XCArchive + if is_ipa { + debug!("Converting IPA file to XCArchive structure"); + let archive_temp_dir = TempDir::create()?; + return ipa_to_xcarchive(path, byteview, _dsym_paths, &archive_temp_dir) + .and_then(|path| handle_directory(&path, plugin_name, plugin_version)) + .with_context(|| format!("Failed to process IPA file {}", path.display())); + } } normalize_file(path, byteview, plugin_name, plugin_version).with_context(|| { @@ -319,8 +316,9 @@ fn handle_file( }) } -#[cfg(all(target_os = "macos", target_arch = "aarch64"))] fn validate_dsym_upload_count(upload_count: usize, dsym_paths: &[&Path]) -> Result<()> { + // dSYM inputs apply to the whole command, so their target would be ambiguous + // if the same invocation uploaded multiple builds. if upload_count > 1 && !dsym_paths.is_empty() { bail!("--dsym can only be used when uploading exactly one IPA file"); } @@ -619,78 +617,6 @@ mod tests { Ok(()) } - #[test] - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - fn test_ipa_upload_includes_repeated_dsyms() -> Result<()> { - let temp_dir = crate::utils::fs::TempDir::create()?; - let first_dsym = temp_dir.path().join("DemoApp.app.dSYM"); - let second_dsym = temp_dir.path().join("DemoFramework.framework.dSYM"); - let first_dwarf = first_dsym.join("Contents/Resources/DWARF"); - let second_dwarf = second_dsym.join("Contents/Resources/DWARF"); - fs::create_dir_all(&first_dwarf)?; - fs::create_dir_all(&second_dwarf)?; - fs::write(first_dwarf.join("DemoApp"), "app debug symbols")?; - fs::write( - second_dwarf.join("DemoFramework"), - "framework debug symbols", - )?; - - let ipa_path = Path::new("tests/integration/_fixtures/build/ipa.ipa"); - let byteview = ByteView::open(ipa_path)?; - let result = handle_file( - ipa_path, - &byteview, - &[first_dsym.as_path(), second_dsym.as_path()], - None, - None, - )?; - - let zip_file = fs::File::open(result.path())?; - let mut archive = ZipArchive::new(zip_file)?; - assert!(archive - .by_name("archive.xcarchive/dSYMs/DemoApp.app.dSYM/Contents/Resources/DWARF/DemoApp") - .is_ok()); - assert!(archive - .by_name("archive.xcarchive/dSYMs/DemoFramework.framework.dSYM/Contents/Resources/DWARF/DemoFramework") - .is_ok()); - Ok(()) - } - - #[test] - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - fn test_ipa_upload_includes_dsyms_from_directory() -> Result<()> { - let temp_dir = crate::utils::fs::TempDir::create()?; - let dsyms_dir = temp_dir.path().join("dSYMs"); - let app_dwarf = dsyms_dir.join("DemoApp.app.dSYM/Contents/Resources/DWARF"); - let framework_dwarf = - dsyms_dir.join("DemoFramework.framework.dSYM/Contents/Resources/DWARF"); - fs::create_dir_all(&app_dwarf)?; - fs::create_dir_all(&framework_dwarf)?; - fs::write(app_dwarf.join("DemoApp"), "app debug symbols")?; - fs::write( - framework_dwarf.join("DemoFramework"), - "framework debug symbols", - )?; - fs::write(dsyms_dir.join("README.txt"), "ignored")?; - - let ipa_path = Path::new("tests/integration/_fixtures/build/ipa.ipa"); - let byteview = ByteView::open(ipa_path)?; - let result = handle_file(ipa_path, &byteview, &[dsyms_dir.as_path()], None, None)?; - - let zip_file = fs::File::open(result.path())?; - let mut archive = ZipArchive::new(zip_file)?; - assert!(archive - .by_name("archive.xcarchive/dSYMs/DemoApp.app.dSYM/Contents/Resources/DWARF/DemoApp") - .is_ok()); - assert!(archive - .by_name("archive.xcarchive/dSYMs/DemoFramework.framework.dSYM/Contents/Resources/DWARF/DemoFramework") - .is_ok()); - assert!(archive - .by_name("archive.xcarchive/dSYMs/README.txt") - .is_err()); - Ok(()) - } - #[test] #[cfg(all(target_os = "macos", target_arch = "aarch64"))] fn test_dsym_arg_is_repeatable() { @@ -749,22 +675,6 @@ mod tests { Ok(()) } - #[test] - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - fn test_ipa_upload_rejects_directory_without_dsyms() -> Result<()> { - let temp_dir = crate::utils::fs::TempDir::create()?; - let symbols_dir = temp_dir.path().join("Symbols"); - fs::create_dir(&symbols_dir)?; - - let ipa_path = Path::new("tests/integration/_fixtures/build/ipa.ipa"); - let byteview = ByteView::open(ipa_path)?; - let error = - handle_file(ipa_path, &byteview, &[symbols_dir.as_path()], None, None).unwrap_err(); - let error = format!("{error:#}"); - assert!(error.contains("No .dSYM bundles found in directory")); - Ok(()) - } - #[test] fn test_normalize_directory_preserves_symlinks() -> Result<()> { let temp_dir = crate::utils::fs::TempDir::create()?; diff --git a/src/utils/build/apple.rs b/src/utils/build/apple.rs index 611129645d..cd5309e6cb 100644 --- a/src/utils/build/apple.rs +++ b/src/utils/build/apple.rs @@ -40,6 +40,7 @@ fn find_car_files(root: &Path) -> Vec { } /// Converts an IPA file to an XCArchive directory structure. The provided IPA must be a valid IPA file. +/// Any provided dSYM inputs are included in the generated XCArchive. /// /// # Format Overview /// @@ -74,8 +75,8 @@ fn find_car_files(root: &Path) -> Vec { pub fn ipa_to_xcarchive( ipa_path: &Path, ipa_bytes: &[u8], - temp_dir: &TempDir, dsym_paths: &[&Path], + temp_dir: &TempDir, ) -> Result { debug!( "Converting IPA to XCArchive structure: {}", @@ -157,83 +158,177 @@ fn copy_dsyms(dsym_paths: &[&Path], xcarchive_dir: &Path) -> Result<()> { std::fs::create_dir(&dsyms_dir)?; for dsym_input in dsym_paths { - for dsym_path in resolve_dsym_bundles(dsym_input)? { - let bundle_name = dsym_path - .file_name() - .ok_or_else(|| anyhow!("dSYM path has no bundle name: {}", dsym_path.display()))?; - let destination = dsyms_dir.join(bundle_name); - if destination.exists() { - bail!( - "Cannot include multiple dSYM bundles named {}", - bundle_name.to_string_lossy() - ); - } - - for entry in WalkDir::new(&dsym_path) { - let entry = entry.with_context(|| { - format!("Failed to read dSYM bundle {}", dsym_path.display()) - })?; - let relative_path = entry.path().strip_prefix(&dsym_path)?; - let target_path = destination.join(relative_path); - - if entry.file_type().is_dir() { - std::fs::create_dir_all(&target_path)?; - } else if entry.file_type().is_file() { - std::fs::copy(entry.path(), &target_path).with_context(|| { - format!( - "Failed to copy dSYM file {} to {}", - entry.path().display(), - target_path.display() - ) - })?; - } else if entry.file_type().is_symlink() { - let link_target = std::fs::read_link(entry.path())?; - std::os::unix::fs::symlink(link_target, &target_path)?; - } - } - } + copy_dsym_input(dsym_input, &dsyms_dir)?; } Ok(()) } -fn resolve_dsym_bundles(path: &Path) -> Result> { - if is_dsym_bundle(path) { - return Ok(vec![path.to_owned()]); +fn copy_dsym_input(dsym_input: &Path, dsyms_dir: &Path) -> Result<()> { + let metadata = match dsym_input.symlink_metadata() { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + bail!("dSYM path does not exist: {}", dsym_input.display()); + } + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to access dSYM path {}", dsym_input.display())); + } + }; + + if metadata.file_type().is_symlink() { + bail!("dSYM paths cannot be symlinks: {}", dsym_input.display()); + } + + let extracted = if metadata.is_file() { + Some(extract_dsym_zip(dsym_input)?) + } else if metadata.is_dir() { + None + } else { + bail!( + "dSYM path must be a .dSYM bundle, a directory containing dSYM bundles, or a ZIP archive: {}", + dsym_input.display() + ); + }; + + let root = extracted + .as_ref() + .map_or(dsym_input, |temp_dir| temp_dir.path()); + let bundles = discover_dsym_bundles(root, extracted.is_some())?; + if bundles.is_empty() { + let input_kind = if extracted.is_some() { + "ZIP archive" + } else { + "directory" + }; + bail!( + "No .dSYM bundles found in {input_kind}: {}", + dsym_input.display() + ); } - if !path.is_dir() { + for dsym_path in bundles { + copy_dsym_bundle(&dsym_path, dsyms_dir)?; + } + + Ok(()) +} + +fn copy_dsym_bundle(dsym_path: &Path, dsyms_dir: &Path) -> Result<()> { + let bundle_name = dsym_path + .file_name() + .ok_or_else(|| anyhow!("dSYM path has no bundle name: {}", dsym_path.display()))?; + let destination = dsyms_dir.join(bundle_name); + if destination.exists() { bail!( - "dSYM path must be a .dSYM bundle or a directory containing .dSYM bundles: {}", - path.display() + "Cannot include multiple dSYM bundles named {}", + bundle_name.to_string_lossy() ); } + debug!( + "Including dSYM bundle in IPA upload: {}", + dsym_path.display() + ); + + for entry in WalkDir::new(dsym_path) { + let entry = + entry.with_context(|| format!("Failed to read dSYM bundle {}", dsym_path.display()))?; + let relative_path = entry.path().strip_prefix(dsym_path)?; + let target_path = destination.join(relative_path); + + if entry.file_type().is_dir() { + std::fs::create_dir_all(&target_path)?; + } else if entry.file_type().is_file() { + std::fs::copy(entry.path(), &target_path).with_context(|| { + format!( + "Failed to copy dSYM file {} to {}", + entry.path().display(), + target_path.display() + ) + })?; + } else if entry.file_type().is_symlink() { + bail!( + "Symlinks are not supported in dSYM bundles: {}", + entry.path().display() + ); + } + } + + Ok(()) +} + +fn extract_dsym_zip(path: &Path) -> Result { + let file = std::fs::File::open(path) + .with_context(|| format!("Failed to open dSYM ZIP {}", path.display()))?; + let mut archive = ZipArchive::new(file) + .with_context(|| format!("dSYM input is not a valid ZIP archive: {}", path.display()))?; + let temp_dir = TempDir::create()?; + for index in 0..archive.len() { + let mut entry = archive.by_index(index)?; + let entry_path = entry + .enclosed_name() + .ok_or_else(|| anyhow!("dSYM ZIP contains an unsafe path: {}", entry.name()))?; + + if entry.is_symlink() { + bail!( + "Symlinks are not supported in dSYM ZIP archives: {}", + entry.name() + ); + } + + let target_path = temp_dir.path().join(entry_path); + if entry.is_dir() { + std::fs::create_dir_all(&target_path)?; + } else { + if let Some(parent) = target_path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut target_file = std::fs::File::create(&target_path)?; + std::io::copy(&mut entry, &mut target_file)?; + } + } + + Ok(temp_dir) +} + +fn discover_dsym_bundles(path: &Path, allow_wrapper: bool) -> Result> { + if has_dsym_extension(path) { + return Ok(vec![path.to_owned()]); + } + let mut bundles = Vec::new(); + let mut directories = Vec::new(); for entry in std::fs::read_dir(path) .with_context(|| format!("Failed to read dSYM directory {}", path.display()))? { let entry = entry.with_context(|| format!("Failed to read dSYM directory {}", path.display()))?; let entry_path = entry.path(); - if is_dsym_bundle(&entry_path) { - bundles.push(entry_path); + let file_type = entry.file_type()?; + if file_type.is_symlink() && has_dsym_extension(&entry_path) { + bail!("dSYM paths cannot be symlinks: {}", entry_path.display()); + } + if file_type.is_dir() { + if has_dsym_extension(&entry_path) { + bundles.push(entry_path); + } else { + directories.push(entry_path); + } } } - if bundles.is_empty() { - bail!("No .dSYM bundles found in directory: {}", path.display()); + if bundles.is_empty() && allow_wrapper && directories.len() == 1 { + return discover_dsym_bundles(&directories[0], false); } Ok(bundles) } -fn is_dsym_bundle(path: &Path) -> bool { - path.is_dir() - && path - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("dsym")) +fn has_dsym_extension(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("dsym")) } static PATTERN: LazyLock = @@ -253,3 +348,195 @@ fn extract_app_name_from_ipa<'a>(archive: &'a ZipArchive>) -> Resu Err(anyhow!("IPA did not contain exactly one .app.")) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + use std::os::unix::fs::symlink; + use zip::write::SimpleFileOptions; + use zip::ZipWriter; + + fn create_dsym(root: &Path, name: &str, contents: &str) -> Result { + let bundle = root.join(name); + std::fs::create_dir_all(&bundle)?; + std::fs::write(bundle.join("symbols"), contents)?; + Ok(bundle) + } + + fn create_dsym_zip(path: &Path, wrapper: Option<&str>, bundles: &[(&str, &str)]) -> Result<()> { + let mut archive = ZipWriter::new(std::fs::File::create(path)?); + for (name, contents) in bundles { + let entry = match wrapper { + Some(wrapper) => format!("{wrapper}/{name}/symbols"), + None => format!("{name}/symbols"), + }; + archive.start_file(entry, SimpleFileOptions::default())?; + archive.write_all(contents.as_bytes())?; + } + archive.finish()?; + Ok(()) + } + + fn create_output_dir(root: &Path, name: &str) -> Result { + let output = root.join(name); + std::fs::create_dir(&output)?; + Ok(output) + } + + #[test] + fn copy_dsyms_accepts_bundle_and_directory_inputs() -> Result<()> { + let temp_dir = TempDir::create()?; + let direct = create_dsym(temp_dir.path(), "DemoApp.app.dSYM", "app symbols")?; + let symbols_dir = temp_dir.path().join("Symbols"); + create_dsym( + &symbols_dir, + "DemoFramework.framework.dSYM", + "framework symbols", + )?; + std::fs::write(symbols_dir.join("README.txt"), "ignored")?; + let xcarchive = create_output_dir(temp_dir.path(), "archive.xcarchive")?; + + copy_dsyms(&[direct.as_path(), symbols_dir.as_path()], &xcarchive)?; + + let output = xcarchive.join("dSYMs"); + assert_eq!( + std::fs::read_to_string(output.join("DemoApp.app.dSYM/symbols"))?, + "app symbols" + ); + assert_eq!( + std::fs::read_to_string(output.join("DemoFramework.framework.dSYM/symbols"))?, + "framework symbols" + ); + assert!(!output.join("README.txt").exists()); + Ok(()) + } + + #[test] + fn copy_dsyms_accepts_supported_zip_layouts() -> Result<()> { + let temp_dir = TempDir::create()?; + let bundle_zip = temp_dir.path().join("bundle.zip"); + create_dsym_zip(&bundle_zip, None, &[("DemoApp.app.dSYM", "app symbols")])?; + let directory_zip = temp_dir.path().join("directory.zip"); + create_dsym_zip( + &directory_zip, + Some("dSYMs"), + &[("DemoFramework.framework.dSYM", "framework symbols")], + )?; + let xcarchive = create_output_dir(temp_dir.path(), "archive.xcarchive")?; + + copy_dsyms(&[bundle_zip.as_path(), directory_zip.as_path()], &xcarchive)?; + + let output = xcarchive.join("dSYMs"); + assert_eq!( + std::fs::read_to_string(output.join("DemoApp.app.dSYM/symbols"))?, + "app symbols" + ); + assert_eq!( + std::fs::read_to_string(output.join("DemoFramework.framework.dSYM/symbols"))?, + "framework symbols" + ); + Ok(()) + } + + #[test] + fn copy_dsym_input_rejects_missing_input() -> Result<()> { + let temp_dir = TempDir::create()?; + let output = create_output_dir(temp_dir.path(), "output")?; + let error = copy_dsym_input(&temp_dir.path().join("missing.dSYM"), &output).unwrap_err(); + assert!(format!("{error:#}").contains("dSYM path does not exist")); + Ok(()) + } + + #[test] + fn copy_dsym_input_rejects_inputs_without_dsyms() -> Result<()> { + let temp_dir = TempDir::create()?; + let empty_directory = create_output_dir(temp_dir.path(), "empty")?; + let output = create_output_dir(temp_dir.path(), "directory-output")?; + let error = copy_dsym_input(&empty_directory, &output).unwrap_err(); + assert!(format!("{error:#}").contains("No .dSYM bundles found in directory")); + + let empty_zip = temp_dir.path().join("empty.zip"); + ZipWriter::new(std::fs::File::create(&empty_zip)?).finish()?; + let output = create_output_dir(temp_dir.path(), "zip-output")?; + let error = copy_dsym_input(&empty_zip, &output).unwrap_err(); + assert!(format!("{error:#}").contains("No .dSYM bundles found in ZIP archive")); + Ok(()) + } + + #[test] + fn copy_dsym_input_rejects_invalid_zip() -> Result<()> { + let temp_dir = TempDir::create()?; + let zip = temp_dir.path().join("invalid.zip"); + std::fs::write(&zip, "not a ZIP")?; + let output = create_output_dir(temp_dir.path(), "output")?; + let error = copy_dsym_input(&zip, &output).unwrap_err(); + assert!(format!("{error:#}").contains("dSYM input is not a valid ZIP archive")); + Ok(()) + } + + #[test] + fn copy_dsym_input_rejects_unsafe_zip_entries() -> Result<()> { + let temp_dir = TempDir::create()?; + let traversal_zip = temp_dir.path().join("traversal.zip"); + let mut archive = ZipWriter::new(std::fs::File::create(&traversal_zip)?); + archive.start_file("../DemoApp.app.dSYM/symbols", SimpleFileOptions::default())?; + archive.write_all(b"symbols")?; + archive.finish()?; + let output = create_output_dir(temp_dir.path(), "traversal-output")?; + let error = copy_dsym_input(&traversal_zip, &output).unwrap_err(); + assert!(format!("{error:#}").contains("dSYM ZIP contains an unsafe path")); + + let symlink_zip = temp_dir.path().join("symlink.zip"); + let mut archive = ZipWriter::new(std::fs::File::create(&symlink_zip)?); + archive.add_symlink( + "DemoApp.app.dSYM/symbols", + "../symbols", + SimpleFileOptions::default(), + )?; + archive.finish()?; + let output = create_output_dir(temp_dir.path(), "symlink-output")?; + let error = copy_dsym_input(&symlink_zip, &output).unwrap_err(); + assert!(format!("{error:#}").contains("Symlinks are not supported in dSYM ZIP archives")); + Ok(()) + } + + #[test] + fn copy_dsym_input_rejects_symlink() -> Result<()> { + let temp_dir = TempDir::create()?; + let bundle = create_dsym(temp_dir.path(), "DemoApp.app.dSYM", "symbols")?; + let link = temp_dir.path().join("DemoAppAlias.app.dSYM"); + symlink(bundle, &link)?; + let output = create_output_dir(temp_dir.path(), "output")?; + let error = copy_dsym_input(&link, &output).unwrap_err(); + assert!(format!("{error:#}").contains("dSYM paths cannot be symlinks")); + Ok(()) + } + + #[test] + fn copy_dsym_bundle_rejects_internal_symlink() -> Result<()> { + let temp_dir = TempDir::create()?; + let bundle = create_dsym(temp_dir.path(), "DemoApp.app.dSYM", "symbols")?; + symlink("symbols", bundle.join("symbols-link"))?; + let output = create_output_dir(temp_dir.path(), "output")?; + let error = copy_dsym_bundle(&bundle, &output).unwrap_err(); + assert!(format!("{error:#}").contains("Symlinks are not supported in dSYM bundles")); + Ok(()) + } + + #[test] + fn copy_dsyms_rejects_duplicate_bundle_names() -> Result<()> { + let temp_dir = TempDir::create()?; + let first = create_dsym(&temp_dir.path().join("first"), "DemoApp.app.dSYM", "first")?; + let second = create_dsym( + &temp_dir.path().join("second"), + "DemoApp.app.dSYM", + "second", + )?; + let xcarchive = create_output_dir(temp_dir.path(), "archive.xcarchive")?; + let error = copy_dsyms(&[first.as_path(), second.as_path()], &xcarchive).unwrap_err(); + assert!(format!("{error:#}") + .contains("Cannot include multiple dSYM bundles named DemoApp.app.dSYM")); + Ok(()) + } +} diff --git a/tests/integration/_cases/build/build-upload-help-macos.trycmd b/tests/integration/_cases/build/build-upload-help-macos.trycmd index f51697cbec..0e6d9b9735 100644 --- a/tests/integration/_cases/build/build-upload-help-macos.trycmd +++ b/tests/integration/_cases/build/build-upload-help-macos.trycmd @@ -86,6 +86,10 @@ Options: The install group(s) for this build. Can be specified multiple times. Builds with at least one matching install group will be shown updates for each other. + --dsym + Path to a dSYM bundle, a directory containing dSYM bundles, or a ZIP of either to include + with an IPA upload. Can be specified multiple times. + -h, --help Print help (see a summary with '-h') diff --git a/tests/integration/_cases/build/build-upload-ipa-with-dsym.trycmd b/tests/integration/_cases/build/build-upload-ipa-with-dsym.trycmd new file mode 100644 index 0000000000..783b2b58a1 --- /dev/null +++ b/tests/integration/_cases/build/build-upload-ipa-with-dsym.trycmd @@ -0,0 +1,7 @@ +``` +$ sentry-cli build upload tests/integration/_fixtures/build/ipa.ipa --dsym tests/integration/_fixtures/build/dSYMs --head-sha deadbeef12345678deadbeef12345678deadbeef +? success +Successfully uploaded 1 file to Sentry + - tests/integration/_fixtures/build/ipa.ipa (http://sentry.io/wat-org/preprod/wat-project/some-text-id) + +``` diff --git a/tests/integration/_fixtures/build/dSYMs/DemoApp.app.dSYM/Contents/Resources/DWARF/DemoApp b/tests/integration/_fixtures/build/dSYMs/DemoApp.app.dSYM/Contents/Resources/DWARF/DemoApp new file mode 100644 index 0000000000..e46b6d223c --- /dev/null +++ b/tests/integration/_fixtures/build/dSYMs/DemoApp.app.dSYM/Contents/Resources/DWARF/DemoApp @@ -0,0 +1 @@ +integration test debug symbols diff --git a/tests/integration/build/upload.rs b/tests/integration/build/upload.rs index feef5d4067..862995e9b9 100644 --- a/tests/integration/build/upload.rs +++ b/tests/integration/build/upload.rs @@ -1,3 +1,5 @@ +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +use std::io::Read as _; use std::sync::atomic::{AtomicBool, Ordering}; use crate::integration::test_utils::chunk_upload; @@ -69,6 +71,21 @@ fn command_build_upload_invalid_xcarchive() { .run_and_assert(AssertCommand::Failure); } +#[test] +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +fn command_build_upload_rejects_dsyms_with_xcarchive() { + TestManager::new() + .assert_cmd([ + "build", + "upload", + "tests/integration/_fixtures/build/archive.xcarchive", + "--dsym", + "tests/integration/_fixtures/build/dSYMs", + ]) + .with_default_token() + .run_and_assert(AssertCommand::Failure); +} + #[test] fn command_build_upload_invalid_ipa() { TestManager::new() @@ -268,6 +285,81 @@ fn command_build_upload_ipa_chunked() { .with_default_token(); } +#[test] +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +fn command_build_upload_ipa_with_dsym() { + ipa_with_dsym_test_manager() + .register_trycmd_test("build/build-upload-ipa-with-dsym.trycmd") + .env("SENTRY_CLI_INTEGRATION_TEST_VERSION_OVERRIDE", "0.0.0-test") + .with_default_token(); +} + +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +fn ipa_with_dsym_test_manager() -> TestManager { + let is_first_assemble_call = AtomicBool::new(true); + + TestManager::new() + .mock_endpoint( + MockEndpointBuilder::new("GET", "/api/0/organizations/wat-org/chunk-upload/") + .with_response_file("build/get-chunk-upload.json"), + ) + .mock_endpoint( + MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/chunk-upload/") + .with_response_fn(move |request| { + let boundary = chunk_upload::boundary_from_request(request) + .expect("content-type header should be a valid multipart/form-data header"); + let body = request.body().expect("body should be readable"); + let decompressed = chunk_upload::decompress_chunks(body, boundary) + .expect("chunks should be valid gzip data"); + + assert_eq!(decompressed.len(), 1, "expected exactly one chunk"); + + let chunk = decompressed.first().unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(chunk)) + .expect("chunk should be a valid zip"); + let mut dsym = archive + .by_name( + "archive.xcarchive/dSYMs/DemoApp.app.dSYM/Contents/Resources/DWARF/DemoApp", + ) + .expect("uploaded archive should contain the dSYM"); + let mut contents = String::new(); + dsym.read_to_string(&mut contents) + .expect("dSYM contents should be readable"); + assert_eq!(contents, "integration test debug symbols\n"); + + vec![] + }), + ) + .mock_endpoint( + MockEndpointBuilder::new( + "POST", + "/api/0/projects/wat-org/wat-project/files/preprodartifacts/assemble/", + ) + .with_header_matcher("content-type", "application/json") + .with_response_fn(move |request| { + if is_first_assemble_call.swap(false, Ordering::Relaxed) { + let body = request.body().expect("body should be readable"); + let request: serde_json::Value = + serde_json::from_slice(body).expect("body should be valid JSON"); + serde_json::json!({ + "state": "created", + "missingChunks": request["chunks"] + }) + .to_string() + } else { + serde_json::json!({ + "state": "ok", + "missingChunks": [], + "artifactUrl": "http://sentry.io/wat-org/preprod/wat-project/some-text-id" + }) + .to_string() + } + .into() + }) + .expect(2), + ) +} + #[test] fn command_build_upload_empty_shas() { TestManager::new() From 0e998bdb2bb9c74ece27e741ee6cd5ffeb8545f0 Mon Sep 17 00:00:00 2001 From: Jamie <2119834+jamieQ@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:24:48 -0500 Subject: [PATCH 3/4] docs(changelog): Add dSYM upload entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb5cede05..d2b9788df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- (build) Add dSYM support to IPA uploads ([#3393](https://github.com/getsentry/sentry-cli/pull/3393)) + ### Fixes - (logs) Correct the severity query example ([#3387](https://github.com/getsentry/sentry-cli/pull/3387)) From d476dc91bb4c427706d6087528e84c1b38406c2b Mon Sep 17 00:00:00 2001 From: Jamie <2119834+jamieQ@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:15:13 -0500 Subject: [PATCH 4/4] fix(build): Ignore macOS metadata in dSYM ZIPs --- src/utils/build/apple.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/utils/build/apple.rs b/src/utils/build/apple.rs index cd5309e6cb..37624c8ee7 100644 --- a/src/utils/build/apple.rs +++ b/src/utils/build/apple.rs @@ -277,6 +277,11 @@ fn extract_dsym_zip(path: &Path) -> Result { ); } + // Ignore common archive metadata so it does not affect dSYM layout discovery. + if !zip::read::root_dir_common_filter(&entry_path) { + continue; + } + let target_path = temp_dir.path().join(entry_path); if entry.is_dir() { std::fs::create_dir_all(&target_path)?; @@ -439,6 +444,32 @@ mod tests { Ok(()) } + #[test] + fn copy_dsyms_ignores_macos_metadata_in_zip() -> Result<()> { + let temp_dir = TempDir::create()?; + let zip = temp_dir.path().join("symbols.zip"); + let mut archive = ZipWriter::new(std::fs::File::create(&zip)?); + archive.start_file( + "dSYMs/DemoApp.app.dSYM/symbols", + SimpleFileOptions::default(), + )?; + archive.write_all(b"symbols")?; + archive.start_file( + "__MACOSX/dSYMs/DemoApp.app.dSYM/._symbols", + SimpleFileOptions::default(), + )?; + archive.write_all(b"metadata")?; + archive.finish()?; + let xcarchive = create_output_dir(temp_dir.path(), "archive.xcarchive")?; + + copy_dsyms(&[zip.as_path()], &xcarchive)?; + + let output = xcarchive.join("dSYMs/DemoApp.app.dSYM"); + assert_eq!(std::fs::read_to_string(output.join("symbols"))?, "symbols"); + assert!(!output.join("._symbols").exists()); + Ok(()) + } + #[test] fn copy_dsym_input_rejects_missing_input() -> Result<()> { let temp_dir = TempDir::create()?;