diff --git a/src/commands/mobile_app/upload.rs b/src/commands/mobile_app/upload.rs index 12de0fe033..233a0a8cc6 100644 --- a/src/commands/mobile_app/upload.rs +++ b/src/commands/mobile_app/upload.rs @@ -24,7 +24,9 @@ use crate::utils::fs::get_sha1_checksums; use crate::utils::fs::TempFile; #[cfg(target_os = "macos")] use crate::utils::mobile_app::handle_asset_catalogs; -use crate::utils::mobile_app::{is_aab_file, is_apk_file, is_apple_app, is_zip_file}; +use crate::utils::mobile_app::{ + ipa_to_xcarchive, is_aab_file, is_apk_file, is_apple_app, is_ipa_file, is_zip_file, +}; use crate::utils::progress::ProgressBar; use crate::utils::vcs; @@ -36,7 +38,7 @@ pub fn make_command(command: Command) -> Command { .arg( Arg::new("paths") .value_name("PATH") - .help("The path to the mobile app files to upload. Supported files include Apk, Aab or XCArchive.") + .help("The path to the mobile app files to upload. Supported files include Apk, Aab, XCArchive, or IPA.") .num_args(1..) .action(ArgAction::Append) .required(true), @@ -95,12 +97,29 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { let normalized_zip = if path.is_file() { debug!("Normalizing file: {}", path.display()); - normalize_file(path, &byteview).with_context(|| { - format!( - "Failed to generate uploadable bundle for file {}", - path.display() - ) - })? + + // Handle IPA files by converting them to XCArchive + if is_zip_file(&byteview) && is_ipa_file(&byteview)? { + debug!("Converting IPA file to XCArchive structure"); + let temp_dir = crate::utils::fs::TempDir::create()?; + let xcarchive_path = + ipa_to_xcarchive(path, &byteview, &temp_dir).with_context(|| { + format!( + "Failed to convert IPA to XCArchive for file {}", + path.display() + ) + })?; + normalize_directory(&xcarchive_path).with_context(|| { + format!("Failed to normalize XCArchive for file {}", path.display()) + })? + } else { + normalize_file(path, &byteview).with_context(|| { + format!( + "Failed to generate uploadable bundle for file {}", + path.display() + ) + })? + } } else if path.is_dir() { debug!("Normalizing directory: {}", path.display()); normalize_directory(path).with_context(|| { @@ -186,9 +205,9 @@ fn validate_is_mobile_app(path: &Path, bytes: &[u8]) -> Result<()> { return Ok(()); } - // Check if the file is a zip file (then AAB or APK) + // Check if the file is a zip file (then AAB, APK, or IPA) if is_zip_file(bytes) { - debug!("File is a zip, checking for AAB/APK format"); + debug!("File is a zip, checking for AAB/APK/IPA format"); if is_aab_file(bytes)? { debug!("Detected AAB file"); return Ok(()); @@ -198,11 +217,16 @@ fn validate_is_mobile_app(path: &Path, bytes: &[u8]) -> Result<()> { debug!("Detected APK file"); return Ok(()); } + + if is_ipa_file(bytes)? { + debug!("Detected IPA file"); + return Ok(()); + } } debug!("File format validation failed"); Err(anyhow!( - "File is not a recognized mobile app format (APK, AAB, or XCArchive): {}", + "File is not a recognized mobile app format (APK, AAB, XCArchive, or IPA): {}", path.display() )) } diff --git a/src/utils/mobile_app/ipa.rs b/src/utils/mobile_app/ipa.rs new file mode 100644 index 0000000000..ba1c7f8850 --- /dev/null +++ b/src/utils/mobile_app/ipa.rs @@ -0,0 +1,135 @@ +#![cfg(feature = "unstable-mobile-app")] + +use anyhow::{anyhow, Result}; +use log::debug; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use zip::ZipArchive; + +use crate::utils::fs::TempDir; + +/// Converts an IPA file to an XCArchive directory structure. The provided IPA must be a valid IPA file. +/// +/// # Format Overview +/// +/// ## IPA (iOS App Store Package) +/// An IPA file is a compressed archive containing an iOS app ready for distribution. +/// It has the following structure: +/// ``` +/// MyApp.ipa +/// └── Payload/ +/// └── MyApp.app/ +/// ├── Info.plist +/// ├── MyApp (executable) +/// ├── Assets.car +/// └── ... (other app resources) +/// ``` +/// +/// ## XCArchive (Xcode Archive) +/// An XCArchive is a directory structure created by Xcode when archiving an app for distribution. +/// It has the following structure: +/// ``` +/// MyApp.xcarchive/ +/// ├── Info.plist +/// ├── Products/ +/// │ └── Applications/ +/// │ └── MyApp.app/ +/// │ ├── Info.plist +/// │ ├── MyApp (executable) +/// │ ├── Assets.car +/// │ └── ... (other app resources) +/// └── ... (other archive metadata) +/// ``` +/// +/// # Transformation Process +/// +/// This function performs the following steps: +/// 1. Creates the XCArchive directory structure (`archive.xcarchive/Products/Applications/`) +/// 2. Extracts the app name from the IPA by finding the shortest path ending with `.app/Info.plist` +/// 3. Extracts all files from the IPA's `Payload/` directory into the XCArchive structure +/// 4. Creates an `Info.plist` file for the XCArchive with the app path reference +/// 5. Returns the path to the XCArchive directory structure +pub fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8], temp_dir: &TempDir) -> Result { + debug!( + "Converting IPA to XCArchive structure: {}", + ipa_path.display() + ); + + let xcarchive_dir = temp_dir.path().join("archive.xcarchive"); + let products_dir = xcarchive_dir.join("Products"); + let applications_dir = products_dir.join("Applications"); + + debug!("Creating XCArchive directory structure"); + std::fs::create_dir_all(&applications_dir)?; + + // Extract IPA file + let cursor = Cursor::new(ipa_bytes); + let mut ipa_archive = ZipArchive::new(cursor)?; + + let app_name = extract_app_name_from_ipa(&ipa_archive)?; + + log::debug!("Extracted app name: {}", app_name); + + // Extract all files from the archive + for i in 0..ipa_archive.len() { + let mut file = ipa_archive.by_index(i)?; + + if let Some(name) = file.enclosed_name() { + if let Ok(stripped) = name.strip_prefix("Payload/") { + if !file.is_dir() { + // Create the file path in the XCArchive structure + let target_path = applications_dir.join(stripped); + + // Create parent directories if necessary + if let Some(parent) = target_path.parent() { + std::fs::create_dir_all(parent)?; + } + + // Extract file + let mut target_file = std::fs::File::create(&target_path)?; + std::io::copy(&mut file, &mut target_file)?; + } + } + } + } + + // Create Info.plist for XCArchive + let info_plist_path = xcarchive_dir.join("Info.plist"); + + let info_plist_content = format!( + r#" + + + + ApplicationProperties + + ApplicationPath + Applications/{app_name}.app + + ArchiveVersion + 1 + +"# + ); + + std::fs::write(&info_plist_path, info_plist_content)?; + + debug!( + "Created XCArchive Info.plist at: {}", + info_plist_path.display() + ); + Ok(xcarchive_dir) +} + +fn extract_app_name_from_ipa(archive: &ZipArchive>) -> Result { + log::debug!("app names: {:?}", archive.file_names().collect::>()); + archive + .file_names() + .filter(|name| name.starts_with("Payload/") && name.ends_with(".app/Info.plist")) + .min_by_key(|name| name.len()) + .and_then(|name| name.strip_prefix("Payload/")) + .and_then(|name| name.split('/').next()) + .and_then(|name| name.strip_suffix(".app")) + .map(|name| name.to_owned()) + .ok_or_else(|| anyhow!("No .app found in IPA")) +} diff --git a/src/utils/mobile_app/mod.rs b/src/utils/mobile_app/mod.rs index f44b25c593..a14adee5f3 100644 --- a/src/utils/mobile_app/mod.rs +++ b/src/utils/mobile_app/mod.rs @@ -2,8 +2,10 @@ #[cfg(target_os = "macos")] mod apple; +mod ipa; mod validation; #[cfg(target_os = "macos")] pub use self::apple::handle_asset_catalogs; -pub use self::validation::{is_aab_file, is_apk_file, is_apple_app, is_zip_file}; +pub use self::ipa::ipa_to_xcarchive; +pub use self::validation::{is_aab_file, is_apk_file, is_apple_app, is_ipa_file, is_zip_file}; diff --git a/src/utils/mobile_app/validation.rs b/src/utils/mobile_app/validation.rs index f17a20729f..e714db1825 100644 --- a/src/utils/mobile_app/validation.rs +++ b/src/utils/mobile_app/validation.rs @@ -37,6 +37,19 @@ pub fn is_aab_file(bytes: &[u8]) -> Result { Ok(has_bundle_config && has_base_manifest) } +pub fn is_ipa_file(bytes: &[u8]) -> Result { + let cursor = std::io::Cursor::new(bytes); + let archive = zip::ZipArchive::new(cursor)?; + + let is_ipa = archive.file_names().any(|name| { + name.starts_with("Payload/") + && name.ends_with(".app/Info.plist") + && name.matches('/').count() == 2 + }); + + Ok(is_ipa) +} + pub fn is_xcarchive_directory

(path: P) -> bool where P: AsRef, diff --git a/tests/integration/_cases/mobile_app/mobile_app-upload-help.trycmd b/tests/integration/_cases/mobile_app/mobile_app-upload-help.trycmd index cf169aabc9..95f6104f03 100644 --- a/tests/integration/_cases/mobile_app/mobile_app-upload-help.trycmd +++ b/tests/integration/_cases/mobile_app/mobile_app-upload-help.trycmd @@ -6,8 +6,8 @@ $ sentry-cli mobile-app upload --help Usage: sentry-cli[EXE] mobile-app upload [OPTIONS] ... Arguments: - ... The path to the mobile app files to upload. Supported files include Apk, Aab or - XCArchive. + ... The path to the mobile app files to upload. Supported files include Apk, Aab, + XCArchive, or IPA. Options: -o, --org diff --git a/tests/integration/_cases/mobile_app/mobile_app-upload-ipa.trycmd b/tests/integration/_cases/mobile_app/mobile_app-upload-ipa.trycmd new file mode 100644 index 0000000000..476b528f5c --- /dev/null +++ b/tests/integration/_cases/mobile_app/mobile_app-upload-ipa.trycmd @@ -0,0 +1,7 @@ +``` +$ sentry-cli mobile-app upload tests/integration/_fixtures/mobile_app/ipa.ipa --sha test_sha +? success +Successfully uploaded 1 file to Sentry + - tests/integration/_fixtures/mobile_app/ipa.ipa + +``` diff --git a/tests/integration/_fixtures/mobile_app/invalid.ipa b/tests/integration/_fixtures/mobile_app/invalid.ipa new file mode 100644 index 0000000000..ff7b29f215 --- /dev/null +++ b/tests/integration/_fixtures/mobile_app/invalid.ipa @@ -0,0 +1 @@ +invalid ipa content diff --git a/tests/integration/_fixtures/mobile_app/ipa.ipa b/tests/integration/_fixtures/mobile_app/ipa.ipa new file mode 100644 index 0000000000..d35fc4a7cf Binary files /dev/null and b/tests/integration/_fixtures/mobile_app/ipa.ipa differ diff --git a/tests/integration/_fixtures/mobile_app/unexpected.ipa b/tests/integration/_fixtures/mobile_app/unexpected.ipa new file mode 100644 index 0000000000..5474cceb6f Binary files /dev/null and b/tests/integration/_fixtures/mobile_app/unexpected.ipa differ diff --git a/tests/integration/mobile_app/upload.rs b/tests/integration/mobile_app/upload.rs index 3694429478..e10eac9c3a 100644 --- a/tests/integration/mobile_app/upload.rs +++ b/tests/integration/mobile_app/upload.rs @@ -55,6 +55,18 @@ fn command_mobile_app_upload_invalid_xcarchive() { .run_and_assert(AssertCommand::Failure); } +#[test] +fn command_mobile_app_upload_invalid_ipa() { + TestManager::new() + .assert_cmd(vec![ + "mobile-app", + "upload", + "tests/integration/_fixtures/mobile_app/invalid.ipa", + ]) + .with_default_token() + .run_and_assert(AssertCommand::Failure); +} + #[test] fn command_mobile_app_upload_apk_all_uploaded() { TestManager::new() @@ -160,3 +172,57 @@ fn command_mobile_app_upload_apk_chunked() { .register_trycmd_test("mobile_app/mobile_app-upload-apk.trycmd") .with_default_token(); } + +#[test] +#[cfg(target_os = "macos")] +/// This test simulates a full chunk upload for an IPA file (with only one chunk). +/// It verifies that the Sentry CLI makes the expected API calls to the chunk upload endpoint +/// and that the data sent to the chunk upload endpoint is exactly as expected. +/// It also verifies that the correct calls are made to the assemble endpoint. +fn command_mobile_app_upload_ipa_chunked() { + 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("mobile_app/get-chunk-upload.json"), + ) + .mock_endpoint( + MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/chunk-upload/") + .with_response_fn(move |request| { + let content_type_headers = request.header("content-type"); + assert_eq!( + content_type_headers.len(), + 1, + "content-type header should be present exactly once, found {} times", + content_type_headers.len() + ); + vec![] // Client does not expect a response body + }), + ) + .mock_endpoint( + MockEndpointBuilder::new( + "POST", + "/api/0/projects/wat-org/wat-project/files/preprodartifacts/assemble/", + ) + .with_header_matcher("content-type", "application/json") + .with_matcher(r#"{"checksum":"ed9da71e3688261875db21b266da84ffe004a8a4","chunks":["ed9da71e3688261875db21b266da84ffe004a8a4"],"git_sha":"test_sha"}"#) + .with_response_fn(move |_| { + if is_first_assemble_call.swap(false, Ordering::Relaxed) { + r#"{ + "state": "created", + "missingChunks": ["ed9da71e3688261875db21b266da84ffe004a8a4"] + }"# + } else { + r#"{ + "state": "ok", + "missingChunks": [] + }"# + } + .into() + }) + .expect(2), + ) + .register_trycmd_test("mobile_app/mobile_app-upload-ipa.trycmd") + .with_default_token(); +}