use super::artifact::{Artifact, MAX_ARTIFACT_BYTES};
use crate::auth::{AuthCredential, send_account_authenticated_with_refresh};
use crate::{CliEnvironment, NativeDebugLookupOptions, NativeDebugUploadOptions, RuntimeError};
const MAX_MULTIPART_BYTES: usize = 128 * 1024 * 1024;
const MAX_MANIFEST_BYTES: usize = 256 * 1024;
const MAX_RESPONSE_BYTES: usize = 256 * 1024;
const MULTIPART_PART_OVERHEAD: usize = 512;
const UPLOAD_NEXT: &str =
"Native debug artifact upload accepted. Verify exact image UUID and architecture lookup.";
const LOOKUP_FOUND_NEXT: &str =
"Native debug artifact lookup matched. Verify issue-detail native symbolication.";
const LOOKUP_MISSING_NEXT: &str =
"No exact native debug artifact matched. Upload the release dSYM and retry lookup.";
pub(super) struct UploadReceipt {
pub(super) upload_id: String,
pub(super) artifact_count: u64,
}
pub(super) enum LookupResult {
Found(LookupArtifact),
Missing,
}
pub(super) struct LookupArtifact {
pub(super) artifact_id: String,
pub(super) upload_id: String,
pub(super) image_uuid: String,
pub(super) architecture: String,
pub(super) debug_file_sha256: String,
pub(super) debug_file_byte_size: u64,
pub(super) upload_status: String,
pub(super) created_at: String,
}
pub(super) async fn upload(
client: &reqwest::Client,
env: &CliEnvironment,
url: reqwest::Url,
options: &NativeDebugUploadOptions,
artifacts: &[Artifact],
) -> Result<UploadReceipt, RuntimeError> {
let manifest = serialize_manifest(options, artifacts)?;
validate_multipart_size(manifest.len(), artifacts)?;
let response = send_account_authenticated_with_refresh(client, env, |client, credential| {
client
.post(url.clone())
.bearer_auth(credential.token())
.multipart(upload_form(manifest.as_str(), artifacts))
})
.await
.map_err(request_error)?;
let (response, credential) = response;
let status = response.status().as_u16();
if status != 200 {
return Err(safe_api_error(status, &credential));
}
let body = bounded_body(response).await?;
parse_upload_response(body.as_str(), artifacts.len())
}
pub(super) async fn lookup(
client: &reqwest::Client,
env: &CliEnvironment,
mut url: reqwest::Url,
options: &NativeDebugLookupOptions,
) -> Result<LookupResult, RuntimeError> {
{
let _query = url
.query_pairs_mut()
.clear()
.append_pair("project_id", options.project_id.as_str())
.append_pair("release", options.release.as_str())
.append_pair("environment", options.environment.as_str())
.append_pair("service", options.service.as_str())
.append_pair("image_uuid", options.image_uuid.as_str())
.append_pair("architecture", options.architecture.as_str());
}
let response = send_account_authenticated_with_refresh(client, env, |client, credential| {
client.get(url.clone()).bearer_auth(credential.token())
})
.await
.map_err(request_error)?;
let (response, credential) = response;
let status = response.status().as_u16();
if status != 200 {
return Err(safe_api_error(status, &credential));
}
let body = bounded_body(response).await?;
parse_lookup_response(body.as_str(), options)
}
pub(super) fn native_artifact_url(base_url: &str) -> Result<reqwest::Url, RuntimeError> {
api_url(base_url, "/api/native-debug-artifacts")
}
pub(super) fn api_url(base_url: &str, path: &str) -> Result<reqwest::Url, RuntimeError> {
let mut url = reqwest::Url::parse(base_url).map_err(|_| transport_error())?;
let secure_transport = url.scheme() == "https"
|| url.scheme() == "http" && url.host_str().is_some_and(is_loopback_host);
if !secure_transport
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(transport_error());
}
url.set_path(path);
url.set_query(None);
Ok(url)
}
fn is_loopback_host(host: &str) -> bool {
host == "localhost"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback())
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct UploadManifest<'a> {
project_id: &'a str,
release: &'a str,
environment: &'a str,
service: &'a str,
artifact_type: &'static str,
validation: ManifestValidation,
artifacts: Vec<ManifestArtifact<'a>>,
}
#[derive(serde::Serialize)]
struct ManifestValidation {
status: &'static str,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ManifestArtifact<'a> {
image_uuid: &'a str,
architecture: &'static str,
debug_file: ManifestDebugFile<'a>,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ManifestDebugFile<'a> {
artifact_sha256: &'a str,
byte_size: u64,
}
fn serialize_manifest(
options: &NativeDebugUploadOptions,
artifacts: &[Artifact],
) -> Result<String, RuntimeError> {
let manifest = UploadManifest {
project_id: options.project_id.as_str(),
release: options.release.as_str(),
environment: options.environment.as_str(),
service: options.service.as_str(),
artifact_type: "apple_dsym_manifest",
validation: ManifestValidation { status: "ready" },
artifacts: artifacts
.iter()
.map(|artifact| ManifestArtifact {
image_uuid: artifact.image_uuid.as_str(),
architecture: artifact.architecture.as_str(),
debug_file: ManifestDebugFile {
artifact_sha256: artifact.sha256.as_str(),
byte_size: artifact.byte_size(),
},
})
.collect(),
};
let body = serde_json::to_string(&manifest).map_err(|_| invalid_artifact())?;
if body.len() > MAX_MANIFEST_BYTES {
return Err(invalid_artifact());
}
Ok(body)
}
fn validate_multipart_size(
manifest_size: usize,
artifacts: &[Artifact],
) -> Result<(), RuntimeError> {
let sizes = artifacts
.iter()
.map(|artifact| artifact.bytes.len())
.collect::<Vec<_>>();
if !multipart_size_allowed(manifest_size, sizes.as_slice()) {
return Err(invalid_artifact());
}
Ok(())
}
fn multipart_size_allowed(manifest_size: usize, artifact_sizes: &[usize]) -> bool {
let payload = artifact_sizes
.iter()
.try_fold(manifest_size, |total, size| total.checked_add(*size));
let overhead = artifact_sizes
.len()
.saturating_add(1)
.saturating_mul(MULTIPART_PART_OVERHEAD);
payload
.and_then(|payload| payload.checked_add(overhead))
.is_some_and(|total| total <= MAX_MULTIPART_BYTES)
}
fn upload_form(manifest: &str, artifacts: &[Artifact]) -> reqwest::multipart::Form {
let mut headers = reqwest::header::HeaderMap::new();
drop(headers.insert(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
));
let manifest_part = reqwest::multipart::Part::text(manifest.to_owned()).headers(headers);
let mut form = reqwest::multipart::Form::new().part("manifest", manifest_part);
for (index, artifact) in artifacts.iter().enumerate() {
form = form.part(
format!("debug_file_{index}"),
reqwest::multipart::Part::stream_with_length(
artifact.multipart_payload(),
artifact.byte_size(),
),
);
}
form
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct UploadResponse {
upload_id: String,
status: String,
artifact_count: u64,
next: String,
next_action: NextAction,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct NextAction {
code: String,
target: String,
}
pub(super) fn parse_upload_response(
body: &str,
expected_count: usize,
) -> Result<UploadReceipt, RuntimeError> {
let response = serde_json::from_str::<UploadResponse>(body).map_err(|_| invalid_response())?;
if !is_public_id(response.upload_id.as_str(), "nativeart_")
|| response.status != "uploaded"
|| usize::try_from(response.artifact_count).ok() != Some(expected_count)
|| response.next != UPLOAD_NEXT
|| response.next_action.code != "verify_native_debug_artifact_lookup"
|| response.next_action.target != "native_debug_artifact_lookup"
{
return Err(invalid_response());
}
Ok(UploadReceipt {
upload_id: response.upload_id,
artifact_count: response.artifact_count,
})
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LookupResponse {
artifact: Option<LookupArtifactDto>,
next: String,
next_action: NextAction,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LookupArtifactDto {
artifact_id: String,
upload_id: String,
project_id: String,
release: String,
environment: String,
service: String,
artifact_type: String,
image_uuid: String,
architecture: String,
debug_file_sha256: String,
debug_file_byte_size: u64,
upload_status: String,
created_at: String,
}
fn parse_lookup_response(
body: &str,
options: &NativeDebugLookupOptions,
) -> Result<LookupResult, RuntimeError> {
let response = serde_json::from_str::<LookupResponse>(body).map_err(|_| invalid_response())?;
if let Some(artifact) = response.artifact {
if response.next != LOOKUP_FOUND_NEXT
|| response.next_action.code != "verify_native_issue_symbolication"
|| response.next_action.target != "native_issue_symbolication"
|| !valid_lookup_artifact(&artifact, options)
{
return Err(invalid_response());
}
Ok(LookupResult::Found(LookupArtifact {
artifact_id: artifact.artifact_id,
upload_id: artifact.upload_id,
image_uuid: artifact.image_uuid,
architecture: artifact.architecture,
debug_file_sha256: artifact.debug_file_sha256,
debug_file_byte_size: artifact.debug_file_byte_size,
upload_status: artifact.upload_status,
created_at: artifact.created_at,
}))
} else {
if response.next != LOOKUP_MISSING_NEXT
|| response.next_action.code != "upload_native_debug_artifact"
|| response.next_action.target != "native_debug_artifact_upload"
{
return Err(invalid_response());
}
Ok(LookupResult::Missing)
}
}
fn valid_lookup_artifact(artifact: &LookupArtifactDto, options: &NativeDebugLookupOptions) -> bool {
is_public_id(artifact.artifact_id.as_str(), "nativeartifact_")
&& is_public_id(artifact.upload_id.as_str(), "nativeart_")
&& artifact.project_id == options.project_id
&& artifact.release == options.release
&& artifact.environment == options.environment
&& artifact.service == options.service
&& artifact.artifact_type == "apple_dsym"
&& artifact.image_uuid == options.image_uuid
&& artifact.architecture == options.architecture
&& is_lower_hex(artifact.debug_file_sha256.as_str(), 64)
&& artifact.debug_file_byte_size > 0
&& artifact.debug_file_byte_size <= u64::try_from(MAX_ARTIFACT_BYTES).unwrap_or(u64::MAX)
&& artifact.upload_status == "uploaded"
&& crate::render::is_rfc3339_utc(artifact.created_at.as_str())
}
fn is_public_id(value: &str, prefix: &str) -> bool {
value
.strip_prefix(prefix)
.is_some_and(|raw| is_lower_hex(raw, 32))
}
fn is_lower_hex(value: &str, length: usize) -> bool {
value.len() == length
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
}
pub(super) async fn bounded_body(mut response: reqwest::Response) -> Result<String, RuntimeError> {
if response.content_length().is_some_and(|length| {
usize::try_from(length).map_or(true, |length| length > MAX_RESPONSE_BYTES)
}) {
return Err(invalid_response());
}
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| transport_error())? {
if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES {
return Err(invalid_response());
}
body.extend_from_slice(&chunk);
}
String::from_utf8(body).map_err(|_| invalid_response())
}
fn request_error(error: RuntimeError) -> RuntimeError {
match error {
RuntimeError::MissingToken | RuntimeError::Unavailable { .. } => error,
RuntimeError::Api {
status,
auth_source,
auth_label,
..
} => RuntimeError::Api {
status,
body: safe_api_body(status),
auth_source,
auth_label,
},
RuntimeError::Cli(_)
| RuntimeError::Io(_)
| RuntimeError::Http(_)
| RuntimeError::StatusUnavailable { .. }
| RuntimeError::InvestigationResponseInvalid
| RuntimeError::NativeDebugArtifactInvalid
| RuntimeError::NativeDebugResponseInvalid
| RuntimeError::NativeDebugVerificationFailed => transport_error(),
}
}
fn safe_api_error(status: u16, credential: &AuthCredential) -> RuntimeError {
RuntimeError::Api {
status,
body: safe_api_body(status),
auth_source: credential.source(),
auth_label: credential.label(),
}
}
fn safe_api_body(status: u16) -> String {
let (error, code, next, action_code, target) = match status {
400 => (
"native debug-artifact request was rejected",
"validation_failed",
"check the artifact identity and request scope, then retry",
"fix_request",
"request",
),
422 => (
"native debug-artifact request was rejected",
"validation_failed",
"send manifest and debug_file_N multipart parts from LogBrew Apple release tooling",
"fix_request",
"request",
),
401 | 403 => (
"authentication is required",
"unauthorized",
"sign in and retry the native debug-artifact command",
"sign_in",
"auth",
),
404 => (
"native debug artifact was not found",
"not_found",
"check the exact project, release, environment, service, UUID, and architecture",
"check_resource",
"resource",
),
413 => (
"native debug-artifact payload is too large",
"payload_too_large",
"reduce the native debug-artifact upload below the documented size limits and retry",
"reduce_artifact_size",
"native_debug_artifact_upload",
),
429 => (
"native debug-artifact request is temporarily limited",
"rate_limited",
"retry the same native debug-artifact command later",
"retry_later",
"request",
),
500..=599 => (
"native debug-artifact service is unavailable",
"server_error",
"retry the same native debug-artifact command later",
"retry_later",
"request",
),
_ => (
"native debug-artifact request returned an unexpected status",
"unexpected_response",
"retry the native debug-artifact command",
"retry_request",
"request",
),
};
serde_json::json!({
"error": error,
"code": code,
"next": next,
"next_action": {"code": action_code, "target": target}
})
.to_string()
}
const fn invalid_artifact() -> RuntimeError {
RuntimeError::NativeDebugArtifactInvalid
}
const fn invalid_response() -> RuntimeError {
RuntimeError::NativeDebugResponseInvalid
}
const fn transport_error() -> RuntimeError {
RuntimeError::Unavailable {
message: "native debug-artifact request could not be completed",
next: "check network connectivity and retry the native debug-artifact command",
}
}
#[cfg(test)]
mod tests {
use super::{MAX_MULTIPART_BYTES, MULTIPART_PART_OVERHEAD, multipart_size_allowed};
#[test]
fn aggregate_bound_includes_multipart_framing() {
let manifest_size = 1024;
let framing = 4 * MULTIPART_PART_OVERHEAD;
let remaining = MAX_MULTIPART_BYTES - manifest_size - framing;
let first = 50 * 1024 * 1024;
let second = 50 * 1024 * 1024;
let third = remaining - first - second;
assert!(multipart_size_allowed(
manifest_size,
&[first, second, third]
));
assert!(!multipart_size_allowed(
manifest_size,
&[first, second, third + 1]
));
}
}