From 2ba5dffb6a852d54ac95715e7d057b022ad7f7c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Jul 2025 01:38:57 +0000 Subject: [PATCH 1/7] Add support for uploading iOS IPA files in mobile-app command Co-authored-by: noah.martin --- IPA_SUPPORT_IMPLEMENTATION.md | 100 +++++++++++ src/commands/mobile_app/upload.rs | 161 +++++++++++++++++- src/utils/mobile_app/mod.rs | 2 +- src/utils/mobile_app/validation.rs | 26 +++ .../mobile_app/mobile_app-upload-help.trycmd | 4 +- .../_fixtures/mobile_app/invalid.ipa | 1 + tests/integration/mobile_app/upload.rs | 12 ++ 7 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 IPA_SUPPORT_IMPLEMENTATION.md create mode 100644 tests/integration/_fixtures/mobile_app/invalid.ipa diff --git a/IPA_SUPPORT_IMPLEMENTATION.md b/IPA_SUPPORT_IMPLEMENTATION.md new file mode 100644 index 0000000000..ae22f0276a --- /dev/null +++ b/IPA_SUPPORT_IMPLEMENTATION.md @@ -0,0 +1,100 @@ +# iOS IPA File Support Implementation + +This document summarizes the implementation of iOS .ipa file support in the sentry-cli mobile_app upload command. + +## Overview + +The mobile_app command now supports uploading iOS .ipa files in addition to the existing support for .apk, .aab, and .xcarchive files. When an .ipa file is provided, it is automatically converted to an xcarchive structure before uploading, allowing it to use the existing xcarchive upload infrastructure. + +## Changes Made + +### 1. File Format Detection (`src/utils/mobile_app/validation.rs`) + +- **Added `is_ipa_file()` function**: Detects .ipa files by checking for the presence of a `Payload/` directory containing a `.app` bundle within the ZIP structure +- **Updated module exports**: Added `is_ipa_file` to the public exports in `src/utils/mobile_app/mod.rs` + +### 2. IPA to XCArchive Conversion (`src/commands/mobile_app/upload.rs`) + +- **Added `ipa_to_xcarchive()` function**: Converts .ipa files to xcarchive structure by: + - Extracting the .app bundle from the `Payload/` directory + - Creating the required xcarchive directory structure: `archive.xcarchive/Products/Applications/` + - Copying the .app bundle to the appropriate location + - Extracting app metadata (bundle ID, version, etc.) from the app's Info.plist + - Generating a proper xcarchive Info.plist with required metadata + - Creating a zip file of the complete xcarchive structure + +- **Updated `normalize_file()` function**: Now detects .ipa files and automatically converts them using the new conversion function + +- **Updated `validate_is_mobile_app()` function**: Added validation for .ipa files alongside existing formats + +### 3. User Interface Updates + +- **Command help text**: Updated to include "IPA" in the list of supported file formats +- **Error messages**: Updated to mention .ipa files in validation error messages +- **Test documentation**: Updated help test case to reflect new .ipa support + +### 4. Testing + +- **Added test fixture**: Created `invalid.ipa` test fixture for testing invalid .ipa file handling +- **Added test case**: `command_mobile_app_upload_invalid_ipa()` test to verify proper error handling for invalid .ipa files + +## Technical Details + +### IPA File Structure + +An .ipa file is a ZIP archive containing: +- `Payload/` directory with a `.app` bundle +- `iTunesArtwork` (app icon) +- `iTunesMetadata.plist` (metadata) +- Additional metadata files + +### XCArchive Structure Created + +The conversion creates this structure: +``` +archive.xcarchive/ +├── Info.plist # Generated xcarchive metadata +└── Products/ + └── Applications/ + └── [AppName].app/ # Extracted from IPA Payload/ + ├── Info.plist # Original app Info.plist + ├── [executable] # App binary + └── [other app files] # All other app bundle contents +``` + +### Generated XCArchive Info.plist + +The conversion generates a standard xcarchive Info.plist containing: +- `ApplicationProperties` with app path, architectures, bundle ID, versions +- `ArchiveVersion` set to 1 +- `CreationDate` set to current timestamp +- `Name` and `SchemeName` derived from app name + +## Dependencies Used + +The implementation leverages existing dependencies: +- `zip` crate for archive handling +- `plist` crate for parsing and generating plist files +- `chrono` crate for timestamp generation + +## Backward Compatibility + +This change is fully backward compatible: +- All existing functionality for .apk, .aab, and .xcarchive files remains unchanged +- New .ipa support is additive and doesn't affect existing workflows +- Error messages are enhanced but maintain the same structure + +## Usage + +Users can now upload .ipa files directly: + +```bash +sentry-cli mobile-app upload path/to/MyApp.ipa +``` + +The CLI will automatically: +1. Detect the .ipa format +2. Convert it to xcarchive structure +3. Upload using the existing xcarchive upload mechanism + +This provides a seamless experience for iOS developers who have .ipa files but need to upload to Sentry via the mobile-app command. \ No newline at end of file diff --git a/src/commands/mobile_app/upload.rs b/src/commands/mobile_app/upload.rs index 91050cecdb..404a785591 100644 --- a/src/commands/mobile_app/upload.rs +++ b/src/commands/mobile_app/upload.rs @@ -24,7 +24,7 @@ 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::{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 +36,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), @@ -186,9 +186,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,19 +198,170 @@ 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() )) } +fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8]) -> Result { + debug!("Converting IPA to XCArchive structure: {}", ipa_path.display()); + + let temp_dir = crate::utils::fs::TempDir::create()?; + 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 = std::io::Cursor::new(ipa_bytes); + let mut ipa_archive = zip::ZipArchive::new(cursor)?; + + let mut app_name = String::new(); + let mut app_bundle_id = String::new(); + let mut app_version = String::new(); + let mut app_short_version = String::new(); + + // Extract .app from Payload/ directory + for i in 0..ipa_archive.len() { + let mut file = ipa_archive.by_index(i)?; + let file_path = file.name(); + + if file_path.starts_with("Payload/") && file_path.ends_with(".app/") { + // Extract app name from path like "Payload/MyApp.app/" + let app_folder_name = file_path + .strip_prefix("Payload/") + .unwrap() + .strip_suffix("/") + .unwrap(); + app_name = app_folder_name.strip_suffix(".app").unwrap().to_string(); + debug!("Found app: {}", app_name); + } + + if file_path.starts_with("Payload/") && !file.is_dir() { + // Create the file path in the XCArchive structure + let relative_path = file_path.strip_prefix("Payload/").unwrap(); + let target_path = applications_dir.join(relative_path); + + // Create parent directories + 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)?; + + // If this is Info.plist, extract bundle information + if relative_path.ends_with("/Info.plist") { + debug!("Extracting bundle info from Info.plist"); + if let Ok(info_plist_data) = std::fs::read(&target_path) { + if let Ok(plist) = plist::from_bytes::(&info_plist_data) { + if let Some(bundle_id) = plist.get("CFBundleIdentifier") + .and_then(|v| v.as_string()) { + app_bundle_id = bundle_id.to_string(); + } + if let Some(version) = plist.get("CFBundleVersion") + .and_then(|v| v.as_string()) { + app_version = version.to_string(); + } + if let Some(short_version) = plist.get("CFBundleShortVersionString") + .and_then(|v| v.as_string()) { + app_short_version = short_version.to_string(); + } + } + } + } + } + } + + if app_name.is_empty() { + app_name = "UnknownApp".to_string(); + } + if app_bundle_id.is_empty() { + app_bundle_id = "com.unknown.app".to_string(); + } + if app_version.is_empty() { + app_version = "1".to_string(); + } + if app_short_version.is_empty() { + app_short_version = "1.0".to_string(); + } + + debug!("App info - Name: {}, Bundle ID: {}, Version: {}, Short Version: {}", + app_name, app_bundle_id, app_version, app_short_version); + + // Create Info.plist for XCArchive + let info_plist_path = xcarchive_dir.join("Info.plist"); + let creation_date = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + + let info_plist_content = format!( + r#" + + + + ApplicationProperties + + ApplicationPath + Applications/{}.app + Architectures + + arm64 + + CFBundleIdentifier + {} + CFBundleShortVersionString + {} + CFBundleVersion + {} + SigningIdentity + Apple Development: Converted from IPA + Team + CONVERTED + + ArchiveVersion + 1 + CreationDate + {} + Name + {} + SchemeName + {} + +"#, + app_name, app_bundle_id, app_short_version, app_version, creation_date, app_name, app_name + ); + + std::fs::write(&info_plist_path, info_plist_content)?; + + debug!("Created XCArchive Info.plist at: {}", info_plist_path.display()); + + // Now create a zip file containing the XCArchive + debug!("Creating zip from XCArchive directory"); + normalize_directory(&xcarchive_dir) +} + // For APK and AAB files, we'll copy them directly into the zip +// For IPA files, we'll convert them to XCArchive structure first fn normalize_file(path: &Path, bytes: &[u8]) -> Result { debug!("Creating normalized zip for file: {}", path.display()); + // Check if this is an IPA file that needs conversion + if is_zip_file(bytes) && is_ipa_file(bytes)? { + debug!("Converting IPA file to XCArchive structure"); + return ipa_to_xcarchive(path, bytes); + } + let temp_file = TempFile::create()?; let mut zip = ZipWriter::new(temp_file.open()?); diff --git a/src/utils/mobile_app/mod.rs b/src/utils/mobile_app/mod.rs index f44b25c593..40e3464867 100644 --- a/src/utils/mobile_app/mod.rs +++ b/src/utils/mobile_app/mod.rs @@ -6,4 +6,4 @@ 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::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..d0bf847860 100644 --- a/src/utils/mobile_app/validation.rs +++ b/src/utils/mobile_app/validation.rs @@ -37,6 +37,32 @@ 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 mut archive = zip::ZipArchive::new(cursor)?; + + // IPA files must contain a Payload/ directory with a .app bundle inside + let mut has_payload = false; + let mut has_app_in_payload = false; + + for i in 0..archive.len() { + let file = archive.by_index(i)?; + let name = file.name(); + + if name.starts_with("Payload/") { + has_payload = true; + + // Check if there's a .app directory in Payload/ + if name.starts_with("Payload/") && name.ends_with(".app/") { + has_app_in_payload = true; + break; + } + } + } + + Ok(has_payload && has_app_in_payload) +} + 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/_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/mobile_app/upload.rs b/tests/integration/mobile_app/upload.rs index 3694429478..e905700646 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() From 46ebd0ff0ebfc67a9872e8a38aa161ce654d555a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Jul 2025 02:22:50 +0000 Subject: [PATCH 2/7] Changes from background composer bc-bea23efb-7a3f-4687-8444-c50797c152d5 --- IPA_SUPPORT_IMPLEMENTATION.md | 100 ---------------------------------- 1 file changed, 100 deletions(-) delete mode 100644 IPA_SUPPORT_IMPLEMENTATION.md diff --git a/IPA_SUPPORT_IMPLEMENTATION.md b/IPA_SUPPORT_IMPLEMENTATION.md deleted file mode 100644 index ae22f0276a..0000000000 --- a/IPA_SUPPORT_IMPLEMENTATION.md +++ /dev/null @@ -1,100 +0,0 @@ -# iOS IPA File Support Implementation - -This document summarizes the implementation of iOS .ipa file support in the sentry-cli mobile_app upload command. - -## Overview - -The mobile_app command now supports uploading iOS .ipa files in addition to the existing support for .apk, .aab, and .xcarchive files. When an .ipa file is provided, it is automatically converted to an xcarchive structure before uploading, allowing it to use the existing xcarchive upload infrastructure. - -## Changes Made - -### 1. File Format Detection (`src/utils/mobile_app/validation.rs`) - -- **Added `is_ipa_file()` function**: Detects .ipa files by checking for the presence of a `Payload/` directory containing a `.app` bundle within the ZIP structure -- **Updated module exports**: Added `is_ipa_file` to the public exports in `src/utils/mobile_app/mod.rs` - -### 2. IPA to XCArchive Conversion (`src/commands/mobile_app/upload.rs`) - -- **Added `ipa_to_xcarchive()` function**: Converts .ipa files to xcarchive structure by: - - Extracting the .app bundle from the `Payload/` directory - - Creating the required xcarchive directory structure: `archive.xcarchive/Products/Applications/` - - Copying the .app bundle to the appropriate location - - Extracting app metadata (bundle ID, version, etc.) from the app's Info.plist - - Generating a proper xcarchive Info.plist with required metadata - - Creating a zip file of the complete xcarchive structure - -- **Updated `normalize_file()` function**: Now detects .ipa files and automatically converts them using the new conversion function - -- **Updated `validate_is_mobile_app()` function**: Added validation for .ipa files alongside existing formats - -### 3. User Interface Updates - -- **Command help text**: Updated to include "IPA" in the list of supported file formats -- **Error messages**: Updated to mention .ipa files in validation error messages -- **Test documentation**: Updated help test case to reflect new .ipa support - -### 4. Testing - -- **Added test fixture**: Created `invalid.ipa` test fixture for testing invalid .ipa file handling -- **Added test case**: `command_mobile_app_upload_invalid_ipa()` test to verify proper error handling for invalid .ipa files - -## Technical Details - -### IPA File Structure - -An .ipa file is a ZIP archive containing: -- `Payload/` directory with a `.app` bundle -- `iTunesArtwork` (app icon) -- `iTunesMetadata.plist` (metadata) -- Additional metadata files - -### XCArchive Structure Created - -The conversion creates this structure: -``` -archive.xcarchive/ -├── Info.plist # Generated xcarchive metadata -└── Products/ - └── Applications/ - └── [AppName].app/ # Extracted from IPA Payload/ - ├── Info.plist # Original app Info.plist - ├── [executable] # App binary - └── [other app files] # All other app bundle contents -``` - -### Generated XCArchive Info.plist - -The conversion generates a standard xcarchive Info.plist containing: -- `ApplicationProperties` with app path, architectures, bundle ID, versions -- `ArchiveVersion` set to 1 -- `CreationDate` set to current timestamp -- `Name` and `SchemeName` derived from app name - -## Dependencies Used - -The implementation leverages existing dependencies: -- `zip` crate for archive handling -- `plist` crate for parsing and generating plist files -- `chrono` crate for timestamp generation - -## Backward Compatibility - -This change is fully backward compatible: -- All existing functionality for .apk, .aab, and .xcarchive files remains unchanged -- New .ipa support is additive and doesn't affect existing workflows -- Error messages are enhanced but maintain the same structure - -## Usage - -Users can now upload .ipa files directly: - -```bash -sentry-cli mobile-app upload path/to/MyApp.ipa -``` - -The CLI will automatically: -1. Detect the .ipa format -2. Convert it to xcarchive structure -3. Upload using the existing xcarchive upload mechanism - -This provides a seamless experience for iOS developers who have .ipa files but need to upload to Sentry via the mobile-app command. \ No newline at end of file From 9586ada7ad6b9b8766359b7102cc0f58331c076d Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Wed, 16 Jul 2025 09:57:52 -0400 Subject: [PATCH 3/7] Fix --- src/commands/mobile_app/upload.rs | 118 +++++++---------------------- src/utils/mobile_app/validation.rs | 4 +- 2 files changed, 29 insertions(+), 93 deletions(-) diff --git a/src/commands/mobile_app/upload.rs b/src/commands/mobile_app/upload.rs index 404a785591..ce6dab4401 100644 --- a/src/commands/mobile_app/upload.rs +++ b/src/commands/mobile_app/upload.rs @@ -213,7 +213,10 @@ fn validate_is_mobile_app(path: &Path, bytes: &[u8]) -> Result<()> { } fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8]) -> Result { - debug!("Converting IPA to XCArchive structure: {}", ipa_path.display()); + debug!( + "Converting IPA to XCArchive structure: {}", + ipa_path.display() + ); let temp_dir = crate::utils::fs::TempDir::create()?; let xcarchive_dir = temp_dir.path().join("archive.xcarchive"); @@ -228,83 +231,37 @@ fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8]) -> Result { let mut ipa_archive = zip::ZipArchive::new(cursor)?; let mut app_name = String::new(); - let mut app_bundle_id = String::new(); - let mut app_version = String::new(); - let mut app_short_version = String::new(); // Extract .app from Payload/ directory for i in 0..ipa_archive.len() { let mut file = ipa_archive.by_index(i)?; - let file_path = file.name(); - - if file_path.starts_with("Payload/") && file_path.ends_with(".app/") { - // Extract app name from path like "Payload/MyApp.app/" - let app_folder_name = file_path - .strip_prefix("Payload/") - .unwrap() - .strip_suffix("/") - .unwrap(); - app_name = app_folder_name.strip_suffix(".app").unwrap().to_string(); - debug!("Found app: {}", app_name); - } + let file_path = file.name().to_string(); - if file_path.starts_with("Payload/") && !file.is_dir() { - // Create the file path in the XCArchive structure - let relative_path = file_path.strip_prefix("Payload/").unwrap(); - let target_path = applications_dir.join(relative_path); - - // Create parent directories - if let Some(parent) = target_path.parent() { - std::fs::create_dir_all(parent)?; + if let Some(stripped) = file_path.strip_prefix("Payload/") { + if let Some(app_folder_name) = stripped.strip_suffix(".app/") { + app_name = app_folder_name.to_string(); + debug!("Found app: {}", app_name); } - // Extract file - let mut target_file = std::fs::File::create(&target_path)?; - std::io::copy(&mut file, &mut target_file)?; - - // If this is Info.plist, extract bundle information - if relative_path.ends_with("/Info.plist") { - debug!("Extracting bundle info from Info.plist"); - if let Ok(info_plist_data) = std::fs::read(&target_path) { - if let Ok(plist) = plist::from_bytes::(&info_plist_data) { - if let Some(bundle_id) = plist.get("CFBundleIdentifier") - .and_then(|v| v.as_string()) { - app_bundle_id = bundle_id.to_string(); - } - if let Some(version) = plist.get("CFBundleVersion") - .and_then(|v| v.as_string()) { - app_version = version.to_string(); - } - if let Some(short_version) = plist.get("CFBundleShortVersionString") - .and_then(|v| v.as_string()) { - app_short_version = short_version.to_string(); - } - } + if !file.is_dir() { + // Create the file path in the XCArchive structure + let target_path = applications_dir.join(stripped); + + // Create parent directories + 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)?; } } } - if app_name.is_empty() { - app_name = "UnknownApp".to_string(); - } - if app_bundle_id.is_empty() { - app_bundle_id = "com.unknown.app".to_string(); - } - if app_version.is_empty() { - app_version = "1".to_string(); - } - if app_short_version.is_empty() { - app_short_version = "1.0".to_string(); - } - - debug!("App info - Name: {}, Bundle ID: {}, Version: {}, Short Version: {}", - app_name, app_bundle_id, app_version, app_short_version); - // Create Info.plist for XCArchive let info_plist_path = xcarchive_dir.join("Info.plist"); - let creation_date = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); - + let info_plist_content = format!( r#" @@ -313,41 +270,20 @@ fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8]) -> Result { ApplicationProperties ApplicationPath - Applications/{}.app - Architectures - - arm64 - - CFBundleIdentifier - {} - CFBundleShortVersionString - {} - CFBundleVersion - {} - SigningIdentity - Apple Development: Converted from IPA - Team - CONVERTED + Applications/{app_name}.app ArchiveVersion 1 - CreationDate - {} - Name - {} - SchemeName - {} -"#, - app_name, app_bundle_id, app_short_version, app_version, creation_date, app_name, app_name +"# ); std::fs::write(&info_plist_path, info_plist_content)?; - debug!("Created XCArchive Info.plist at: {}", info_plist_path.display()); - - // Now create a zip file containing the XCArchive - debug!("Creating zip from XCArchive directory"); + debug!( + "Created XCArchive Info.plist at: {}", + info_plist_path.display() + ); normalize_directory(&xcarchive_dir) } diff --git a/src/utils/mobile_app/validation.rs b/src/utils/mobile_app/validation.rs index d0bf847860..368260538b 100644 --- a/src/utils/mobile_app/validation.rs +++ b/src/utils/mobile_app/validation.rs @@ -48,10 +48,10 @@ pub fn is_ipa_file(bytes: &[u8]) -> Result { for i in 0..archive.len() { let file = archive.by_index(i)?; let name = file.name(); - + if name.starts_with("Payload/") { has_payload = true; - + // Check if there's a .app directory in Payload/ if name.starts_with("Payload/") && name.ends_with(".app/") { has_app_in_payload = true; From b9cf3ed20e547359dde78dc199e0bc25882f4d01 Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Thu, 17 Jul 2025 09:23:58 -0400 Subject: [PATCH 4/7] Update src/commands/mobile_app/upload.rs Co-authored-by: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> --- src/commands/mobile_app/upload.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/mobile_app/upload.rs b/src/commands/mobile_app/upload.rs index a371476489..40d24d924e 100644 --- a/src/commands/mobile_app/upload.rs +++ b/src/commands/mobile_app/upload.rs @@ -235,9 +235,8 @@ fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8]) -> Result { // Extract .app from Payload/ directory for i in 0..ipa_archive.len() { let mut file = ipa_archive.by_index(i)?; - let file_path = file.name().to_string(); - if let Some(stripped) = file_path.strip_prefix("Payload/") { + if let Some(stripped) = file.name().strip_prefix("Payload/") { if let Some(app_folder_name) = stripped.strip_suffix(".app/") { app_name = app_folder_name.to_string(); debug!("Found app: {}", app_name); From 0b088cb21843e26bbb5e79ddd15fa78cde2c6099 Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Thu, 17 Jul 2025 09:24:10 -0400 Subject: [PATCH 5/7] Update src/utils/mobile_app/validation.rs Co-authored-by: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> --- src/utils/mobile_app/validation.rs | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/src/utils/mobile_app/validation.rs b/src/utils/mobile_app/validation.rs index 368260538b..d3acbbfefb 100644 --- a/src/utils/mobile_app/validation.rs +++ b/src/utils/mobile_app/validation.rs @@ -41,26 +41,11 @@ pub fn is_ipa_file(bytes: &[u8]) -> Result { let cursor = std::io::Cursor::new(bytes); let mut archive = zip::ZipArchive::new(cursor)?; - // IPA files must contain a Payload/ directory with a .app bundle inside - let mut has_payload = false; - let mut has_app_in_payload = false; - - for i in 0..archive.len() { - let file = archive.by_index(i)?; - let name = file.name(); - - if name.starts_with("Payload/") { - has_payload = true; - - // Check if there's a .app directory in Payload/ - if name.starts_with("Payload/") && name.ends_with(".app/") { - has_app_in_payload = true; - break; - } - } - } + let is_ipa = archive + .file_names() + .any(|name| name.starts_with("Payload/") && name.ends_with(".app/")); - Ok(has_payload && has_app_in_payload) + Ok(is_ipa) } pub fn is_xcarchive_directory

(path: P) -> bool From e8cb4a3a3f7dab7ebd9a07e4020b127bfd7e379f Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Thu, 17 Jul 2025 09:58:21 -0400 Subject: [PATCH 6/7] PR feedback --- src/commands/mobile_app/upload.rs | 114 ++++----------- src/utils/mobile_app/ipa.rs | 132 ++++++++++++++++++ src/utils/mobile_app/mod.rs | 2 + src/utils/mobile_app/validation.rs | 10 +- .../mobile_app/mobile_app-upload-ipa.trycmd | 7 + .../integration/_fixtures/mobile_app/ipa.ipa | Bin 0 -> 24735 bytes tests/integration/mobile_app/upload.rs | 54 +++++++ 7 files changed, 227 insertions(+), 92 deletions(-) create mode 100644 src/utils/mobile_app/ipa.rs create mode 100644 tests/integration/_cases/mobile_app/mobile_app-upload-ipa.trycmd create mode 100644 tests/integration/_fixtures/mobile_app/ipa.ipa diff --git a/src/commands/mobile_app/upload.rs b/src/commands/mobile_app/upload.rs index 40d24d924e..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_ipa_file, 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; @@ -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(|| { @@ -212,91 +231,10 @@ fn validate_is_mobile_app(path: &Path, bytes: &[u8]) -> Result<()> { )) } -fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8]) -> Result { - debug!( - "Converting IPA to XCArchive structure: {}", - ipa_path.display() - ); - - let temp_dir = crate::utils::fs::TempDir::create()?; - 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 = std::io::Cursor::new(ipa_bytes); - let mut ipa_archive = zip::ZipArchive::new(cursor)?; - - let mut app_name = String::new(); - - // Extract .app from Payload/ directory - for i in 0..ipa_archive.len() { - let mut file = ipa_archive.by_index(i)?; - - if let Some(stripped) = file.name().strip_prefix("Payload/") { - if let Some(app_folder_name) = stripped.strip_suffix(".app/") { - app_name = app_folder_name.to_string(); - debug!("Found app: {}", app_name); - } - - if !file.is_dir() { - // Create the file path in the XCArchive structure - let target_path = applications_dir.join(stripped); - - // Create parent directories - 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() - ); - normalize_directory(&xcarchive_dir) -} - // For APK and AAB files, we'll copy them directly into the zip -// For IPA files, we'll convert them to XCArchive structure first fn normalize_file(path: &Path, bytes: &[u8]) -> Result { debug!("Creating normalized zip for file: {}", path.display()); - // Check if this is an IPA file that needs conversion - if is_zip_file(bytes) && is_ipa_file(bytes)? { - debug!("Converting IPA file to XCArchive structure"); - return ipa_to_xcarchive(path, bytes); - } - let temp_file = TempFile::create()?; let mut zip = ZipWriter::new(temp_file.open()?); diff --git a/src/utils/mobile_app/ipa.rs b/src/utils/mobile_app/ipa.rs new file mode 100644 index 0000000000..765c27ae97 --- /dev/null +++ b/src/utils/mobile_app/ipa.rs @@ -0,0 +1,132 @@ +#![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)?; + + // 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 { + 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 40e3464867..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::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 d3acbbfefb..e714db1825 100644 --- a/src/utils/mobile_app/validation.rs +++ b/src/utils/mobile_app/validation.rs @@ -39,11 +39,13 @@ pub fn is_aab_file(bytes: &[u8]) -> Result { pub fn is_ipa_file(bytes: &[u8]) -> Result { let cursor = std::io::Cursor::new(bytes); - let mut archive = zip::ZipArchive::new(cursor)?; + let archive = zip::ZipArchive::new(cursor)?; - let is_ipa = archive - .file_names() - .any(|name| name.starts_with("Payload/") && name.ends_with(".app/")); + 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) } 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/ipa.ipa b/tests/integration/_fixtures/mobile_app/ipa.ipa new file mode 100644 index 0000000000000000000000000000000000000000..d35fc4a7cf9687f4e6db0e79728cb0454d90c005 GIT binary patch literal 24735 zcmbrlbF45;x8}X~y=~jJZQHhO+qP}nwr$(C?`?h0dothT%sX?E!PT`Y{YS4~s*<&m zuFlo+Qb53v0RIxL%Dv`)-2Ar*8~`3b-oV4!&cK*XMI9Odz0=M7U-1lx3?TpCv*`XU z3;91~37gp13E11y8ra+a2La=s?0?1oNTB#P0?~g#&=ayVHc_%Lvo&ybaWwf4AnZS| zf5ra~LROQnW9i-1g$Z2<6^dQ3wUStD$Z{1x^9W-+N&I_l;xW zLt2TE(t!Yar4WlF;(uZMFPtSQ5F!`3$^L}$Lv!PSnv?1`a{KaMh?6;Fr27cL_O8&t zs5p|9KxC^xPlXyl2ov;bm>w!`v2a@s#2+*83rHg0x11^^8EiF44fJHh`TSk(xdfN& zsd|}2fF$LTix*@wF{ ztbqfmgD+MRnjB>v#h(g1BeIrnP@+}EW-x@TNr+H<2cUEfDX{1SMm5lUR@K>BN=?F$ z+ps8w9}^Ud(tn94ry}4s=9|K93S*m+BlH-XGnv*}Ce^{*&dK@tb0#Kf+z67ZfVSVw zS7TgW+vyzJj>;3hs@inax&3b!}*G_R=(V_h}PnB+Ca^0*k-U0sRK^kHVO#^nc zKo?ga8~DmeKxe||pS}PCAOEGaCi?>bzy}WSAARz_E=sKbqA34c&-_pI=L4awe6sf2 z!!!gN>A#;zRG0y|Z7?Q}5vn2pZ$E<_mH`zF%f#%ovFzB-C`CijB;sVHAU-omLW7hx zz3fOKv!%W0Sf@Rp{Bzdv3cKCWuiy9Vv-kV-?aV&sam#VWG3R>gROi6qem3bN7l;qx z4@{0+KA(l3OK@Bsc@CKjS*&VCp_E8UY+{B;!{1tM33zOl4gEeLgPFD!*`s3IKPAiU z@vvgaB9z87V@dNh;`B4x!7_zdpqJqzW!Vx!G11g~VW*9GMwein8uFv<;g>7(qM5X? zh@HQ__jr)ahp|CD72Z1VNhPl}`S%@qX>f0gh}~}tq_LPqZ01`emT*N{gu$ltY2?SW z*y)Ftk2u%s_er7h1u!)P6Zk}U}kjC^7;6Eu^sRd z)dAR4?(a)=36xx+p?$hNf?kR<{$&@LaSWQu5ip)vhuCciTmy0WtFRE93jgaprrkw@_^>hBW z)F%RAHggc%)1PPCtAkrEZD|uxG|4P~rQQw_gbkt)Z(yhHb3tJwlOKI z+vCYA+gZ2DPI4luY?2d0RV6!@5cMzHf14=`xOwJpeRkFIfc_{2l8 zI`N1rwL<>2)9w{~7oC33)wIt&;)R^JEq&UGHv))IX>*X}V!WG8aX_CY_(iTx`8!vN zg{%PAvtXR9Q6OCgioG+O63;4u>h4h1c*Qef1FoerFUiFp#Ou4}W2-pUO9L+mi%xzq z%~Id==XL6L+~+O!`qX*ouXRc70cA zFw6N!ZWOpct>bEvmGcrl)_(qcQv_U<73Wxa1<*NvGcW4}eLT%-XO+FPCdAU6TW7P> z{%|M14sFi>_F*wnS>_6T8*bffFW*vFxYgebwzMT#-9g=Rg*|U+?6+}kohr18E>CkA z=JdU6R=nrw5zXrMxN;ta5T!h=XXf@4!BBI2=d;Jl38`qa% z2jl?i0PvZ-P$^<@6p~!sO_sE@lPGB^!_Tc*ba*Z+*|8iG(r)O>Z|_{K#h2gfTCq)) z^|u0SDYu1KZg`RvuVRsREveR;mC`=@h$XaSl?2b5l_XrZ$&9O>r^8K=6 ziw}3J5P7PPuCZFJIB`);(#{e$Ls+&^O9;D@kqhie1^1E#@uCBK$!|QZwV1PP7Rw`p z==0!zEw!ub0J{R6gz^&FyOY@)(5+MRgLEZcm(s73w7R_^thDRzoC@GoMiV4KX&wy2lHgmIoBj zNq3XORn}4K4FT(}yTo(dS9<*eO}HJqX_S;jRMeJHO%1{_@0y*(v-*9|=?vbes~y)X z$rh$Wb412Xm?O4~JqPSY(X#{Y=u+#ieDCH~ zvROyV;%=rBSXL=Rxs%yz80?Ah`X|k3WBG}?<7@H$vW8sy{>@Cl{BhK9Vaki0G>c^z`{9!jzm$canfV_4uLsengS{uhRt=-HBR z=XfzSEvYgokq}k}gJ);7I+hIlvkaL!#pg{5ihNr}^V!%H78b&9>RFh(FJ$}86$X?f z2VJcRc3lGeu-6xzs@p+duL5F>uUe~YB9;Wa7ZxVg+#iW7BcTHXl>8=5c%%=SXTowX zF~d2yua)(1_Z__%25Jf$-Z--*RHXX1gJU<-l*B*dH(=Fd6lbmwRK!oVYavax5E63AHL1=3q!#)rxF$Zts^YQvorJBn zTvntQV~~iuW5`)?%&ndbXa&c0^u#@Z(G)L&C{=KW7xcAziH^<-X+7djxpz&XRYUGG zck^gU>V3h-JXtD#8b2abpiin_8Us0^qt3kcp%rAAJ4YP6_D^|SK~47z%=*(*dJExW zjM%I1sp~7uTEywpJ%tHB1_D1BS4u^_-o125Jvau6tM+~Sb%C}ZUKcQrdPyBnok{Ss z0{HgUwf)d1LGkU>)usZB;jIbhx$~<*W#w|F$_w9P(ot}0Wfe(?M~I2Z3yF+Wk{PKJ zA>(8tBgA3V$OjD(2M3W*8fxz7kE_52d~^)q`*^_+WcJkc`C9&PGEyi;ob?U_nrma0 z>3@dGI)U;NEo7umd&LLsfR=wLOUCa>LX^QU3NDUB#M3$x5|8D8A9CO0iyJe{1|P$j z1x7m%gtMc$CpwZ4JJbSm0D&4q+~c#&I}nZQ?$3=plpXz_y))4N#Od>Vih0}kX{=@a zKCSMmLd=zLr#);(oXv8(9duC5?YQ60I>F_7J#I&x;kfT6ob7Vo4LjlGem-u;o#lAk z&OHI-x*d3^<+>etxaGd?d&uST4L|8}r#*GE%>jArhMfs;-%UOFa{Hy9eS7?X&w<^0 z=8hfhv$IYyWdjk_L?YQRUN zUrTxJy+FLdS8F_HZM_}p8%KP9%U>^hYrP%i%?!V&kEs*u&0~Me9}kQ+Yj54X{lZSJ zbvXY$JEz?j{c~x{@<>Qy_v`ai7>9~2;(Md->3rsAN2yp(Z){?c^*XF+?Z~FDb~YC3 zLsgJr;73P;kauU-k9#b0YpnnOL2tjXj`@Q4p~T=Q_6KxVTo9Azud5US8R2fNpD`|q z>Uw0=cY@-?zyu+o#t{5t0o@XUsbI};;~IkFqJL!dTMS#RHJNaeuF%;ei@9|BOsPF zMqbXXEJ>fO&fFmK{;H%y6vIhG~@Q{KePnX{|jKUS_xIVjJLL7Ky> zCzq8n*`NUnBm6_?A%w#xcB5NI?k5OSgd2dV5^9myST>+fIL26%^Cwk?G&ICOV z-f0+3xILJ<^bBpYik@7VjqMWkjFDx@cvUvw?jIGn~5dD4i$L)W|>p;}p-Df36@zv40oI4NKVSm)+772qPp%amF;j4YcA9Hr?A91zvL zlx6*bA_KgJe>a&STie~g)=Po}%LrFQGAo)RBgsQD$$J{wgB$uS70Xo?GDx5k=RZ`- zm8LTf1ziyrQq`O&6)dWM&_$PB8eYSldqON00$D4*Nt-2ZS^&GCI7Dg6^?fmVL$G~h z0nY+tTRY^Vyzi;h1T-kAp>={myH{35uS6Q5c$pUS8PMiYChLm}j~ihBUMEGKXEvl* zy`(5;<60M`%5-V}fj8M%-%d0f*RT&^Y01}g$<>HcHQGhioHAYl)if&5>KiuyDn(F%7Xp{0OyfynP(pA|)`g2-pIU^#B8;#7R zT~wNax^DH=lO^deite!5l1Ie~$0mc~hiPHbtIqRzaVU)wJV}{cgcR(c4Bz}8hOcxe$j3)BPl7M|-O?MV~teEgs zBOdF}kY*Ns+$Qn}nBc8TJf(eeQcK98PFy9-NRya%Xl$0ntwr32pSTuvY?MviGSTPI zc++G*jH4Rs)-d6-t-i5BT4vvvB}02`?3UH-pTuMRD3-nb7u+v9iO0r~hs+~;5e3*BQ7b6_v)3IjI9UNJu8W881$eiZmREhP`=weo!!K zLJK}C51*I0K?m63#gb?>z`J{Zp8(iRPZ1#GyXUQg!RBQ4a$kMn_5r@HXyDHLXX?&e zc+BD}aO?pp7tDA0%_|Psriaz?%HzoI=c;%$H}Ap8eo74-?k7EZZ}B_MPipN8b6@Z0 zG}dp}&QoXQM=Ai!>t_B?FJ99lV4yEce0Exuq+*FFC1O9iMo}PBMpOgUF<@dnmXJ#y z))~r9Sb!qzU$Hy-)CCKoztKfkqCB1;QM#q27x)`ZuWV63K@J+Rg{nnFgxi!W@1RMe z3SE|))~uP>6vz#BFX&N8v11BG;p9G%(*7IXi6Gg{29 z9pT&g$jfsg-5*{86ev~_M`*p1$2Cq{FKl58906f4OCq?Jz z(&*M9CVeJ<>nCl$3&?u@K`LnIB_kAjfcLJwE+(yLn<5on>W1rIA)Hme2}zutFL+Lf zY0E$m;hX>gYa^_@eqZlUK_oVOiim~E$9*p-b7-3<11IyP|3mdEdVVQO_hITXnM9Cv z0lPs9wq+YIq_Q(j5^!`I*ZrO{Cfk;Rn^%3WY|h#DJydTy9f$QngVj>a^EnydrG$3! zHpkn@*ur~IgXN`ryL)w>df0B{^9AeEVl^HB=d}%9HY>~ibTzbQOLy6Ey3sJFRw}*M zXdwFcMDF+PWP+e;PZG1oRDbXbIci~r)B7T2yW6X676lac)!wboNx0SAV$93w=q&f~ z!+2omGi|xYr`zymiY1HFCU3>h%VaP9J~tO)YC7`ugVQhPY3b58j@t7jr{ifVbmiyP zm6TwK)A<@5vMrV#2 zF`bW%$S>%@^K$5Nd-yP_EivpiSK-cu@B3*XjrZ-XpYN)#=ZZ`6)1nayYS)Fgs27vX zA>U#Cqp|&2&djHRtIOBwJzviG_x2@)AvDYknygpL?X6MF`|N&ly+tXUWpNsfu4k=o z;CGv8;-81)Y~M7WPr|s6sY0>ba<-9_8$I{)GlSHmSaJ6}&tWBGYZzbOzq;~CYQv81 zx`64lTx{R7E9F(BhgW*_)N~$ieyBHoRo^H2?Ut*glNL#zy@u{%;S5T6J&(_-g(65Y zwzk#6Wzq*K#b}}6d26mEfw!>a+@v6~w1kw2I&`3e zbK9Vsv1Q@!-;&eQi{8UPLB&0=K5<&-9Xf`o)N#s!6toRCg5E-W}Y9Pbe@GnpCXan;A^N8)j(CMN}% z#i@WjW07eJG)$EkY)mQVCAiR`1)Za5;WH1qw?cqgID1ktE^krUQfkp7u`gP#5h&F6 zoDIXFMArC2yiD;nxuFSY#JFga*A8_EVQ6`AbR@CljR~M@C_M3h4bAL-!A+S{D+bP0 z)mM$v>KN3|<Nkko6G#Ja{gXl)N3UVHq8M! zk1X|UK%HD|4DjkF2A3QFMh%yRHga|~OpQi4Cs4O3s)oxgBs@C0W^r?+6qhhUTnK}w z^g}LJX3r|9G}fg`krWFb;+Wx{d~bJW(N$}`ziqYgDqM@6cJp2Uo{~fgQx0fwYLY&C zCu7l2Z^xs-$w^4I52jrP z1G~w&&5~?lvcj0@O`Kz2768=Mi&JU}P!tlDhmS+xi?T!@AeB$9q3o2?&feP6J$v1r zSOqx~Q(&`-V6&u0z2j}9wcc}IYrB?pIx>7VGq=U{L_#CS5=sX)M_!Bb_jDCQp4#16 z(dku^?QVEIY$d5AWiVgF!R(aPF@M@H-M!rxwQRK6X-Uo5cu9!m>7sN}o#(`v(tTLk z9W3rkQSs(w)Dt|MhUoEqX!Ba%7Yr@!X}9|<=rLq~~ptkt}N>%6B& z>qzNwxfD%xMdjXIVt@ON-}gkpWO$5q{k%L4dLbIME%Ep|)f-%UYZFkhg(uiX**>G568R4w^(?0X-C!CaBTe&57R zMm4FvsGGmp+>pUomG)sR@_4K?V=}(+Vl*^b=QMqc*p;8q=}}#u>{OJRf5%D7k9Z-7 zuq8Y7EKS~q_^9F%>+~oPl0ZDxP)^uaN+wEs4E8_&C7BS-2qrAo6~8395`5SC-o9OP z(Vf5Vo&6@sqxt@@;?e8rz7(ol-*Cxix;Q=-Ok~7K5d+yWmSpT=%bRPi2c-#&R#h!! zLJJ;>Wm1YHb#Zjn)7>d_`zWpOnc>!5avjFG6a-W(8C#ZyRrs`3y`wQyFI&h4f(H?iZLaq@XCIag0%wLSxF7 z;a#5(k9Oo;>X%T9oxQrRuSsHR?f{35=ImHCUcTsv0TU%b^^DgM8X>K(*=N3aF=@~M zf=kz2B@-%2b5Dh*?5sd4{^e(8O+|eL4RDIFu?kcCG57u`r1)FBZ6jN(SV>lShyRrJ zF0=EzR^2nF?e4Pl$y>F@mc7Y$AG)Qc4CdTH#F!*OLaREt)^R}#-T_GiKF+rrD9NEk zq@1*|eBG3aY|}&{vM9CX3dgSh_t#b`c+slxWE^WQ+ne$xW`B5<#$*Di3c{;_wk)4=`7hSu7~~nQVD^~u#*bZ^xZle?Of$r_ftD= zDHS5i^!oO}$^5$>g@Y-N8vOEU$MT}>>UH|@^SI6cFAK~GkN@v4X-uty= z|9Mo~?q6P}wD(=7la#caR7t(Qj*B_3jwvA562U|~5furecVWIGpB}HhnTFRgI@#>j zqOYf_L*YELwBby}<~n+j*P_7n;q$GeDr>5ZPbFXLkLTyZzl}1aqP2>R8aai7$PJO` zBJRS>#?SWf1(74czpFE)J;6R-r%rBaaj)(#QGYMv_ZgsiDem4`T@G@V_jr`Ig7SW- z&L4_z&uC-f9y8Lbv@FM=cI(epe83KEahz&(WfY&evBc7)g6f!S(jw*G9N-_Y^)Fxoz^&YATc z`lY@(X8fHGhy(qU<2z-3n31Bm>ShI)>*c+@X41XrA$juw76Q8}0s(b={1fV2JWNLv%g4Q$8`7EThor zQk?DI@3u^92E$z}qo#K0@-w>pihpynD>I&U&JiYoOmu}t5fK4zwD$|sNUxzEdR(w9 zhhLVa+Za;TtDJk>o${KwwU~M}4eCd<2;)z||8DjqYmnkAZlYfjA8$>Z)ZBdis!>WY ziAYQio?E^fo}@cn+5Yq%NJHH#l0w6)ykYnI`Jp-%5c+)^>qW;YuI}@)9~jB zv&d^P+nC+SZ0-fQbK6GSa=T&cljefo-K!EBk8F5IX zC>ZvM%PNUHCb>c#bL(3g#{IOxO)%{b}t zd+q*Pe(+q0u#sfMI;x7IGpl;+z1qOZLlfiiu%qyqCyTMsbZl_c8cKvzH@9tZZy&{3 z#MQ;5dAas{K0*OyW#!xMcLlQ+*wF{~)y|4gRc|Kcbea-bY|@65uqq+Igo4 zTbusoqdJ^b#|aXijI6 zT~q6DrSd%_M4^$ET`OJb&iSUeJlSwjL^pTyu{{Sv1kI+i3sp>f)&*TN=lE$_>iBF0 z6ZU#S2a&mXF|U=EeZ#A-R;QLK`{d1Ka%;5rAr^MGx5IE`#rJ(c*Oh61a;Yn|G73-h@Lf2^;GD zbM!5LBKGX}t@yQ4XIDbTvqI%#xk`BA)Rn;vcc(FFHk4QQ<&Wv+5lC&Vdmu*l@|Drb zj4EBoC&Q)NNxdDXtI&EsrT-HxCL4r6Vbid}$x4DiCJ8BBuc9@BcSDNKp1Lt>UW-5O z58lPJ$ztih@BAcxRfg4Cyb>Ha=d|s^H&@r zrK4z#sVVp3=OaG(+=?aYNDuw*4H%Snte@*pI&e%6f2Ev#ctPQzccIVp3V^TxY z>FIc1#K!l)3mfB387pizmv2haRcLk_NC?*@0g0r1C*LXR$70xXM1`Mptj6a1 zX}mAUoo3`db%R3{YN!6KikESa05o%D@E^-Nrgne<=Fom z4&{SKWb*~H>L!r`7&(yUD{k9=maFvAf5oBF`-60+5=#N zYn$wUUc9T7q78G$#X;yek&serLgPX>54eo-NZ=EtPhE94>`J#nruZYk8`n4bnSz`kJBP zP@6+`bKQAe8y&2<)7x?*{9RfyI~3D?bGMnRe=T3*g4M#^*HUV$88icLIv$?Y5OeK? zZ>B51JIX_|zoV~k{SEm8^uMhmzNlZYf7#?l>rb6$NdW-nO#uIsP43@KsMP<3P0qx| z(8So-#F*B`&d|cz#NN@))xycb&i3Dpsy99m?L;ame05y~7OkiPs3`8t`TFOwHxdjo zQZ^OJiIv5aDiW0VvWhAd#40H~6~)Vdn1CS52_hmmBJ%?}_JAm94rmz&D9ZAp2x_4w zpaX!*7(Z=~AJ1NOT}#J&zjscfzi+*FE{bikIbO3oXJ4}&uc~`sH_f=*=1WtwGP)nj z2T)jRI(Seq6*Ew%=LyvUo*^8pD&t94!n0bu-GEq*!#TJ64NFa4o8o8Mb{yf51$ z2`g7@n>9^s{ag7oQL0nsSuv?TFQ_sy)-iS-@3BIydDEsCDsv7|IBg4f)ThjxMD$Tw zt)fNBWk;F|Xn=9%$w{r$wiR%oIZcHMd#`OOf|WL+TTl?63)*!%4W2;~ zp!8dixa+2{(=_>6B)3+>fHkH==-2GVORi&?*8n>Z)yfamHw*WO%`ad)_}8(BP2lTv znz<`YH6p8zoBqyT&TBgGLnnRZ-o~ktryEAo*S1PJZr18j#l9CpR zIx{q#S=8Rq7aB^X)+Y6Y=@7K8`0#;tA%X{q)YJ=1RT%O0D>1jloKBQnEVtM}ZmDV^Zjbz{$c}vTe7_ z?u%jOlDA~!si7h=FD6=MP>s`#*Z!=D&VAWsQYy?VbYwHK8Ee)dboeKS?#A~a5%J<3 zD!hOyk;n7t^VS{7fA*$ua5OBD+JUVfpA!x`;!7Mc5**zjSJ_8jF-YLYwD~ETX;927 zX&7Y(+1?tc>~H1s^o>aK6{TLCQyifx#f?Z*QScL7NEMTYgjfBA+i?Ez^ld`g?9p-= z^-5ec@5pk*NKQsTelw0NP_Kvzm+??IK3ntJvnV`&U)%bWnD}%^N(A~(!!#8;SAvUz zYa0MKTLpM&q7ZzJ4!1ptbZ1z|+f-yoM%n7f=kV(4XRB~NiW%F zQ_@{caWJ{Fs*Og}MoAaof3(i92tyubc&E)lsU)Xx=rhF>(g&FOPDqginWWNRWh$aXPH3DQPapg|7(c z(D#V%UAin4Ur#LS-Tc}xofUhb;z3b+U>Z{!E6xWiDf-4$zZZt?r5J}xe zHG^rzYr58>2S!K+Cr2e~hB#rXEKhNRvP4MKWVTxNknh=wMWMu^vx3UtQN{+zBG>#a z;T~FcSgA#!8|OOkse5MNvM^Fs6=_V zD8#Oahh0K=9O~X({g$9eZ6!5KJj5uS1HvLT&nvRfeR(Ej$+zhe+tLn9g~_wLdX`iS zfNtfP#80$pPR2i?84({%5?$er$P8zs06VOQHdHX~K*FhvPS-BhO>PnK@ZmTnpL4-Bw%W{d^{X|%ZKDPV*yu`lqe*#{xyXolXjbl77Z zR`CRnCcg)#x(_%-`A9J|EGNo@IUEB;p9g%K*N%bfzr#|^tyR;CwYiUo$Z)pqAqFu; za0Rr{fpd|ie6A$x_#@{jsAv&nmJg|i4*BJbEyx=~*-s1vbCKM6Fp*z3SgeU?(-Y41 z2X1siiIem$wvq;6yl~4@!jWlOxA9E{_+BXI!%B5$_dA>gZ0h^;rz{O>H@iBOWf1 zRjL92N$uL)X`;jz*#FmS=SX#Di%U;lBlM4T2kP$97zk#SW+1l3TIRQ7MahuH)XbHL zMYyNp@`;w<3d2-|xO@aWLnv%#$)C-NM29u2WQz=(VJ}E!lLc=+s*_Fm;L{}sQadR2 z6*TWroR`UmMm;eX%@)Vf<(Hj+yClY}<#So;vs9BpX-Srdm^N-2+2iz9>O^)$`2E3h z{(=Zuf;v$g3-L&Nycm3rjifKhUXIdsfLr++Ut>cHl0@halkEs_++!6)&75;n#)opA zfK4K*^XTVZ93dP|#ziqzB=Y+D)PW#Hl~IlBCew60qOB*eF>g<|T#eW_dg{vXq~5 zH>d!y#(S2Vu(l(gw${Kb8vaCnwH-Le4nBk+_;FQ&@iNj*T#cPFfxl{JuHDFX(Hfn~ zcWzD3iX}VnFnnoO{qBOrOf+N&W6uc!W`89Fs-rg~9O5Ol=&R4dA$f$WT3Ao?AB?m4 zt00#48#T2VAnS)li(uHu{@7EbV0i7zY6s9nA7q19!K}p_H=#zjU73&^+NTsNTR=5% z=9q(;_$|hEb)T%o3n2cPScGZBF0FSVPm@u4PNoH_0c*C}+_COOC}-KGBcT$P*lV;kHm)cU^T+ zTKPLpAylKfe0C72M_lPp$3M}1@i9%lJAWZic3?K;%MB!PB)xmr!r55RJT@WB&3PJO zU45%^Z>}(#rF})$@%_*6Gq#;;rsvAMU(A7w3fNyYn59*xA-!1f@~hnD4FC;d9AZmM zG|UvNI@jnh?L!sNB-}PKQeUMWi7auYk)R+M4ML&#Q7gBo`orbea3BTSV z={jVS+?=N@M5@D)bH!dX&ScrLL-DND4o7@8oRGK>R3fNE){{yOyU^ZfQ{NA}C&r<9 zcWxAUf+jv`u2qVDE)pLWR|XS73e`+B#bs2dw!jM}vXxIz(aJ&|Qy6lHpMDm{yw8+#V0tsEx0l&5)fWaEhN=pIlPmTH)EF{PF()MU$p z46X1?y>K~JE#x$_rBf|;?O{~SAg@7S)uccXbAOq0+l6)PI*6hSi;{7GetfIl?ya2p z*2~$FAi|AMqtfx6Zz0%5P0*Fa9ns!K7rER8Z>bUADV*WWOO3$ZofHdS1~vm7F>a;K z@*$XW5&>Gpz?MlNR|(SkRu-iZ5vH?pC2NK#;J!*t)YyvaQ-=TS?tt*W{$* zP#sNAZmEPghJ^7p$?Vkc~%k1o-D?9EB)T2YH_@Ngs7I zEn8`W2?yo6*fE@1L@Uck5wxRpMXc0BmON#LN(eO$>d2992}(9w#E*PFT4kn@=m{8M z#*BzGeu#zBdEMB%i$GOuknlRwpTw=-e}>qpE2cxfrct&Hv9%MuHPli5q9~>)XPWS@ zatF>Oqrz~*M!%Yoc)@4_fPOpc7G=r3Z}Y}ElspSn)j}#DR0hI8!9(w^d1|LjEex5_ zMKr36r!QZKR}5~oVR|$f5``c0by^N=9t{Oz8NPdS-hb+y6SAAvQm`um2E*(Sv~%WDkRV5WeSkcARr=Zm+rjG z2!uRJ$|T}W&01oRC2c`7#VYi;FVO-;s-uZWP*41^F#Awx6d1lta!K7kcKxBZ zu}&w_WoK%}jZ0Jj-|`*BAA|OywqpX^i-e6_ZD)xTAxPW_%Mk`!BG`>YsW*;^U|QYW4Wkq%@VxuJ#>TgTX_GaM>V#t*`vxo<1Z^Hj$2 zh(!q9k;hZs6}uV28_J#N%UmiJgKX8&EY)#R#7z6j>GhNY>~bPaK&lP)!dM3@>ZwFp zM4X#}hL!Thg_W95N`b8Q{F7;WS_x%6T3bI(NX$psOvt$^CSaY*9SiWp;@Fx8dLGKEx5LFF?7H>oVeJL0g* z{jEqcElEa_7pMq$GLx|?7eXtUf^4JClhJ}n3RAA+g@AR(uKHH-TPdf%aUD5Wza(wH z87Hp3!=m-s@@?IV`B-yqOU8IepFD|{62Bqaw0913KYkPMG`NNl=#wR%M+S!a2Z&=5 zCC0ROEmAF+M=FiG^r?duP4m%l`-kWahM45i?4gbh7S&1_zL-mg@fxKW$X7`d0%nTT zB^}DW%K^oXHKd3gvLyrzGu#3$Q6tU^@Rz)LOa>Jt{p7);oT}7HlKY@YWKb+UV^@MW zOWT;~rAG)7j`Y0yo|rkIS<(0y%57Wnft`SE37Oq7ld81daYKx4r=B;OLpo2ogUy`eFg83XwrL>yc8DZc(pb$dK%o-w zqUJ3OTshBOt~(7Ejnt0)Spg{+p)?a!O>&8q{;h?h8?4%BE&`Ml$>WNq2=1=%roQ*s zF!c(?+VDIhfvO>+I7>_Dld{din#dYsFp>D2fQ}OQhnFVB`@y;-i|_!97Jmv2H3i7? zI{{E~iCJ0nAlcQe75(}BqhuxxD>^~XK`YbHlTG;Qg2xXsYUAYrI%9y~PbwLULQ;PVf<7dB&&-)@G14~xis!3LSAhTrCFShuoV*EKtYc{q7mM_#> zO@`$Uktj(IESu|qw7t(1w}POZOQ;^VCrrx!1d!pDmx1YrNf6x5KK;y2kq*fVyy#eJ zP%>CRv*sg7Gv}+L(7xv2VLG(50!2661< zm$la8G$YTWLWZOWdCYNK>UXKzo=5(idRE|AWtG+XZ)leXF76oh5mbtDOP z;%`$Z2OB4b$!AidO%Vu@_}Kr+ylm9|qmp3jB#8L3$iXrqS%i(N(xF_&)zBRoe?hhZ zi^H4&dhb$j@gkXnIwKFnYxw$5E;!g?8*HPVgtI2iu zFh~+%-HU>%BlITAifYb66_55)bX4O3M&Tt(>Zm(A1bGb@qcd``cAqhiuyP z;4M{ZP?vWwpdEk#(nJz}itsx?7dz+-#{VrjFXh`1gkqmlu}&=#y`3xonhYePkfWjy zWDmR?Q|jG{@=m&lrcpc{#!Zbx9tER)g>;A>M&Vc+lUwT+9Xy<*Za=swRuaM3#9t+P z2h9WJd_*z5+|ifxs}|BIqGgP5_`p#Av#_*gzWxvqrYfeX@-!+o)J%ipYdb46Nx+*5 zBH@U>qJu1^ei|%M8R1+>JpAC9_ubzz5`}<&$an?Ar-3n?#D*FalJB2W8yO-MksVKB2y{-<@uz!NRZC{KFLTJoH*GE&pCp$+pC zY5!Zuk@Z}>n9wnQy8j%&IbVrHQ$*;nRlg`Ur6*zPNeL)A9`%%B`110oiK?)Ot{6%0 zJh>O9>dPRAfEix52s>6SsFgl@RBYwCCjeK8Gk5yzg%dseZVAoQ_}>HVP2VT$N{jJ5+57_-{r%*C`nX zJzikL2+XKUUweNS0GN88{2?Zrx{3ZW7Vz=8nNdibiOPOB0+?KhA&Vqy_Eq?OrYW=* zi?6WOJvlw(en=i{CvVfQdd_|ueL58VZE0yxhJioR0mI+=X#F;_P*+TTrGDj)>X1gb ze_rxV@G$T3Q~(|!lPK`QwQ{UTyX*m@bAV(Tp!P2?{d7tf08pF!;mXmPy37~7CxJRj zXgx9in6}A8$S}Lm0eVv-w8}J!U`#d=R#yq;gXND>TbAG6ETQ?s`L`eY`rT-)y;;MB1i?;F*~b<2Rf#AP1xu#)kO1e{odC@PqPfjo#f4$ zGq#*E&B(Zz!vqV!P6U7Yxo(I5vWOXyh{$LWNT(Y7?ALAj{L`|v*z<7h-X_$uPLFD~ z3U`2B3E8h8uQt<)R!%aYjdp1INm;IS0O%%fP>jB=iO7Kw8;k8C@|g z9-iTKa~T&m&G&%G;`mn*!+?V*qBQ*@&T{rg{Y$E02_9ATJO<9`9+$M{}DvU*>1A%EM zdyS$vgEdrki;YYt38hb7n%fQVLWPOPqe^fvuxud+XyXu0m2#3}s+(Kl=40&2Zqo>}VcLv|jeIbcjU^T<*XZ5|p!Vk5`@*T{JX zHMMVhoZfp4(gZ|61cHe4E+8OHiiM8Sg@BpkzDH}8)X@XSWrMI5TdhAYJ zYR|3k2af@ST=IL8J1>7uMC-=$z2l%Wfb}=XQnHII^Dz*<^XB(vwYy{`?XF}RPgd%#a;bCQ;VaggN3R&G)1$X z0zk1>@;c0}vfR5m_nW`l6GJof18&l@J%!Pa9mp+&Y&=FF@oUp#oD>?JqclJHDk35p zHJOs$zvbghy;AT_EO2duk?8D(M`%SG;)%g(!6Tn7dVQs9Ib#Pp^?bf~HybWCGZy84 z!E#-t{eWxdS>4iYb|lITd}JpC?$hb6doALU_MHA{p2Y(XZ?$c{q>VbnlhAb7h zW!4(NR1WyTkbPC77J%3lO4(goYTD84+9z6*O``!@_?*u%suDb(Ww7#&4hl{*7r3HL zVD8gKjl7VBx`oxz5WPL1NUad*kKBEA^k{(8Q+=(%qd}`W-9uXtFGY)NT=H@q6Rk}T zA}7CwdRi|rN$X)|GRzrfjtfprjB~C_cJ$oUlgLOWzSh}Me!#1XW>7P{wRiLKDctzm ze4U*T*LoTOZ^pgvEwP&vy>GN==8Z+z@ZS$?3U~)jySWe%sgD@uZKf<48;wa+m(HG! zl)CUf>!f)Rc4O`j<&r#$>XzY0CC{HAg}jGRCS?3Sy03W+_DTpT7BR@QH`omEXF^u=UyjWY8fUxRV{4!fv2yl<4!ukhif2gKOFC5vBHg zPA{!PiZ_>z(pZ-FW`s$laCxVjYKIE_#?STPmqKoAx+0r<6^jvAqH0mt2+O1$Gc*%n zR{)}5W>eJXH}6Y-kJQbpdAk?=t=XSeN}br;{&Zgf0S^5E;+S7^=&&gahQ zE-V;x3%4am(nyJ)yq!kBc`75s)fHavZYfMUQ65wP!A~%nnVh)qS<+7@ZvD^rI;c~~= z@R1dRz8Tf5zBN96g4G5SAG)~i<5J_Tjt5=+jTW#hG8J8w6Ri~8TloXN>t!vUb1cwk zbC(f7U7ABRi7-wmcuI!c=oV6fzqB;dx1zcd{XKkL^UUMBithth&1WN~8bn}h$F;?! zsiMc%%F38w6_=PYZ>YlF$z$IG#T(W+n5LH^b9S*6=+EQ4B)P?oWim_G8)8>u0X34- zvPCCZWz18qXyJvaTW-Pr+3nYMn6Vp2R+=AhFc`UH>ZD(CY^&SR{4H3-UlBeLkt$y7dB5F(3p+ylnTCYJ!l1UH9cDbADdz%Ifp866~ z1jD>Jg{>gK9`EKBwriSM8bA~7Xbpo7v_w-AqWG`H(O2H0GBYVM6{lvbdPphdF?Ls2 zW8h7aIOXhA)qQGy{6J~}VUtfRQqKWwd|G@Y1$T&^b(H}q-b(`2gY-x>w8$biVY1Qp zd!@q}Q08vkD>o9_fpV-s2BMfdf>Fb2R32pH4>c;$wH=~EOv*A#_Qx^w4QgcL$DNAz z+msamd>d@k(c!>%s_!q_IM!6&{;+oB${|t>;fQ;5=m%l2rvvcN6X9nXI^Pq2^Dv1H zNOG%N+&S3e+kGl449$-45|zh=KJx$+~W2Xl)M4nm@PnhPdTtu8 zCoEgoT8N57BJQF<1W2I#HXPuV%}dAo+=FDmV*^GFBqr-!s;>~G(Fn&kkkIcUTk8_1 zSdYGd5O&cMeBPiGrm}G-;K|NsiAefPPEU2jqMhsUB3Ouo*o%`YB%y;0=o;#1pYSjW zZ-6+N(%JsBy1+El{cUY_|GdsRf04=cH2f|6VI#{(3Wn?Wh}M=_eu>ZyX#hFiC_0(# zpB|h;#czF!Ay3hGEo(PD1cpl*4}s-+&vy`)b7B6?O^J%i`Ke=TskVVdc#;m3g_BCsD$a27>1akFAlaM^s{UYhjAr^mRY`oyNN61cHDmb)swSkIByL8 zxdL(&y5Q3<$6DL69;(CTD(55YW7UMojxf>-*h;r*-TWRPz%7sJ$6bYu9biw7O2(rK zx#g~Y^p|sSUC1MbVBPG&9J@ELJ7ul79oVV_m+w*1;jplhY@zRR_ng)F(0QO5vgP7k zBUC|4{vuLp0?nF(5^jd{n%zC!aH-AmZVmQb{~EW5ygV-to;)MGlCXkL9Ne2hlN`vM zADN}}37fI!24wpp{ibcEEZ<{iN`mO>*H+*yDG;^IB_l5RORr%|%$lj#@jFszv_*s0 z!es;@i(AuH`iMHMYE-C=+;UMwgo9LRI8BHi1ypawox^h=LbH~;^{j5kj}~%#2vykO z^64S;$>^J7$ph2KO1~QU82~r)CwS#i$|FaT-`uyqH0lw#v15jc`9fhjcvh(zJw?jm zg4et~#v;e_(v=G>Th}kUboA#>{}=^7R*>X}lg#Eac914ayAMR>E1iFVDx6#>EnZ~# zECde@3Md91Np31MIL+s_)3#byNU@qAInwf>qlv3dS7p11f`w-_V?Yo zq%QEfgBq70NFQrX-DN?*y^4WlxPJxm@VjJT>-pZI9iG&a5O#oy>3 zBNAk!14?FMQ*Z$lyY+Z$H0AXbem!06qL^;(R$tr&&U-~Yk(2T?KK&)Scc%Rd&hs?k zc5kpZDnbCe9FVvx1=;$9lV2M{oVy01yWw2Rkc~G|t;kd}n);PP%k8#A`?0;Ln^G2a zn2jj~_jMbwK!k7tZr!ej(Fc3wl0WAb^lqnDpUZc#Yn#2PHC+jV(h0gOK31~jJUdNn z)onRCn`#m~=HJdB4PFgM{16p%lw)-gxhDhl8_jqJs^6IxXgRKFtv>)CC}5*^Z`B$> zpP+icm?g==piOM!@o>;O7HR?BD6>0^@5xdP!nQz#7rGRBmAhx2 z+5w)PUp z3AjiSoA$tsCb0%*HCMX^Bj)$29bPu?HKoIY4`YJPM!CO8xnfQYSp$1f#r|7yV^MeY z;jfSil_i#ep@EmeO5SJvAaq5LDu+aWV&0ckr?WTEpk3)r2dh5N!sRE_I_s5OEQeIk z6+fcCD+<~>kIHb63bw^C|48Io^oFJeoMR4Jt_qQ8tCB^i;KaFN&;+@ASnz_dkNjCG z+|sWv(uEs~T$l51AZ-qq8-`jhS}L&0UG?tn%N|z_pNT49Q_eAm@i_$2ujJa2*wfu# z*evXb%OdqjujkS7NE7V1rjX$ik;cT#dtC_poY+IIu!68{1<8w7tBe=(Fj7T->{_)M z_f0oJ`)``%(%4faznciG+iFye@@^1#KA{ktvELHnkDJTB*spayke`$}l9O7*#<4c< z2@6=D%ah4R;hrLNSA=PT=R5WN zR4$1x$#d#9z61wvHtzQYp6Ptf#1w=p)uSe|xD~;b3-zb#lq0FN;y$j}#;TlXvcgtm z2JQl(AGi^X9tu9B-IqrjIRvit4r+3^Pv+HTLI!Hu&uh7{t2^S{4V3}RIa4hsm4c@+ zg*fE#kP>F6o9bLjz%Fy%tY|9O%Ru48kJTtWFWt(M(%An~6Mp?yY>ZSElCcC=8rX6w zv%EOkIGSH}j+&aUELobarwgHHLohoB@O;-U+GkYCX0-MeeSJ^S#sfAK5#HWKf(&Te zFJ5XR-uehv6*KG3^rGx}Nck3P8Qf=mIaqW*FRtZlhA*e*Xqi~k7Gv!Cy;~x%XGDwQ zMN*e>-F-rrag)8apCzB}8T%6=%i!GprorO#Y6nzl*qSp{P)ZT0oJDyfAWB4*Sw9r%JLK(g-RC5Z}!?rSmO zjiAPrl-3o{RSe8xl;+Wx{YLDDLZ7SH<<;cL zm*L>_>Fos<)ZTxIb#dBzTt4|FAEMaCy+$G(t@rkW(JJY`2w?+Q@EAr`(xw z8rQh1*tp$m6y(0S#B5bJJ&mj>%#|>W2BNw~C?B6-pOO?AS1=4;ZQt@va6==pyg07Z3TQ&;%cHNv5k5^b* zv;`N((L!Qf`l#XA^x-+*vD#Wp`!WudHN4>0D+SjxcRyY6p>35tY(JEtwUi0kWpB_| z0-qwp6xOU-uC`CkjuUYs9{N=Jv!3&vF53Afi$yG!o9oqXmw;;==CGtE3$w}|6gW`Q;^%mvx*q_VZQ0ix z)P&ZV1YJqBUJV)zPk?ZzY>B(1%tlr%$aG7WRd`^rcQ@ZA=IEUdU)Bcse%=I@T_gZfDAW`?1 zPGHYpL-y1^vCF@%U+Cp{LmhoaL#FI{*3?T|yhHpjF*;*_*`qj6=#QI>amEFFj?u~e z15N>U8$;5xDe*Cs@ow{g1%NK3Gv3TcW1$XMYOvZDfu&(&{CO%~#Is{DA22Kf_k+OS zdx-3i9->&ksNJu#>FP6yubg!GQEmpmAzAy)&2g^I={W5yp)AW{uEqy3U0>qh1A=ES zHlr^|&%P7(=D{>IJ->L}{gfu7@#c;lu-sI{V)oroLgc`=>x5uynR4pee}pi2{UKUC z@mv5JLQnFdJIpFGwm0wFgZA;yLpge;o_8k5d=(EjhQl_G;hqZqAEujBT^0rypQokd z0uNq+0v1ZrIK`sxOt$5hp11%#Ex1S|KuNhy9_AJ?VQaQCPeMo&*s~zZZJQmOwr}%HBKm&PZVC(E|l!DrDvhS&7aVz;1#D z;UQW4o!qf6y5Gm~ZS37*e5!7;e03zD#SU8GN&c4bz?xmWvW*)cGjr7z&3ZQi8guO} z|Gnys`uBW15`D$X_()MRN&XMrrORWT{1Ou%@rgx^6AP{;^lk8c)ot7AH2t=n9dNh2 z$OR_R+;tR>U4id8we|2a20{?-6y~(BO1p}7;ZP2l$rKK;H*3c-k~}@!UizaC3MI%b zW}k*K4spKVd-h(;>_?^uJBtm!OkPiQd;6Y&bMcSSL&})&qtNXJ6LE?d<2yspm$%Mr zf+5xh>E0qGn@=2xQ+$eJhbJ9ZZwte}!D7EL`Aa%ic1^Dm3TdT`pu?lEbiRk9^zpvl zw=Pbm4;f(%6Q70pdy_X8a&fyTC0J=gVN@XBIKM?H0~cLXpyNqh0KI_coRhbSHb-q3fjQM;aAQ5 zss{zJ|8Z`A%5@ty1+j`-z_e~SMbt^GT6 z8~B^}r_kR2pZH(s?|=P_e_Q%}Z;AakONda*pOOBTTYq=TpGfcjF8XWb>G#fj`!~@~ zp%J(KyXfC9K>anI|9tgd5%@hFx&Ffn>gj*C@_WxG`?Kf&?)+c6zh~j^F8LJdWbxm1 g|4i+FM)X%|8|V;|{0!AU2>z`<@#%d(AH~D_9}1y(uK)l5 literal 0 HcmV?d00001 diff --git a/tests/integration/mobile_app/upload.rs b/tests/integration/mobile_app/upload.rs index e905700646..e10eac9c3a 100644 --- a/tests/integration/mobile_app/upload.rs +++ b/tests/integration/mobile_app/upload.rs @@ -172,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(); +} From 6025156a7d376d7fd17fae140e1a0befdcaa02e5 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Fri, 18 Jul 2025 12:53:46 +0200 Subject: [PATCH 7/7] reproduction for bug in #2619 # Steps to reproduce 1. Checkout this PR locally 2. Run `cargo run -Funstable-mobile-app -- mobile-app upload tests/integration/_fixtures/mobile_app/unexpected.ipa --log-level=debug` 3. Observe following the following error, which occurs despite the presence of `Payload/DemoApp.app/Info.plist`, because `Payload/s/demo.app/Info.plist` is shorter in terms of number of characters. ``` error: Failed to convert IPA to XCArchive for file tests/integration/_fixtures/mobile_app/unexpected.ipa Caused by: No .app found in IPA ``` --- src/utils/mobile_app/ipa.rs | 3 +++ .../_fixtures/mobile_app/unexpected.ipa | Bin 0 -> 51356 bytes 2 files changed, 3 insertions(+) create mode 100644 tests/integration/_fixtures/mobile_app/unexpected.ipa diff --git a/src/utils/mobile_app/ipa.rs b/src/utils/mobile_app/ipa.rs index 765c27ae97..ba1c7f8850 100644 --- a/src/utils/mobile_app/ipa.rs +++ b/src/utils/mobile_app/ipa.rs @@ -68,6 +68,8 @@ pub fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8], temp_dir: &TempDir) - 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)?; @@ -120,6 +122,7 @@ pub fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8], temp_dir: &TempDir) - } 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")) diff --git a/tests/integration/_fixtures/mobile_app/unexpected.ipa b/tests/integration/_fixtures/mobile_app/unexpected.ipa new file mode 100644 index 0000000000000000000000000000000000000000..5474cceb6f3170a968ab02ba221d9590bf13f098 GIT binary patch literal 51356 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 x