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)) diff --git a/src/commands/build/upload.rs b/src/commands/build/upload.rs index 8b058566c0..acf9eadad3 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,8 @@ pub fn make_command(command: Command) -> Command { .num_args(1..) .action(ArgAction::Append) .required(true), - ) + ); + let command = command .git_metadata_args() .arg( Arg::new("build_configuration") @@ -68,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. @@ -104,6 +116,16 @@ 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(not(all(target_os = "macos", target_arch = "aarch64")))] + let dsym_paths = Vec::<&Path>::new(); + + 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()); @@ -167,10 +189,15 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { handle_file( path, &byteview, + &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( || { @@ -260,17 +287,25 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { fn handle_file( path: &Path, byteview: &ByteView, + _dsym_paths: &[&Path], plugin_name: Option<&str>, plugin_version: Option<&str>, ) -> Result { - // 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)? { - debug!("Converting IPA file to XCArchive structure"); - let archive_temp_dir = TempDir::create()?; - return ipa_to_xcarchive(path, byteview, &archive_temp_dir) - .and_then(|path| handle_directory(&path, plugin_name, plugin_version)) - .with_context(|| format!("Failed to process IPA file {}", path.display())); + { + 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 + 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(|| { @@ -281,6 +316,15 @@ fn handle_file( }) } +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"); + } + Ok(()) +} + fn validate_is_supported_build(path: &Path, bytes: &[u8]) -> Result<()> { debug!("Validating build format for: {}", path.display()); @@ -549,7 +593,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 +617,64 @@ mod tests { 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] 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..37624c8ee7 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::{ @@ -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 /// @@ -71,7 +72,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], + dsym_paths: &[&Path], + temp_dir: &TempDir, +) -> Result { debug!( "Converting IPA to XCArchive structure: {}", ipa_path.display() @@ -134,6 +140,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 +149,193 @@ 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 { + copy_dsym_input(dsym_input, &dsyms_dir)?; + } + + Ok(()) +} + +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() + ); + } + + 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!( + "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() + ); + } + + // 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)?; + } 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(); + 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() && allow_wrapper && directories.len() == 1 { + return discover_dsym_bundles(&directories[0], false); + } + + Ok(bundles) +} + +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 = LazyLock::new(|| Regex::new(r"^Payload/([^/]+)\.app/Info\.plist$").expect("regex is valid")); @@ -158,3 +353,221 @@ 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_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()?; + 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()