Skip to content

Commit 7f81494

Browse files
committed
Add asset catalog files to zip without adding to folder
1 parent 937ff98 commit 7f81494

File tree

23 files changed

+625
-53
lines changed

23 files changed

+625
-53
lines changed

apple-catalog-parsing/native/swift/AssetCatalogParser/Sources/AssetCatalogParser/AssetCatalogReader.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import ObjcSupport
77
@_cdecl("swift_inspect_asset_catalog")
88
// Insepects the asset catalog and writes the results to a JSON file
99
// in the xcarchive containing the asset catalog.
10-
public func swift_inspect_asset_catalog(_ path: UnsafePointer<CChar>) {
10+
public func swift_inspect_asset_catalog(_ path: UnsafePointer<CChar>, outputPath: UnsafePointer<CChar>) {
1111
let pathString = String(cString: path)
12+
let outputPathString = String(cString: outputPath)
1213
if #available(macOS 13.0, *) {
1314
let supportedVersions = [13, 14, 15]
1415
let version = ProcessInfo.processInfo.operatingSystemVersion
1516
if supportedVersions.contains(version.majorVersion) {
16-
AssetUtil.disect(file: URL(filePath: pathString))
17+
AssetUtil.disect(file: URL(filePath: pathString), outputURL: URL(filePath: outputPathString))
1718
} else {
1819
print("Skipping asset catalog inspection on unsupported macOS version \(version)")
1920
}
@@ -48,8 +49,8 @@ typealias objectiveCMethodImp = @convention(c) (AnyObject, Selector, UnsafeRawPo
4849
>?
4950

5051
enum AssetUtil {
51-
private static func createResultsPath(assetPath: URL) throws -> URL {
52-
var archiveURL = assetPath
52+
private static func createResultsPath(assetURL: URL, outputURL: URL) throws -> URL {
53+
var archiveURL = assetURL
5354
var tailComponents: [String] = []
5455
while archiveURL.pathExtension != "xcarchive" && archiveURL.pathComponents.count > 1 {
5556
tailComponents.insert(archiveURL.lastPathComponent, at: 0)
@@ -58,19 +59,18 @@ enum AssetUtil {
5859
if archiveURL.pathExtension != "xcarchive" {
5960
throw Error.pathError
6061
}
61-
let parsedRoot = archiveURL.appendingPathComponent("ParsedAssets",
62-
isDirectory: true)
62+
6363
let destDir = tailComponents
6464
.dropLast()
65-
.reduce(parsedRoot) { partial, next in
65+
.reduce(outputURL) { partial, next in
6666
partial.appendingPathComponent(next, isDirectory: true)
6767
}
6868
try! FileManager.default.createDirectory(at: destDir,
6969
withIntermediateDirectories: true)
7070
return destDir
7171
}
7272

73-
@discardableResult static func disect(file: URL) -> [AssetCatalogEntry] {
73+
@discardableResult static func disect(file: URL, outputURL: URL) -> [AssetCatalogEntry] {
7474
var assets: [AssetCatalogEntry] = []
7575
var colorLength: UInt = 0
7676
var colorCount = 0
@@ -154,7 +154,7 @@ enum AssetUtil {
154154
))
155155

156156
let data = try! JSONEncoder().encode(assets)
157-
let folder = try! createResultsPath(assetPath: file)
157+
let folder = try! createResultsPath(assetURL: file, outputURL: outputURL)
158158
let url = folder
159159
.appendingPathComponent("Assets")
160160
.appendingPathExtension("json")

apple-catalog-parsing/native/swift/AssetCatalogParser/Tests/AssetCatalogParserTests/AssetCatalogParserTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ struct AssetCatalogParserTests {
66
@Test func testParseAssets() throws {
77
let archivePath = try #require(Bundle.module.path(forResource: "test", ofType: "xcarchive"))
88
let url = URL(filePath: "\(archivePath)/Products/Applications/DemoApp.app/Assets.car")
9-
let results = AssetUtil.disect(file: url)
9+
let results = AssetUtil.disect(file: url, outputURL: URL(filePath: "\(archivePath)/ParsedAssets"))
1010
#expect(results.count == 2)
1111
}
1212
}

apple-catalog-parsing/src/asset_catalog.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ pub enum Error {
1111
}
1212

1313
extern "C" {
14-
fn swift_inspect_asset_catalog(msg: *const std::os::raw::c_char);
14+
fn swift_inspect_asset_catalog(
15+
catalog_path: *const std::os::raw::c_char,
16+
output_path: *const std::os::raw::c_char,
17+
);
1518
}
1619

1720
/// This calls out to Swift code that uses Apple APIs to convert the contents
@@ -20,17 +23,19 @@ extern "C" {
2023
/// as duplicate image detection, xray, and image optimization insights.
2124
/// The path should be in an xcarchive file, results are written
2225
/// to a JSON file in the xcarchive’s ParsedAssets directory.
23-
pub fn inspect_asset_catalog<P>(path: P) -> Result<(), Error>
26+
pub fn inspect_asset_catalog<P>(catalog_path: P, output_path: P) -> Result<(), Error>
2427
where
2528
P: AsRef<Path>,
2629
{
27-
let c_string = CString::new(path.as_ref().as_os_str().as_bytes())?;
28-
let string_ptr = c_string.as_ptr();
30+
let catalog_c_string = CString::new(catalog_path.as_ref().as_os_str().as_bytes())?;
31+
let output_path_c_string = CString::new(output_path.as_ref().as_os_str().as_bytes())?;
32+
let catalog_string_ptr = catalog_c_string.as_ptr();
33+
let output_string_ptr = output_path_c_string.as_ptr();
2934
unsafe {
3035
// The string pointed to is immutable, in Swift we cannot change it.
3136
// We ensure this by using "UnsafePointer<CChar>" in Swift which is
3237
// immutable (as opposed to "UnsafeMutablePointer<CChar>").
33-
swift_inspect_asset_catalog(string_ptr);
38+
swift_inspect_asset_catalog(catalog_string_ptr, output_string_ptr);
3439
}
3540
Ok(())
3641
}

src/commands/mobile_app/upload.rs

Lines changed: 120 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::fs;
44
use std::io::Write as _;
55
#[cfg(not(windows))]
66
use std::os::unix::fs::PermissionsExt as _;
7-
use std::path::Path;
7+
use std::path::{Path, PathBuf};
88

99
use anyhow::{anyhow, bail, Context as _, Result};
1010
use clap::{Arg, ArgAction, ArgMatches, Command};
@@ -13,6 +13,7 @@ use itertools::Itertools as _;
1313
use log::{debug, info, warn};
1414
use sha1_smol::Digest;
1515
use symbolic::common::ByteView;
16+
use walkdir::WalkDir;
1617
use zip::write::SimpleFileOptions;
1718
use zip::{DateTime, ZipWriter};
1819

@@ -21,7 +22,6 @@ use crate::config::Config;
2122
use crate::utils::args::ArgExt as _;
2223
use crate::utils::chunks::{upload_chunks, Chunk, ASSEMBLE_POLL_INTERVAL};
2324
use crate::utils::fs::get_sha1_checksums;
24-
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
2525
use crate::utils::fs::TempDir;
2626
use crate::utils::fs::TempFile;
2727
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
@@ -95,19 +95,14 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
9595
let byteview = ByteView::open(path)?;
9696
debug!("Loaded file with {} bytes", byteview.len());
9797

98-
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
99-
if is_apple_app(path) {
100-
handle_asset_catalogs(path);
101-
}
102-
10398
validate_is_mobile_app(path, &byteview)?;
10499

105100
let normalized_zip = if path.is_file() {
106101
debug!("Normalizing file: {}", path.display());
107102
handle_file(path, &byteview)?
108103
} else if path.is_dir() {
109104
debug!("Normalizing directory: {}", path.display());
110-
normalize_directory(path).with_context(|| {
105+
handle_directory(path).with_context(|| {
111106
format!(
112107
"Failed to generate uploadable bundle for directory {}",
113108
path.display()
@@ -187,9 +182,9 @@ fn handle_file(path: &Path, byteview: &ByteView) -> Result<TempFile> {
187182
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
188183
if is_zip_file(byteview) && is_ipa_file(byteview)? {
189184
debug!("Converting IPA file to XCArchive structure");
190-
let temp_dir = TempDir::create()?;
191-
return ipa_to_xcarchive(path, byteview, &temp_dir)
192-
.and_then(|path| normalize_directory(&path))
185+
let archive_temp_dir = TempDir::create()?;
186+
return ipa_to_xcarchive(path, byteview, &archive_temp_dir)
187+
.and_then(|path| handle_directory(&path))
193188
.with_context(|| format!("Failed to process IPA file {}", path.display()));
194189
}
195190

@@ -276,38 +271,45 @@ fn normalize_file(path: &Path, bytes: &[u8]) -> Result<TempFile> {
276271
Ok(temp_file)
277272
}
278273

274+
fn sort_entries(path: &Path) -> Result<std::vec::IntoIter<(PathBuf, PathBuf)>> {
275+
Ok(WalkDir::new(path)
276+
.follow_links(true)
277+
.into_iter()
278+
.filter_map(Result::ok)
279+
.filter(|entry| entry.path().is_file())
280+
.map(|entry| {
281+
let entry_path = entry.into_path();
282+
let relative_path = entry_path.strip_prefix(path)?.to_owned();
283+
Ok((entry_path, relative_path))
284+
})
285+
.collect::<Result<Vec<_>>>()?
286+
.into_iter()
287+
.sorted_by(|(_, a), (_, b)| a.cmp(b)))
288+
}
289+
290+
fn handle_directory(path: &Path) -> Result<TempFile> {
291+
let temp_dir = TempDir::create()?;
292+
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
293+
if is_apple_app(path) {
294+
handle_asset_catalogs(path, temp_dir.path());
295+
}
296+
normalize_directory(path, temp_dir.path())
297+
}
298+
279299
// For XCArchive directories, we'll zip the entire directory
280-
fn normalize_directory(path: &Path) -> Result<TempFile> {
300+
fn normalize_directory(path: &Path, parsed_assets_path: &Path) -> Result<TempFile> {
281301
debug!("Creating normalized zip for directory: {}", path.display());
282302

283303
let temp_file = TempFile::create()?;
284304
let mut zip = ZipWriter::new(temp_file.open()?);
285305

286306
let mut file_count = 0;
307+
let directory_name = path.file_name().expect("Failed to get basename");
287308

288309
// Collect and sort entries for deterministic ordering
289310
// This is important to ensure stable sha1 checksums for the zip file as
290311
// an optimization is used to avoid re-uploading the same chunks if they're already on the server.
291-
let entries = walkdir::WalkDir::new(path)
292-
.follow_links(true)
293-
.into_iter()
294-
.filter_map(Result::ok)
295-
.filter(|entry| entry.path().is_file())
296-
.map(|entry| {
297-
let entry_path = entry.into_path();
298-
let relative_path = entry_path
299-
.strip_prefix(path.parent().ok_or_else(|| {
300-
anyhow!(
301-
"Cannot determine parent directory for path: {}",
302-
path.display()
303-
)
304-
})?)?
305-
.to_owned();
306-
Ok((entry_path, relative_path))
307-
})
308-
.collect::<Result<Vec<_>>>()?
309-
.into_iter()
310-
.sorted_by(|(_, a), (_, b)| a.cmp(b));
312+
let entries = sort_entries(path)?;
311313

312314
// Need to set the last modified time to a fixed value to ensure consistent checksums
313315
// This is important as an optimization to avoid re-uploading the same chunks if they're already on the server
@@ -317,18 +319,47 @@ fn normalize_directory(path: &Path) -> Result<TempFile> {
317319
.last_modified_time(DateTime::default());
318320

319321
for (entry_path, relative_path) in entries {
320-
debug!("Adding file to zip: {}", relative_path.display());
322+
let zip_path = format!(
323+
"{}/{}",
324+
directory_name.to_string_lossy(),
325+
relative_path.to_string_lossy()
326+
);
327+
debug!("Adding file to zip: {}", zip_path);
321328

322329
#[cfg(not(windows))]
323330
// On Unix, we need to preserve the file permissions.
324331
let options = options.unix_permissions(fs::metadata(&entry_path)?.permissions().mode());
325332

326-
zip.start_file(relative_path.to_string_lossy(), options)?;
333+
zip.start_file(zip_path, options)?;
327334
let file_byteview = ByteView::open(&entry_path)?;
328335
zip.write_all(file_byteview.as_slice())?;
329336
file_count += 1;
330337
}
331338

339+
// Add parsed assets to the zip in a "ParsedAssets" directory
340+
if parsed_assets_path.exists() {
341+
debug!(
342+
"Adding parsed assets from: {}",
343+
parsed_assets_path.display()
344+
);
345+
346+
let parsed_assets_entries = sort_entries(parsed_assets_path)?;
347+
348+
for (entry_path, relative_path) in parsed_assets_entries {
349+
let zip_path = format!(
350+
"{}/ParsedAssets/{}",
351+
directory_name.to_string_lossy(),
352+
relative_path.to_string_lossy()
353+
);
354+
debug!("Adding parsed asset to zip: {}", zip_path);
355+
356+
zip.start_file(zip_path, options)?;
357+
let file_byteview = ByteView::open(&entry_path)?;
358+
zip.write_all(file_byteview.as_slice())?;
359+
file_count += 1;
360+
}
361+
}
362+
332363
zip.finish()?;
333364
debug!(
334365
"Successfully created normalized zip for directory with {} files",
@@ -470,12 +501,66 @@ mod tests {
470501
fs::create_dir_all(test_dir.join("Products"))?;
471502
fs::write(test_dir.join("Products").join("app.txt"), "test content")?;
472503

473-
let result_zip = normalize_directory(&test_dir)?;
504+
let result_zip = normalize_directory(&test_dir, temp_dir.path())?;
474505
let zip_file = fs::File::open(result_zip.path())?;
475506
let mut archive = ZipArchive::new(zip_file)?;
476507
let file = archive.by_index(0)?;
477508
let file_path = file.name();
478509
assert_eq!(file_path, "MyApp.xcarchive/Products/app.txt");
479510
Ok(())
480511
}
512+
513+
#[test]
514+
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
515+
fn test_xcarchive_upload_includes_parsed_assets() -> Result<()> {
516+
// Test that XCArchive uploads include parsed asset catalogs
517+
let xcarchive_path = Path::new("tests/integration/_fixtures/mobile_app/archive.xcarchive");
518+
519+
// Process the XCArchive directory
520+
let result = handle_directory(xcarchive_path)?;
521+
522+
// Verify the resulting zip contains parsed assets
523+
let zip_file = fs::File::open(result.path())?;
524+
let mut archive = ZipArchive::new(zip_file)?;
525+
526+
let mut has_parsed_assets = false;
527+
for i in 0..archive.len() {
528+
let file = archive.by_index(i)?;
529+
let file_name = file.enclosed_name().ok_or(anyhow!("Failed to get file name"))?;
530+
if file_name.to_string_lossy().contains("ParsedAssets") {
531+
has_parsed_assets = true;
532+
break;
533+
}
534+
}
535+
536+
assert!(has_parsed_assets, "XCArchive upload should include parsed asset catalogs");
537+
Ok(())
538+
}
539+
540+
#[test]
541+
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
542+
fn test_ipa_upload_includes_parsed_assets() -> Result<()> {
543+
// Test that IPA uploads handle missing asset catalogs gracefully
544+
let ipa_path = Path::new("tests/integration/_fixtures/mobile_app/ipa_with_asset.ipa");
545+
let byteview = ByteView::open(ipa_path)?;
546+
547+
// Process the IPA file - this should work even without asset catalogs
548+
let result = handle_file(ipa_path, &byteview)?;
549+
550+
let zip_file = fs::File::open(result.path())?;
551+
let mut archive = ZipArchive::new(zip_file)?;
552+
553+
let mut has_parsed_assets = false;
554+
for i in 0..archive.len() {
555+
let file = archive.by_index(i)?;
556+
let file_name = file.enclosed_name().ok_or(anyhow!("Failed to get file name"))?;
557+
if file_name.to_string_lossy().contains("ParsedAssets") {
558+
has_parsed_assets = true;
559+
break;
560+
}
561+
}
562+
563+
assert!(has_parsed_assets, "XCArchive upload should include parsed asset catalogs");
564+
Ok(())
565+
}
481566
}

src/utils/mobile_app/apple.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ use std::io::Cursor;
1212
use walkdir::WalkDir;
1313
use zip::ZipArchive;
1414

15-
pub fn handle_asset_catalogs(path: &Path) {
15+
pub fn handle_asset_catalogs(archive_path: &Path, output_path: &Path) {
1616
// Find all asset catalogs
17-
let cars = find_car_files(path);
17+
let cars = find_car_files(archive_path);
1818
for car in &cars {
19-
if let Err(e) = apple_catalog_parsing::inspect_asset_catalog(car) {
19+
if let Err(e) =
20+
apple_catalog_parsing::inspect_asset_catalog(car, &output_path.to_path_buf())
21+
{
2022
eprintln!("Failed to inspect asset catalog {}: {e}", car.display());
2123
}
2224
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
```
2+
$ sentry-cli mobile-app upload tests/integration/_fixtures/mobile_app/archive.xcarchive
3+
? success
4+
[..]WARN[..]EXPERIMENTAL: The mobile-app subcommand is experimental. The command is subject to breaking changes and may be removed without notice in any release.
5+
Nothing to upload, all files are on the server
6+
Successfully uploaded 1 file to Sentry
7+
- tests/integration/_fixtures/mobile_app/archive.xcarchive
8+
9+
```

0 commit comments

Comments
 (0)