diff --git a/src/api/data_types/chunking/upload/capability.rs b/src/api/data_types/chunking/upload/capability.rs index aa9f3fda8a..729a5e4611 100644 --- a/src/api/data_types/chunking/upload/capability.rs +++ b/src/api/data_types/chunking/upload/capability.rs @@ -30,6 +30,9 @@ pub enum ChunkUploadCapability { /// Upload of il2cpp line mappings Il2Cpp, + /// Upload of Dart symbol maps + DartSymbolMap, + /// Upload of preprod artifacts PreprodArtifacts, @@ -52,6 +55,7 @@ impl<'de> Deserialize<'de> for ChunkUploadCapability { "sources" => ChunkUploadCapability::Sources, "bcsymbolmaps" => ChunkUploadCapability::BcSymbolmap, "il2cpp" => ChunkUploadCapability::Il2Cpp, + "dartsymbolmap" => ChunkUploadCapability::DartSymbolMap, "preprod_artifacts" => ChunkUploadCapability::PreprodArtifacts, _ => ChunkUploadCapability::Unknown, }) diff --git a/src/commands/dart_symbol_map/mod.rs b/src/commands/dart_symbol_map/mod.rs new file mode 100644 index 0000000000..24dd414c98 --- /dev/null +++ b/src/commands/dart_symbol_map/mod.rs @@ -0,0 +1,46 @@ +use anyhow::Result; +use clap::{ArgMatches, Args, Command, Parser as _, Subcommand}; + +pub mod upload; + +const GROUP_ABOUT: &str = "Manage Dart/Flutter symbol maps for Sentry."; +const UPLOAD_ABOUT: &str = + "Upload a Dart/Flutter symbol map (dartsymbolmap) for deobfuscating Dart exception types."; +const UPLOAD_LONG_ABOUT: &str = + "Upload a Dart/Flutter symbol map (dartsymbolmap) for deobfuscating Dart exception types.{n}{n}Examples:{n} sentry-cli dart-symbol-map upload --org my-org --project my-proj path/to/dartsymbolmap.json path/to/debug/file{n}{n}The mapping must be a JSON array of strings with an even number of entries (pairs).{n}The debug file must contain exactly one Debug ID."; + +#[derive(Args)] +pub(super) struct DartSymbolMapArgs { + #[command(subcommand)] + pub(super) subcommand: DartSymbolMapSubcommand, +} + +#[derive(Subcommand)] +#[command(about = GROUP_ABOUT)] +pub(super) enum DartSymbolMapSubcommand { + #[command(about = UPLOAD_ABOUT)] + #[command(long_about = UPLOAD_LONG_ABOUT)] + Upload(upload::DartSymbolMapUploadArgs), +} + +pub(super) fn make_command(command: Command) -> Command { + DartSymbolMapSubcommand::augment_subcommands( + command + .about(GROUP_ABOUT) + .subcommand_required(true) + .arg_required_else_help(true), + ) +} + +pub(super) fn execute(_: &ArgMatches) -> Result<()> { + let subcommand = match crate::commands::derive_parser::SentryCLI::parse().command { + crate::commands::derive_parser::SentryCLICommand::DartSymbolMap(DartSymbolMapArgs { + subcommand, + }) => subcommand, + _ => unreachable!("expected dart-symbol-map subcommand"), + }; + + match subcommand { + DartSymbolMapSubcommand::Upload(args) => upload::execute(args), + } +} diff --git a/src/commands/dart_symbol_map/upload.rs b/src/commands/dart_symbol_map/upload.rs new file mode 100644 index 0000000000..f4da7f17e4 --- /dev/null +++ b/src/commands/dart_symbol_map/upload.rs @@ -0,0 +1,180 @@ +use std::borrow::Cow; +use std::ffi::OsStr; +use std::fmt::{Display, Formatter, Result as FmtResult}; +use std::path::Path; + +use anyhow::{bail, Context as _, Result}; +use clap::Args; + +use crate::api::{Api, ChunkUploadCapability}; +use crate::config::Config; +use crate::constants::{DEFAULT_MAX_DIF_SIZE, DEFAULT_MAX_WAIT}; +use crate::utils::chunks::{upload_chunked_objects, Assemblable, ChunkOptions, Chunked}; +use crate::utils::dif::DifFile; +use symbolic::common::ByteView; +use symbolic::common::DebugId; + +struct DartSymbolMapObject<'a> { + bytes: &'a [u8], + name: &'a str, + debug_id: DebugId, +} + +impl<'a> AsRef<[u8]> for DartSymbolMapObject<'a> { + fn as_ref(&self) -> &[u8] { + self.bytes + } +} + +impl<'a> Display for DartSymbolMapObject<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + write!(f, "dartsymbolmap {}", self.name) + } +} + +impl<'a> Assemblable for DartSymbolMapObject<'a> { + fn name(&self) -> Cow<'_, str> { + Cow::Borrowed(self.name) + } + + fn debug_id(&self) -> Option { + Some(self.debug_id) + } +} + +#[derive(Args, Clone)] +pub(crate) struct DartSymbolMapUploadArgs { + #[arg(short = 'o', long = "org")] + #[arg(help = "The organization ID or slug.")] + pub(super) org: Option, + + #[arg(short = 'p', long = "project")] + #[arg(help = "The project ID or slug.")] + pub(super) project: Option, + + #[arg(value_name = "MAPPING")] + #[arg( + help = "Path to the dartsymbolmap JSON file (e.g. dartsymbolmap.json). Must be a JSON array of strings with an even number of entries (pairs)." + )] + pub(super) mapping: String, + + #[arg(value_name = "DEBUG_FILE")] + #[arg( + help = "Path to the corresponding debug file to extract the Debug ID from. The file must contain exactly one Debug ID." + )] + pub(super) debug_file: String, +} + +pub(super) fn execute(args: DartSymbolMapUploadArgs) -> Result<()> { + let mapping_path = &args.mapping; + let debug_file_path = &args.debug_file; + + // Extract Debug ID(s) from the provided debug file + let dif = DifFile::open_path(debug_file_path, None)?; + let mut ids: Vec = dif.ids().filter(|id| !id.is_nil()).collect(); + + // Ensure a single, unambiguous Debug ID + ids.sort(); + ids.dedup(); + match ids.len() { + 0 => bail!( + "No debug identifier found in the provided debug file ({}). Ensure the file contains an embedded Debug ID.", + debug_file_path + ), + 1 => { + let debug_id = ids.remove(0); + + // Validate the dartsymbolmap JSON: must be a JSON array of strings with even length + let mapping_file_bytes = ByteView::open(mapping_path) + .with_context(|| format!("Failed to read mapping file at {mapping_path}"))?; + let mapping_entries: Vec> = + serde_json::from_slice(mapping_file_bytes.as_ref()) + .context("Invalid dartsymbolmap: expected a JSON array of strings")?; + + if mapping_entries.len() % 2 != 0 { + bail!( + "Invalid dartsymbolmap: expected an even number of entries, got {}", + mapping_entries.len() + ); + } + + // Prepare upload object + let file_name = Path::new(mapping_path) + .file_name() + .and_then(OsStr::to_str) + .unwrap_or(mapping_path) + ; + + let mapping_len = mapping_file_bytes.len(); + let object = DartSymbolMapObject { + bytes: mapping_file_bytes.as_ref(), + name: file_name, + debug_id, + }; + + // Prepare chunked upload + let api = Api::current(); + // Resolve org and project like logs: prefer args, fallback to defaults + let config = Config::current(); + let (default_org, default_project) = config.get_org_and_project_defaults(); + let org = args + .org + .as_ref() + .or(default_org.as_ref()) + .ok_or_else(|| anyhow::anyhow!( + "No organization specified. Please specify an organization using the --org argument." + ))?; + let project = args + .project + .as_ref() + .or(default_project.as_ref()) + .ok_or_else(|| anyhow::anyhow!( + "No project specified. Use --project or set a default in config." + ))?; + let chunk_upload_options = api + .authenticated()? + .get_chunk_upload_options(org)? + .ok_or_else(|| anyhow::anyhow!( + "server does not support chunked uploading. Please update your Sentry server." + ))?; + + if !chunk_upload_options.supports(ChunkUploadCapability::DartSymbolMap) { + bail!( + "Server does not support uploading Dart symbol maps via chunked upload. Please update your Sentry server." + ); + } + + // Early file size check against server or default limits (same as debug files) + let effective_max_file_size = if chunk_upload_options.max_file_size > 0 { + chunk_upload_options.max_file_size + } else { + DEFAULT_MAX_DIF_SIZE + }; + + if (mapping_len as u64) > effective_max_file_size { + bail!( + "The dartsymbolmap '{}' exceeds the maximum allowed size ({} bytes > {} bytes).", + mapping_path, + mapping_len, + effective_max_file_size + ); + } + + let options = ChunkOptions::new(chunk_upload_options, org, project) + .with_max_wait(DEFAULT_MAX_WAIT); + + let chunked = Chunked::from(object, options.server_options().chunk_size as usize)?; + let (_uploaded, has_processing_errors) = upload_chunked_objects(&[chunked], options)?; + if has_processing_errors { + bail!("Some symbol maps did not process correctly"); + } + + Ok(()) + } + _ => bail!( + "Multiple debug identifiers found in the provided debug file ({}): {}. Please provide a file that contains a single Debug ID.", + debug_file_path, + ids.into_iter().map(|id| id.to_string()).collect::>().join(", ") + ), + } +} diff --git a/src/commands/derive_parser.rs b/src/commands/derive_parser.rs index 3d81b94733..c11242e667 100644 --- a/src/commands/derive_parser.rs +++ b/src/commands/derive_parser.rs @@ -2,6 +2,7 @@ use crate::utils::auth_token::AuthToken; use crate::utils::value_parsers::{auth_token_parser, kv_parser}; use clap::{command, ArgAction::SetTrue, Parser, Subcommand}; +use super::dart_symbol_map::DartSymbolMapArgs; use super::logs::LogsArgs; use super::send_metric::SendMetricArgs; @@ -35,4 +36,5 @@ pub(super) struct SentryCLI { pub(super) enum SentryCLICommand { Logs(LogsArgs), SendMetric(SendMetricArgs), + DartSymbolMap(DartSymbolMapArgs), } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a894619d9f..bd5c984b07 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -20,6 +20,7 @@ use crate::utils::update::run_sentrycli_update_nagger; use crate::utils::value_parsers::auth_token_parser; mod bash_hook; +mod dart_symbol_map; mod debug_files; mod deploys; mod derive_parser; @@ -71,6 +72,7 @@ macro_rules! each_subcommand { $mac!(send_envelope); $mac!(send_metric); $mac!(sourcemaps); + $mac!(dart_symbol_map); #[cfg(not(feature = "managed"))] $mac!(uninstall); #[cfg(not(feature = "managed"))] diff --git a/tests/integration/_cases/help/help-windows.trycmd b/tests/integration/_cases/help/help-windows.trycmd index bbdc458575..fdbe15c6dd 100644 --- a/tests/integration/_cases/help/help-windows.trycmd +++ b/tests/integration/_cases/help/help-windows.trycmd @@ -27,6 +27,7 @@ Commands: send-event Send a manual event to Sentry. send-envelope Send a stored envelope to Sentry. sourcemaps Manage sourcemaps for Sentry releases. + dart-symbol-map Manage Dart/Flutter symbol maps for Sentry. upload-proguard Upload ProGuard mapping files to a project. help Print this message or the help of the given subcommand(s) diff --git a/tests/integration/_cases/help/help.trycmd b/tests/integration/_cases/help/help.trycmd index eecaffb706..302bf0ac5c 100644 --- a/tests/integration/_cases/help/help.trycmd +++ b/tests/integration/_cases/help/help.trycmd @@ -27,6 +27,7 @@ Commands: send-event Send a manual event to Sentry. send-envelope Send a stored envelope to Sentry. sourcemaps Manage sourcemaps for Sentry releases. + dart-symbol-map Manage Dart/Flutter symbol maps for Sentry. uninstall Uninstall the sentry-cli executable. upload-proguard Upload ProGuard mapping files to a project. help Print this message or the help of the given subcommand(s) diff --git a/tests/integration/_fixtures/dart_symbol_map/dartsymbolmap-invalid.json b/tests/integration/_fixtures/dart_symbol_map/dartsymbolmap-invalid.json new file mode 100644 index 0000000000..78b2e3e87d --- /dev/null +++ b/tests/integration/_fixtures/dart_symbol_map/dartsymbolmap-invalid.json @@ -0,0 +1,5 @@ +[ + "MaterialApp", + "ex", + "Scaffold" +] diff --git a/tests/integration/_fixtures/dart_symbol_map/dartsymbolmap.json b/tests/integration/_fixtures/dart_symbol_map/dartsymbolmap.json new file mode 100644 index 0000000000..ced609f6b6 --- /dev/null +++ b/tests/integration/_fixtures/dart_symbol_map/dartsymbolmap.json @@ -0,0 +1,6 @@ +[ + "MaterialApp", + "ex", + "Scaffold", + "ey" +] diff --git a/tests/integration/_responses/dart_symbol_map/get-chunk-upload-no-debug-files.json b/tests/integration/_responses/dart_symbol_map/get-chunk-upload-no-debug-files.json new file mode 100644 index 0000000000..3ad65e968d --- /dev/null +++ b/tests/integration/_responses/dart_symbol_map/get-chunk-upload-no-debug-files.json @@ -0,0 +1,11 @@ +{ + "url": "organizations/wat-org/chunk-upload/", + "chunkSize": 8388608, + "chunksPerRequest": 64, + "maxFileSize": 2147483648, + "maxRequestSize": 33554432, + "concurrency": 8, + "hashAlgorithm": "sha1", + "compression": ["gzip"], + "accept": ["dartsymbolmap"] +} diff --git a/tests/integration/_responses/dart_symbol_map/get-chunk-upload.json b/tests/integration/_responses/dart_symbol_map/get-chunk-upload.json new file mode 100644 index 0000000000..e85f3f2fa8 --- /dev/null +++ b/tests/integration/_responses/dart_symbol_map/get-chunk-upload.json @@ -0,0 +1,11 @@ +{ + "url": "organizations/wat-org/chunk-upload/", + "chunkSize": 8388608, + "chunksPerRequest": 64, + "maxFileSize": 2147483648, + "maxRequestSize": 33554432, + "concurrency": 8, + "hashAlgorithm": "sha1", + "compression": ["gzip"], + "accept": ["debug_files", "dartsymbolmap"] +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index 62b68ea816..124a288743 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -24,6 +24,7 @@ mod test_utils; mod token_validation; mod uninstall; mod update; +mod upload_dart_symbol_map; mod upload_dif; mod upload_dsym; mod upload_proguard; diff --git a/tests/integration/upload_dart_symbol_map.rs b/tests/integration/upload_dart_symbol_map.rs new file mode 100644 index 0000000000..784ceeeda5 --- /dev/null +++ b/tests/integration/upload_dart_symbol_map.rs @@ -0,0 +1,104 @@ +use std::sync::atomic::{AtomicU8, Ordering}; + +use crate::integration::test_utils::AssertCommand; +use crate::integration::{MockEndpointBuilder, TestManager}; + +#[test] +fn command_upload_dart_symbol_map_missing_capability() { + // Server does not advertise `dartsymbolmap` capability → command should bail early. + TestManager::new() + .mock_endpoint( + MockEndpointBuilder::new("GET", "/api/0/organizations/wat-org/chunk-upload/") + .with_response_file("debug_files/get-chunk-upload.json"), + ) + .assert_cmd([ + "dart-symbol-map", + "upload", + "tests/integration/_fixtures/dart_symbol_map/dartsymbolmap.json", + // Use a fixture with a single Debug ID + "tests/integration/_fixtures/Sentry.Samples.Console.Basic.pdb", + ]) + .with_default_token() + .run_and_assert(AssertCommand::Failure); +} + +#[test] +fn command_upload_dart_symbol_map_chunk_upload_flow() { + // Happy path: server supports dartsymbolmap capability, file needs upload, then assembles to ok. + let call_count = AtomicU8::new(0); + + TestManager::new() + // Server advertises capability including `dartsymbolmap`. + .mock_endpoint( + MockEndpointBuilder::new("GET", "/api/0/organizations/wat-org/chunk-upload/") + .with_response_file("dart_symbol_map/get-chunk-upload.json"), + ) + // Accept chunk upload requests for the missing chunks; no validation needed here. + .mock_endpoint(MockEndpointBuilder::new( + "POST", + "/api/0/organizations/wat-org/chunk-upload/", + )) + // Assemble flow: 1) not_found (missingChunks), 2) created, 3) ok + .mock_endpoint( + MockEndpointBuilder::new( + "POST", + "/api/0/projects/wat-org/wat-project/files/difs/assemble/", + ) + .with_header_matcher("content-type", "application/json") + .with_response_fn(move |request| { + let body = request.body().expect("body should be readable"); + let body_json: serde_json::Value = serde_json::from_slice(body) + .expect("request body should be valid JSON"); + + // The request map has a single entry keyed by checksum; reuse it in responses. + let (checksum, _obj) = body_json + .as_object() + .and_then(|m| m.iter().next()) + .map(|(k, v)| (k.clone(), v.clone())) + .expect("assemble request must contain at least one object"); + + match call_count.fetch_add(1, Ordering::Relaxed) { + 0 => format!( + "{{\"{checksum}\":{{\"state\":\"not_found\",\"missingChunks\":[\"{checksum}\"]}}}}" + ) + .into(), + 1 => format!( + "{{\"{checksum}\":{{\"state\":\"created\",\"missingChunks\":[]}}}}" + ) + .into(), + 2 => format!( + "{{\"{checksum}\":{{\"state\":\"ok\",\"detail\":null,\"missingChunks\":[],\"dif\":{{\"id\":\"1\",\"uuid\":\"00000000-0000-0000-0000-000000000000\",\"debugId\":\"00000000-0000-0000-0000-000000000000\",\"objectName\":\"dartsymbolmap.json\",\"cpuName\":\"any\",\"headers\":{{\"Content-Type\":\"application/octet-stream\"}},\"size\":1,\"sha1\":\"{checksum}\",\"dateCreated\":\"1776-07-04T12:00:00.000Z\",\"data\":{{}}}}}}}}" + ) + .into(), + n => panic!( + "Only 3 calls to the assemble endpoint expected, but there were {}.", + n + 1 + ), + } + }) + .expect(3), + ) + .assert_cmd([ + "dart-symbol-map", + "upload", + "tests/integration/_fixtures/dart_symbol_map/dartsymbolmap.json", + // Use a fixture with a single Debug ID (embedded PDB) + "tests/integration/_fixtures/Sentry.Samples.Console.Basic.pdb", + ]) + .with_default_token() + .run_and_assert(AssertCommand::Success); +} + +#[test] +fn command_upload_dart_symbol_map_invalid_mapping() { + // Invalid mapping (odd number of entries) should fail before any HTTP calls. + TestManager::new() + .assert_cmd([ + "dart-symbol-map", + "upload", + "tests/integration/_fixtures/dart_symbol_map/dartsymbolmap-invalid.json", + "tests/integration/_fixtures/Sentry.Samples.Console.Basic.pdb", + ]) + .with_default_token() + .run_and_assert(AssertCommand::Failure); +}