Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 58 additions & 32 deletions src/utils/source_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,41 @@ fn url_to_bundle_path(url: &str) -> Result<String> {

#[cfg(test)]
mod tests {
use sha1_smol::Sha1;
use symbolic::debuginfo::sourcebundle::SourceFileType;

use crate::utils::file_upload::SourceFile;

use super::*;

/// Build a source bundle from the standard pair of sourcemap fixtures.
fn make_test_bundle() -> TempFile {
let projects_slice = &["wat-project".into()];
let context = BundleContext {
org: "wat-org",
projects: Some(projects_slice.into()),
release: None,
dist: None,
note: None,
};

let source_files = ["bundle.min.js.map", "vendor.min.js.map"]
.into_iter()
.map(|name| SourceFile {
url: format!("~/{name}"),
path: format!("tests/integration/_fixtures/{name}").into(),
contents: std::fs::read(format!("tests/integration/_fixtures/{name}"))
.unwrap()
.into(),
ty: SourceFileType::SourceMap,
headers: Default::default(),
messages: Default::default(),
already_uploaded: false,
})
.collect::<Vec<_>>();

build(context, &source_files, None).unwrap()
}

#[test]
fn test_url_to_bundle_path() {
assert_eq!(url_to_bundle_path("~/bar").unwrap(), "_/_/bar");
Expand Down Expand Up @@ -229,37 +257,35 @@ mod tests {

#[test]
fn build_deterministic() {
let projects_slice = &["wat-project".into()];
let context = BundleContext {
org: "wat-org",
projects: Some(projects_slice.into()),
release: None,
dist: None,
note: None,
};

let source_files = ["bundle.min.js.map", "vendor.min.js.map"]
.into_iter()
.map(|name| SourceFile {
url: format!("~/{name}"),
path: format!("tests/integration/_fixtures/{name}").into(),
contents: std::fs::read(format!("tests/integration/_fixtures/{name}"))
.unwrap()
.into(),
ty: SourceFileType::SourceMap,
headers: Default::default(),
messages: Default::default(),
already_uploaded: false,
})
.collect::<Vec<_>>();

let file = build(context, &source_files, None).unwrap();
let first = std::fs::read(make_test_bundle().path()).unwrap();
let second = std::fs::read(make_test_bundle().path()).unwrap();
assert_eq!(first, second, "bundle output should be deterministic");
}

let buf = std::fs::read(file.path()).unwrap();
let hash = Sha1::from(buf);
assert_eq!(
hash.digest().to_string(),
"f0e25ae149b711c510148e022ebc883ad62c7c4c"
);
/// Canary against silent compression-method changes from upstream bundle
/// writers. The allowlist is a starting bound, not a verified server
/// contract. We can widen it if a real incompatibility surfaces.
#[test]
fn build_compression_method_canary() {
let bundle = make_test_bundle();
let mut archive =
zip::ZipArchive::new(std::fs::File::open(bundle.path()).unwrap()).unwrap();

// Two source files plus the manifest.json that the source bundle
// writer adds automatically.
assert_eq!(archive.len(), 3, "unexpected bundle entry count");
for i in 0..archive.len() {
let entry = archive.by_index(i).unwrap();
let method = entry.compression();
assert!(
matches!(
method,
zip::CompressionMethod::Stored | zip::CompressionMethod::Deflated
),
"entry {:?} uses {method:?}, outside the current allowlist: \
verify backend compatibility before widening",
entry.name(),
);
}
Comment on lines +269 to +289
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 🚀

}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
112 changes: 64 additions & 48 deletions tests/integration/build/upload.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
use crate::integration::{AssertCommand, MockEndpointBuilder, TestManager};
use regex::bytes::Regex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::LazyLock;
use std::{fs, str};

use crate::integration::test_utils::chunk_upload;
use crate::integration::{AssertCommand, MockEndpointBuilder, TestManager};

#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
#[test]
Expand Down Expand Up @@ -105,26 +104,13 @@ fn command_build_upload_apk_invlid_sha() {
TestManager::new().register_trycmd_test("build/build-invalid-*-sha.trycmd");
}

/// This regex is used to extract the boundary from the content-type header.
/// We need to match the boundary, since it changes with each request.
/// The regex matches the format as specified in
/// https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html.
static CONTENT_TYPE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"^multipart\/form-data; boundary=(?<boundary>[\w'\(\)+,\-\.\/:=? ]{0,69}[\w'\(\)+,\-\.\/:=?])$"#
)
.expect("Regex is valid")
});

#[test]
/// This test simulates a full chunk upload (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_build_upload_apk_chunked() {
let is_first_assemble_call = AtomicBool::new(true);
let expected_chunk_body = fs::read("tests/integration/_expected_requests/build/apk_chunk.bin")
.expect("expected chunk body file should be present");

TestManager::new()
.mock_endpoint(
Expand All @@ -134,34 +120,35 @@ fn command_build_upload_apk_chunked() {
.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");
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");

// The CLI wraps the APK in a zip bundle with metadata.
// Verify the bundle is a valid zip containing the APK.
let chunk = decompressed.first().unwrap();
let cursor = std::io::Cursor::new(chunk);
let mut archive =
zip::ZipArchive::new(cursor).expect("chunk should be a valid zip");
let apk_entry = archive
.by_name("apk.apk")
.expect("bundle should contain the APK file");
let expected_size =
std::fs::metadata("tests/integration/_fixtures/build/apk.apk")
.expect("fixture file should exist")
.len();
assert_eq!(
content_type_headers.len(),
1,
"content-type header should be present exactly once, found {} times",
content_type_headers.len()
apk_entry.size(),
expected_size,
"APK size in bundle should match the fixture"
);
let content_type = content_type_headers[0].as_bytes();
let boundary = CONTENT_TYPE_REGEX
.captures(content_type)
.expect("content-type should match regex")
.name("boundary")
.expect("boundary should be present")
.as_bytes();
let boundary_str = str::from_utf8(boundary).expect("boundary should be valid utf-8");
let boundary_escaped = regex::escape(boundary_str);
let body_regex = Regex::new(&format!(
r#"^--{boundary_escaped}(?<chunk_body>(?s-u:.)*?)--{boundary_escaped}--\s*$"#
))
.expect("regex should be valid");
let body = request.body().expect("body should be readable");
let chunk_body = body_regex
.captures(body)
.expect("body should match regex")
.name("chunk_body")
.expect("chunk_body section should be present")
.as_bytes();
assert_eq!(chunk_body, expected_chunk_body);

vec![] // Client does not expect a response body
}),
)
Expand Down Expand Up @@ -212,13 +199,42 @@ fn command_build_upload_ipa_chunked() {
.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");
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");

// The CLI converts the IPA to an XCArchive structure and
// zips it with a metadata file. Verify the chunk is a valid
// zip containing the expected set of entries.
let chunk = decompressed.first().unwrap();
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(chunk))
.expect("chunk should be a valid zip");
let entry_names: std::collections::HashSet<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_owned())
.collect();
let expected_entries: std::collections::HashSet<String> = [
"archive.xcarchive/Info.plist",
"archive.xcarchive/Products/Applications/DemoApp.app/DemoApp",
"archive.xcarchive/Products/Applications/DemoApp.app/Info.plist",
"archive.xcarchive/Products/Applications/DemoApp.app/PkgInfo",
"archive.xcarchive/Products/Applications/DemoApp.app/_CodeSignature/CodeResources",
"archive.xcarchive/Products/Applications/DemoApp.app/embedded.mobileprovision",
".sentry-cli-metadata.txt",
]
.into_iter()
.map(String::from)
.collect();
assert_eq!(
content_type_headers.len(),
1,
"content-type header should be present exactly once, found {} times",
content_type_headers.len()
entry_names, expected_entries,
"IPA-derived xcarchive bundle should contain the expected entries",
);

vec![] // Client does not expect a response body
}),
)
Expand Down
Loading
Loading