1use axum::extract::{DefaultBodyLimit, Request};
2use axum::response::IntoResponse;
3use axum::routing::{MethodRouter, post};
4use bytes::Bytes;
5use futures::{self, Stream, StreamExt, TryStreamExt};
6use multer::{Field, Multipart};
7use relay_config::ConfigSnapshot;
8use relay_dynamic_config::Feature;
9use relay_event_schema::protocol::EventId;
10use relay_quotas::{DataCategory, RateLimits};
11use relay_system::Addr;
12use smallvec::smallvec;
13use std::convert::Infallible;
14use std::error::Error;
15use tokio::io::BufReader;
16use tokio_util::io::{ReaderStream, StreamReader};
17use tower_http::limit::RequestBodyLimitLayer;
18
19use crate::constants::{ITEM_NAME_BREADCRUMBS1, ITEM_NAME_BREADCRUMBS2, ITEM_NAME_EVENT};
20use crate::endpoints::common::{self, BadStoreRequest, TextResponse, upload_stream};
21use crate::envelope::{AttachmentType, ContentType, Envelope, Item, ItemType, Items};
22use crate::extractors::{RawContentType, RequestMeta};
23use crate::managed::{Managed, ManagedResult};
24use crate::middlewares;
25use crate::service::ServiceState;
26use crate::services::outcome::{DiscardAttachmentType, DiscardItemType, DiscardReason, Outcome};
27use crate::services::projects::project::ProjectState;
28use crate::services::upload::{ByteStream, ProjectContext, Upload};
29use crate::utils::{
30 self, AttachmentStrategy, SizeSplit, find_error_source, is_length_limit_error, peek_n,
31 read_bytes_into_item, read_field_into_item,
32};
33
34const MINIDUMP_FIELD_NAME: &str = "upload_file_minidump";
38
39const VIEW_HIERARCHY_FIELD_NAME: &str = "view-hierarchy.json";
43
44const MINIDUMP_FILE_NAME: &str = "Minidump";
49
50const MINIDUMP_MAGIC_HEADER_LE: &[u8] = b"MDMP";
52const MINIDUMP_MAGIC_HEADER_BE: &[u8] = b"PMDM";
53const MINIDUMP_MAGIC_HEADER_LENGTH: usize = MINIDUMP_MAGIC_HEADER_LE.len();
54
55const GZIP_MAGIC_HEADER: &[u8] = b"\x1F\x8B";
57const XZ_MAGIC_HEADER: &[u8] = b"\xFD\x37\x7A\x58\x5A\x00";
59const BZIP2_MAGIC_HEADER: &[u8] = b"\x42\x5A\x68";
61const ZSTD_MAGIC_HEADER: &[u8] = b"\x28\xB5\x2F\xFD";
63
64const MAGIC_PEEK: usize = 6;
66
67const MINIDUMP_RAW_CONTENT_TYPES: &[&str] = &["application/octet-stream", "application/x-dmp"];
69
70macro_rules! wrap_decode {
71 ($stream:expr, $decoder:ident) => {{ ReaderStream::new($decoder::new(BufReader::new(StreamReader::new($stream)))).boxed() }};
72}
73
74async fn decode_stream<S, E>(stream: S) -> std::io::Result<ByteStream>
79where
80 S: Stream<Item = Result<Bytes, E>> + Send + 'static,
81 E: Into<Box<dyn Error + Send + Sync>> + Send + 'static,
82{
83 use async_compression::tokio::bufread::{BzDecoder, GzipDecoder, XzDecoder, ZstdDecoder};
84
85 let stream = stream.map_err(std::io::Error::other);
86 let (head, stream) = utils::stream::peek_n(stream, MAGIC_PEEK).await?;
87 let decoded = match Compression::from(&head) {
88 Compression::None => stream.boxed(),
89 Compression::Zstd => wrap_decode!(stream, ZstdDecoder),
90 Compression::Gzip => wrap_decode!(stream, GzipDecoder),
91 Compression::Xz => wrap_decode!(stream, XzDecoder),
92 Compression::Bzip2 => wrap_decode!(stream, BzDecoder),
93 };
94 Ok(decoded)
95}
96
97async fn decode_and_validate_stream<S, E>(stream: S) -> Result<ByteStream, BadStoreRequest>
98where
99 S: Stream<Item = Result<Bytes, E>> + Send + 'static,
100 E: Into<Box<dyn Error + Send + Sync>> + Send + 'static,
101{
102 let stream = decode_stream(stream)
103 .await
104 .map_err(|_| BadStoreRequest::InvalidMinidump)?;
105
106 let (head, stream) = peek_n(stream, MINIDUMP_MAGIC_HEADER_LENGTH)
107 .await
108 .map_err(|_| BadStoreRequest::InvalidMinidump)?;
109
110 validate_minidump(&head)?;
111 Ok(stream.boxed())
112}
113
114fn validate_minidump(data: &[u8]) -> Result<(), BadStoreRequest> {
115 if !data.starts_with(MINIDUMP_MAGIC_HEADER_LE) && !data.starts_with(MINIDUMP_MAGIC_HEADER_BE) {
116 relay_log::trace!("invalid minidump file");
117 return Err(BadStoreRequest::InvalidMinidump);
118 }
119
120 Ok(())
121}
122
123enum Compression {
125 None,
126 Gzip,
127 Xz,
128 Bzip2,
129 Zstd,
130}
131
132impl Compression {
133 fn from(header: &[u8]) -> Self {
134 if header.starts_with(GZIP_MAGIC_HEADER) {
135 Self::Gzip
136 } else if header.starts_with(XZ_MAGIC_HEADER) {
137 Self::Xz
138 } else if header.starts_with(BZIP2_MAGIC_HEADER) {
139 Self::Bzip2
140 } else if header.starts_with(ZSTD_MAGIC_HEADER) {
141 Self::Zstd
142 } else {
143 Self::None
144 }
145 }
146}
147
148async fn decode_minidump(minidump_data: Bytes, max_size: usize) -> Result<Bytes, BadStoreRequest> {
153 if matches!(Compression::from(&minidump_data), Compression::None) {
154 return Ok(minidump_data);
155 }
156 let stream = futures::stream::once(async move { Ok::<_, Infallible>(minidump_data) });
157 let decoded = decode_stream(stream)
158 .await
159 .map_err(BadStoreRequest::InvalidCompression)?;
160
161 match utils::stream::split_by_size(decoded, max_size.saturating_add(1)).await {
162 Ok(SizeSplit::Small(decoded)) => Ok(decoded),
163 Ok(SizeSplit::Large(_)) => {
164 let item_type = DiscardItemType::Attachment(DiscardAttachmentType::Minidump);
165 Err(BadStoreRequest::ItemTooLarge(item_type))
166 }
167 Err(err) => {
168 relay_log::trace!("invalid compression container");
170 Err(BadStoreRequest::InvalidCompression(err))
171 }
172 }
173}
174
175fn remove_container_extension(filename: &str) -> &str {
179 [".gz", ".xz", ".bz2", ".zst"]
180 .into_iter()
181 .find_map(|suffix| filename.strip_suffix(suffix))
182 .unwrap_or(filename)
183}
184
185async fn extract_embedded_minidump(payload: Bytes) -> Result<Option<Bytes>, BadStoreRequest> {
196 let boundary = match utils::get_multipart_boundary(&payload) {
197 Some(boundary) => boundary,
198 None => return Ok(None),
199 };
200
201 let stream = futures::stream::once(async { Ok::<_, Infallible>(payload.clone()) });
202 let mut multipart = Multipart::new(stream, boundary);
203
204 while let Some(field) = multipart.next_field().await? {
205 if field.name() == Some(MINIDUMP_FIELD_NAME) {
206 return Ok(Some(field.bytes().await?));
207 }
208 }
209
210 Ok(None)
211}
212
213#[derive(Clone, Debug)]
214enum UploadDecision {
215 Inline,
217 Upload,
221 Drop(RateLimits),
226}
227
228struct UploadContext<'a> {
229 upload: &'a Addr<Upload>,
230 project: ProjectContext,
231 upload_attachments: UploadDecision,
232 upload_minidumps: UploadDecision,
233 inline_limit: usize,
234 gpu_crash_split: bool,
235}
236
237impl UploadContext<'_> {
238 fn upload_decision(&self, attachment_type: Option<AttachmentType>) -> &UploadDecision {
239 match attachment_type {
240 Some(AttachmentType::Attachment) => &self.upload_attachments,
241 Some(
244 AttachmentType::Minidump
245 | AttachmentType::NvGpuDump
246 | AttachmentType::NvShaderDebug,
247 ) => &self.upload_minidumps,
248 _ => &UploadDecision::Inline,
249 }
250 }
251}
252
253struct MinidumpAttachmentStrategy<'a> {
254 upload_context: Option<UploadContext<'a>>,
258}
259
260impl<'a> AttachmentStrategy for MinidumpAttachmentStrategy<'a> {
261 async fn add_to_item(
262 &self,
263 field: Field<'static>,
264 item: Managed<Item>,
265 config: &ConfigSnapshot,
266 ) -> Result<Option<Managed<Item>>, BadStoreRequest> {
267 let read_inline = async |field: Field<'static>, item: Managed<Item>| {
268 let is_minidump = matches!(item.attachment_type(), Some(AttachmentType::Minidump));
269 match read_field_into_item(field, item, config).await {
270 Err(multer::Error::FieldSizeExceeded { .. }) if !is_minidump => Ok(None),
272 r => Ok(Some(r?)),
273 }
274 };
275
276 let Some(ref upload_context) = self.upload_context else {
278 return read_inline(field, item).await;
279 };
280
281 match upload_context.upload_decision(item.attachment_type()) {
282 UploadDecision::Inline => read_inline(field, item).await,
283 UploadDecision::Upload => {
284 let content_type = field.content_type().map(|ct| ct.as_ref().to_owned());
285 let is_minidump = matches!(item.attachment_type(), Some(AttachmentType::Minidump));
286
287 match utils::stream::split_by_size(field, upload_context.inline_limit).await? {
288 SizeSplit::Small(bytes) => Ok(Some(read_bytes_into_item(
289 bytes,
290 item,
291 content_type.map(|ct| ct.parse().unwrap_or(ContentType::OctetStream)),
292 ))),
293 SizeSplit::Large(stream) => {
294 match upload_stream_checked(
295 stream,
296 content_type,
297 item,
298 config,
299 upload_context.project.clone(),
300 upload_context.upload,
301 "minidump",
302 )
303 .await
304 {
305 Ok(item) => Ok(Some(item)),
306 Err(e) if is_minidump => Err(e),
308 Err(_) => Ok(None),
310 }
311 }
312 }
313 }
314 UploadDecision::Drop(limits) => {
315 let _ = item.reject_err(Outcome::RateLimited(
318 limits.longest().and_then(|l| l.reason_code.clone()),
319 ));
320 Ok(None)
321 }
322 }
323 }
324
325 fn infer_type(&self, field: &Field) -> AttachmentType {
326 match field.file_name() {
327 Some(name) if name.ends_with(".nv-gpudmp") => return AttachmentType::NvGpuDump,
328 Some(name) if name.ends_with(".nvdbg") => return AttachmentType::NvShaderDebug,
329 _ => {}
330 }
331 match field.name().unwrap_or("") {
332 MINIDUMP_FIELD_NAME => AttachmentType::Minidump,
333 ITEM_NAME_BREADCRUMBS1 => AttachmentType::Breadcrumbs,
334 ITEM_NAME_BREADCRUMBS2 => AttachmentType::Breadcrumbs,
335 ITEM_NAME_EVENT => AttachmentType::EventPayload,
336 VIEW_HIERARCHY_FIELD_NAME => AttachmentType::ViewHierarchy,
337 _ => AttachmentType::Attachment,
338 }
339 }
340}
341
342pub async fn upload_stream_checked<S, E>(
344 stream: S,
345 content_type: Option<String>,
346 mut item: Managed<Item>,
347 config: &ConfigSnapshot,
348 project: ProjectContext,
349 upload: &Addr<Upload>,
350 referrer: &'static str,
351) -> Result<Managed<Item>, BadStoreRequest>
352where
353 S: futures::Stream<Item = Result<Bytes, E>> + Send + 'static,
354 E: Into<Box<dyn std::error::Error + Send + Sync>> + Send + 'static,
355{
356 if !matches!(item.attachment_type(), Some(AttachmentType::Minidump)) {
357 return upload_stream(
358 stream,
359 content_type,
360 item,
361 config,
362 project,
363 upload,
364 referrer,
365 )
366 .await
367 .map_err(BadStoreRequest::from);
368 }
369
370 let stream = match decode_and_validate_stream(stream).await {
371 Ok(decoded) => decoded,
372 Err(_) => {
373 let _ = item.reject_err(Outcome::Invalid(DiscardReason::InvalidMinidump));
374 return Err(BadStoreRequest::InvalidMinidump);
375 }
376 };
377
378 item.modify(|item, _| {
379 if let Some(filename) = item.filename() {
380 let new_filename = remove_container_extension(filename);
381 if new_filename != filename {
382 let new_filename = new_filename.to_owned();
383 item.set_filename(new_filename);
384 }
385 }
386 });
387 upload_stream(
388 stream,
389 Some(ContentType::Minidump.to_string()),
390 item,
391 config,
392 project,
393 upload,
394 referrer,
395 )
396 .await
397 .map_err(BadStoreRequest::from)
398}
399
400async fn multipart_to_items(
401 multipart: Multipart<'static>,
402 meta: &RequestMeta,
403 state: &ServiceState,
404 upload_context: Option<UploadContext<'_>>,
405) -> Result<Managed<Items>, BadStoreRequest> {
406 let minidump_attachment_strategy = MinidumpAttachmentStrategy { upload_context };
407 let config = state.config();
408
409 let mut items = utils::multipart_items(
410 multipart,
411 &config,
412 minidump_attachment_strategy,
413 meta,
414 state.outcome_aggregator(),
415 )
416 .await?;
417
418 let minidump_idx = items
419 .iter()
420 .position(|item| item.attachment_type() == Some(AttachmentType::Minidump))
421 .ok_or(BadStoreRequest::MissingMinidump)
422 .reject(&items)?;
423
424 let minidump_item = items
425 .get(minidump_idx)
426 .ok_or(BadStoreRequest::MissingMinidump)
427 .reject(&items)?;
428 if !minidump_item.is_attachment_ref() {
430 let payload = minidump_item.payload();
431 let payload = extract_embedded_minidump(payload.clone())
432 .await
433 .reject(&items)?
434 .unwrap_or(payload);
435 let payload = decode_minidump(payload, config.max_attachment_size())
436 .await
437 .reject(&items)?;
438
439 items.try_modify(|items, records| -> Result<(), BadStoreRequest> {
440 let minidump_item = items
441 .get_mut(minidump_idx)
442 .ok_or(BadStoreRequest::MissingMinidump)?;
443 minidump_item.set_payload(ContentType::Minidump, payload);
444 records.lenient(DataCategory::Attachment); if let Some(minidump_filename) = minidump_item.filename() {
446 minidump_item.set_filename(remove_container_extension(minidump_filename).to_owned())
447 }
448 validate_minidump(&minidump_item.payload())?;
449 Ok(())
450 })?;
451 }
452
453 Ok(items)
454}
455
456async fn upload_context<'a>(
458 meta: &RequestMeta,
459 state: &'a ServiceState,
460) -> Result<Option<UploadContext<'a>>, BadStoreRequest> {
461 let global_config = state.global_config_handle().current().unwrap_or_default();
462
463 if !global_config.options.endpoint_fetch_config_enabled {
464 return Ok(None);
465 }
466
467 let project = state
468 .project_cache_handle()
469 .ready(meta.public_key(), state.config().query_timeout())
470 .await
471 .ok_or(BadStoreRequest::ProjectUnavailable)?;
472
473 let project_config = match project.state() {
474 ProjectState::Enabled(info) => info.clone(),
475 ProjectState::Dummy => return Ok(None),
478 ProjectState::Disabled | ProjectState::Pending => {
479 return Err(BadStoreRequest::EventRejected(DiscardReason::ProjectId));
480 }
481 };
482
483 let scoping = project_config
484 .scoping(meta.public_key())
485 .ok_or(BadStoreRequest::EventRejected(DiscardReason::ProjectId))?;
486
487 let rate_limits = project.rate_limits().current_limits().check_with_quotas(
488 project_config.get_quotas(),
489 &scoping.item(DataCategory::Error),
490 );
491
492 let attachment_rate_limits = project.rate_limits().current_limits().check_with_quotas(
493 project_config.get_quotas(),
494 &scoping.item(DataCategory::Attachment),
495 );
496
497 let upload_minidumps = if !project_config.has_feature(Feature::MinidumpUploads) {
498 UploadDecision::Inline
499 } else if rate_limits.is_limited() {
500 UploadDecision::Drop(rate_limits.clone())
501 } else {
502 UploadDecision::Upload
503 };
504
505 let upload_attachments = if matches!(upload_minidumps, UploadDecision::Drop(_)) {
506 UploadDecision::Drop(rate_limits)
507 } else if attachment_rate_limits.is_limited() {
508 UploadDecision::Drop(attachment_rate_limits)
509 } else {
510 UploadDecision::Upload
511 };
512
513 Ok(Some(UploadContext {
514 upload: state.upload(),
515 project: ProjectContext {
516 scoping,
517 upstream: project_config.upstream.clone(),
518 retention: project_config.event_retention(),
519 },
520 upload_attachments,
521 upload_minidumps,
522 inline_limit: global_config.options.attachment_inline_limit,
523 gpu_crash_split: project_config.has_feature(Feature::NvGpuCrashSplit),
524 }))
525}
526
527async fn raw_minidump_to_item(
528 request: Request,
529 meta: &RequestMeta,
530 state: &ServiceState,
531 upload_context: Option<UploadContext<'_>>,
532) -> Result<Managed<Item>, BadStoreRequest> {
533 debug_assert!(!matches!(
534 upload_context.as_ref().map(|c| &c.upload_minidumps),
535 Some(UploadDecision::Drop(_))
536 ));
537
538 let mut item = Item::new(ItemType::Attachment);
539 item.set_filename(MINIDUMP_FILE_NAME);
540 item.set_attachment_type(AttachmentType::Minidump);
541 let mut item = Managed::with_meta_from_request_meta(meta, state.outcome_aggregator(), item);
542 if let Some(upload_context) = upload_context
543 && matches!(upload_context.upload_minidumps, UploadDecision::Upload)
544 {
545 let stream = request.into_body().into_data_stream();
546
547 match utils::stream::split_by_size(stream, upload_context.inline_limit)
548 .await
549 .map_err(|e| BadStoreRequest::InvalidBody(std::io::Error::other(e)))?
550 {
551 SizeSplit::Small(bytes) => {
552 let payload = decode_minidump(bytes, state.config().max_attachment_size())
553 .await
554 .reject(&item)?;
555 item.try_modify(|inner, records| -> Result<(), BadStoreRequest> {
556 inner.set_payload(ContentType::Minidump, payload);
557 records.lenient(DataCategory::Attachment); validate_minidump(&inner.payload())?;
559 Ok(())
560 })?;
561 }
562 SizeSplit::Large(stream) => {
563 let stream = decode_and_validate_stream(stream).await?;
564
565 item = upload_stream(
566 stream,
567 Some(ContentType::Minidump.to_string()),
568 item,
569 &state.config(),
570 upload_context.project,
571 upload_context.upload,
572 "minidump",
573 )
574 .await
575 .map_err(BadStoreRequest::from)?;
576 }
577 }
578 } else {
579 let minidump_data =
580 axum::body::to_bytes(request.into_body(), state.config().max_attachment_size())
581 .await
582 .map_err(|e| match find_error_source(&e, is_length_limit_error) {
583 Some(_) => BadStoreRequest::ItemTooLarge(DiscardItemType::Attachment(
584 DiscardAttachmentType::Minidump,
585 )),
586 None => BadStoreRequest::InvalidBody(std::io::Error::other(e)),
587 })?;
588
589 let payload = decode_minidump(minidump_data, state.config().max_attachment_size())
590 .await
591 .reject(&item)?;
592 item.try_modify(|inner, records| -> Result<(), BadStoreRequest> {
593 inner.set_payload(ContentType::Minidump, payload);
594 records.lenient(DataCategory::Attachment); validate_minidump(&inner.payload())?;
596 Ok(())
597 })?;
598 };
599
600 Ok(item)
601}
602
603async fn items(
604 upload_context: Option<UploadContext<'_>>,
605 state: &ServiceState,
606 meta: &RequestMeta,
607 content_type: RawContentType,
608 request: Request,
609) -> Result<Managed<Items>, BadStoreRequest> {
610 let items = if MINIDUMP_RAW_CONTENT_TYPES.contains(&content_type.as_ref()) {
611 raw_minidump_to_item(request, meta, state, upload_context)
612 .await?
613 .map(|item, _| smallvec![item])
614 } else {
615 let multipart = utils::multipart_from_request(request)?;
616 multipart_to_items(multipart, meta, state, upload_context).await?
617 };
618 Ok(items)
619}
620
621async fn handle(
622 state: ServiceState,
623 meta: RequestMeta,
624 content_type: RawContentType,
625 request: Request,
626) -> axum::response::Result<impl IntoResponse> {
627 let err = (DataCategory::Error, 1);
638 let managed_err = Managed::with_meta_from_request_meta(&meta, state.outcome_aggregator(), err);
639
640 let upload_context = upload_context(&meta, &state).await.reject(&managed_err)?;
641
642 if let Some(upload_context) = &upload_context
643 && let UploadDecision::Drop(limits) = &upload_context.upload_minidumps
644 {
645 let _ = managed_err.reject_err(Outcome::RateLimited(
646 limits.longest().and_then(|l| l.reason_code.clone()),
647 ));
648 return Ok(TextResponse(Some(EventId::new())));
649 }
650
651 let gpu_crash_split = upload_context
652 .as_ref()
653 .is_some_and(|ctx| ctx.gpu_crash_split);
654
655 let items = items(upload_context, &state, &meta, content_type, request)
656 .await
657 .reject(&managed_err)?;
658
659 let mut envelope = Managed::zip(managed_err, items).try_map(|(_, items), _| {
660 let event_id = common::event_id_from_items(&items)?.unwrap_or_default();
661 let envelope = Envelope::from_request(Some(event_id), meta).with_items(items);
662 Ok::<_, BadStoreRequest>(Box::new(envelope))
663 })?;
664 if gpu_crash_split {
665 let (cpu, gpu) = utils::gpu::split_crash(envelope);
666 if let Some(gpu) = gpu {
667 match common::handle_managed_envelope(&state, gpu).await {
670 Ok(handled) => {
671 handled.ignore_rate_limits();
672 }
673 Err(rejected) => relay_log::debug!(
674 error = &rejected.into_inner() as &dyn std::error::Error,
675 "failed to submit split-off GPU crash envelope",
676 ),
677 }
678 }
679 envelope = cpu;
680 }
681
682 let id = envelope.event_id();
683
684 common::handle_managed_envelope(&state, envelope)
686 .await?
687 .ignore_rate_limits();
688
689 Ok(TextResponse(id))
692}
693
694pub fn route(config: &ConfigSnapshot) -> MethodRouter<ServiceState> {
695 post(handle)
696 .route_layer(RequestBodyLimitLayer::new(
697 config.max_upload_size() + config.max_attachments_size(),
698 ))
699 .route_layer(DefaultBodyLimit::disable())
700 .route_layer(axum::middleware::from_fn(middlewares::content_length))
701}
702
703#[cfg(test)]
704mod tests {
705 use crate::envelope::ContentType;
706 use crate::utils::FormDataIter;
707 use axum::body::Body;
708 use bzip2::Compression as BzCompression;
709 use bzip2::write::BzEncoder;
710 use flate2::Compression as GzCompression;
711 use flate2::write::GzEncoder;
712 use liblzma::write::XzEncoder;
713 use relay_config::Config;
714 use std::io::Write;
715 use zstd::stream::Encoder as ZstdEncoder;
716
717 use super::*;
718
719 #[test]
720 fn test_validate_minidump() {
721 let be_minidump = b"PMDMxxxxxx";
722 assert!(validate_minidump(be_minidump).is_ok());
723
724 let le_minidump = b"MDMPxxxxxx";
725 assert!(validate_minidump(le_minidump).is_ok());
726
727 let garbage = b"xxxxxx";
728 assert!(validate_minidump(garbage).is_err());
729 }
730
731 type EncodeFunction = fn(&[u8]) -> Result<Bytes, Box<dyn std::error::Error>>;
732
733 fn encode_gzip(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
734 let mut encoder = GzEncoder::new(Vec::new(), GzCompression::default());
735 encoder.write_all(be_minidump)?;
736 let compressed = encoder.finish()?;
737 Ok(Bytes::from(compressed))
738 }
739 fn encode_bzip(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
740 let mut encoder = BzEncoder::new(Vec::new(), BzCompression::default());
741 encoder.write_all(be_minidump)?;
742 let compressed = encoder.finish()?;
743 Ok(Bytes::from(compressed))
744 }
745 fn encode_xz(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
746 let mut encoder = XzEncoder::new(Vec::new(), 6);
747 encoder.write_all(be_minidump)?;
748 let compressed = encoder.finish()?;
749 Ok(Bytes::from(compressed))
750 }
751 fn encode_zst(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
752 let mut encoder = ZstdEncoder::new(Vec::new(), 0)?;
753 encoder.write_all(be_minidump)?;
754 let compressed = encoder.finish()?;
755 Ok(Bytes::from(compressed))
756 }
757
758 fn stream_of(data: Bytes) -> impl Stream<Item = Result<Bytes, Infallible>> + Send + 'static {
759 futures::stream::once(async move { Ok(data) })
760 }
761
762 #[tokio::test]
763 async fn test_decode_and_validate_minidump() -> Result<(), Box<dyn std::error::Error>> {
764 let encoders: Vec<EncodeFunction> = vec![encode_gzip, encode_zst, encode_bzip, encode_xz];
765 for encoder in &encoders {
766 let be_minidump = b"PMDMxxxxxx";
767 let compressed = encoder(be_minidump)?;
768 assert!(
769 decode_and_validate_stream(stream_of(compressed))
770 .await
771 .is_ok()
772 );
773
774 let le_minidump = b"MDMPxxxxxx";
775 let compressed = encoder(le_minidump)?;
776 assert!(
777 decode_and_validate_stream(stream_of(compressed))
778 .await
779 .is_ok()
780 );
781
782 let garbage = b"xxxxxx";
783 let compressed = encoder(garbage)?;
784 assert!(matches!(
785 decode_and_validate_stream(stream_of(compressed)).await,
786 Err(BadStoreRequest::InvalidMinidump)
787 ));
788 }
789
790 let plain = Bytes::from_static(b"MDMPxxxxxx");
791 assert!(decode_and_validate_stream(stream_of(plain)).await.is_ok());
792
793 let plain = Bytes::from_static(b"xxxxxxxxxx");
794 assert!(matches!(
795 decode_and_validate_stream(stream_of(plain)).await,
796 Err(BadStoreRequest::InvalidMinidump)
797 ));
798
799 let short = stream_of(Bytes::from_static(b"MD"));
800 assert!(matches!(
801 decode_and_validate_stream(short).await,
802 Err(BadStoreRequest::InvalidMinidump)
803 ));
804
805 let chunked = futures::stream::iter([
806 Ok::<_, Infallible>(Bytes::from_static(b"MD")),
807 Ok(Bytes::from_static(b"MP")),
808 Ok(Bytes::from_static(b"rest")),
809 ]);
810 assert!(decode_and_validate_stream(chunked).await.is_ok());
811
812 Ok(())
813 }
814
815 #[tokio::test]
816 async fn test_decode_minidump_size_limit() -> Result<(), Box<dyn std::error::Error>> {
817 let minidump_data = b"xxxxxxxxxx".repeat(10);
819 let compressed = encode_gzip(&minidump_data)?;
820
821 let result = decode_minidump(compressed.clone(), 200).await;
823 assert!(result.is_ok());
824 assert_eq!(result.unwrap().len(), 100);
825
826 let result = decode_minidump(compressed, 50).await;
828 assert!(matches!(result, Err(BadStoreRequest::ItemTooLarge(_))));
829
830 Ok(())
831 }
832
833 #[test]
834 fn test_remove_container_extension() -> Result<(), Box<dyn std::error::Error>> {
835 assert_eq!(remove_container_extension("minidump"), "minidump");
836 assert_eq!(remove_container_extension("minidump.gz"), "minidump");
837 assert_eq!(remove_container_extension("minidump.bz2"), "minidump");
838 assert_eq!(remove_container_extension("minidump.xz"), "minidump");
839 assert_eq!(remove_container_extension("minidump.zst"), "minidump");
840 assert_eq!(remove_container_extension("minidump.dmp"), "minidump.dmp");
841 assert_eq!(
842 remove_container_extension("minidump.dmp.gz"),
843 "minidump.dmp"
844 );
845 assert_eq!(
846 remove_container_extension("minidump.dmp.bz2"),
847 "minidump.dmp"
848 );
849 assert_eq!(
850 remove_container_extension("minidump.dmp.xz"),
851 "minidump.dmp"
852 );
853 assert_eq!(
854 remove_container_extension("minidump.dmp.zst"),
855 "minidump.dmp"
856 );
857
858 Ok(())
859 }
860
861 #[tokio::test]
862 async fn test_minidump_multipart_attachments() {
863 let multipart_body: &[u8] =
864 b"-----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
865 Content-Disposition: form-data; name=\"guid\"\x0d\x0a\x0d\x0add46bb04-bb27-448c-aad0-0deb0c134bdb\x0d\x0a\
866 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
867 Content-Disposition: form-data; name=\"config.json\"; filename=\"config.json\"\x0d\x0a\x0d\x0a\
868 \"Sentry\": { \"Dsn\": \"https://ingest.us.sentry.io/xxxxxxx\", \"MaxBreadcrumbs\": 50, \"Debug\": true }\x0d\x0a\
869 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
870 Content-Disposition: form-data; name=\"__sentry-breadcrumb1\"; filename=\"__sentry-breadcrumb1\"\x0d\x0a\
871 Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
872 \x82\
873 \xa9timestamp\xb82024-03-12T16:59:33.069Z\
874 \xa7message\xb5default level is info\x0d\x0a\
875 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
876 Content-Disposition: form-data; name=\"__sentry-breadcrumb2\"; filename=\"__sentry-breadcrumb2\"\x0d\x0a\
877 Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
878 \x0d\x0a\
879 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
880 Content-Disposition: form-data; name=\"__sentry-event\"; filename=\"__sentry-event\"\x0d\x0a\
881 Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
882 \x82\xa5level\xa5fatal\xa8platform\xa6native\x0d\x0a\
883 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
884 Content-Disposition: form-data; name=\"view-hierarchy.json\"; filename=\"view-hierarchy.json\"\x0d\x0a\
885 Content-Type: application/json\x0d\x0a\x0d\x0a\
886 {\"rendering_system\":\"android_view_system\",\"windows\":[{\"type\":\"com.android.internal.policy.DecorView\",\"width\":768.0,\"height\":1280.0,\"x\":0.0,\"y\":0.0,\"visibility\":\"visible\",\"alpha\":1.0}]}\x0d\x0a\
887 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw-----\x0d\x0a";
888
889 let request = Request::builder()
890 .header(
891 "content-type",
892 "multipart/form-data; boundary=---MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---",
893 )
894 .body(Body::from(multipart_body)).unwrap();
895
896 let config = Config::default();
897 let config = config.current();
898
899 let request_meta = RequestMeta::new(
900 "https://a94ae32be2582e0bbd7a4cbb95971fee:@sentry.io/42"
901 .parse()
902 .unwrap(),
903 );
904 let multipart = utils::multipart_from_request(request).unwrap();
905 let items = utils::multipart_items(
906 multipart,
907 &config,
908 MinidumpAttachmentStrategy {
909 upload_context: None,
910 },
911 &request_meta,
912 &Addr::dummy(),
913 )
914 .await
915 .unwrap();
916
917 assert_eq!(6, items.len());
924
925 let item = &items[0];
927 assert_eq!(item.filename().unwrap(), "config.json");
928 assert!(item.content_type().is_none());
929 assert_eq!(item.ty(), &ItemType::Attachment);
930 assert_eq!(item.attachment_type().unwrap(), AttachmentType::Attachment);
931 assert_eq!(item.payload().len(), 95);
932
933 let item = &items[1];
935 assert_eq!(item.filename().unwrap(), "__sentry-breadcrumb1");
936 assert_eq!(item.content_type().unwrap(), ContentType::OctetStream);
937 assert_eq!(item.ty(), &ItemType::Attachment);
938 assert_eq!(item.attachment_type().unwrap(), AttachmentType::Breadcrumbs);
939 assert_eq!(item.payload().len(), 66);
940
941 let item = &items[2];
943 assert_eq!(item.filename().unwrap(), "__sentry-breadcrumb2");
944 assert_eq!(item.content_type().unwrap(), ContentType::OctetStream);
945 assert_eq!(item.ty(), &ItemType::Attachment);
946 assert_eq!(item.attachment_type().unwrap(), AttachmentType::Breadcrumbs);
947 assert_eq!(item.payload().len(), 0);
948
949 let item = &items[3];
951 assert_eq!(item.filename().unwrap(), "__sentry-event");
952 assert_eq!(item.content_type().unwrap(), ContentType::OctetStream);
953 assert_eq!(item.ty(), &ItemType::Attachment);
954 assert_eq!(
955 item.attachment_type().unwrap(),
956 AttachmentType::EventPayload
957 );
958 assert_eq!(item.payload().len(), 29);
959
960 let item = &items[4];
962 assert_eq!(item.filename().unwrap(), "view-hierarchy.json");
963 assert_eq!(item.content_type().unwrap(), ContentType::Json);
964 assert_eq!(item.ty(), &ItemType::Attachment);
965 assert_eq!(
966 item.attachment_type().unwrap(),
967 AttachmentType::ViewHierarchy
968 );
969 assert_eq!(item.payload().len(), 184);
970
971 let item = &items[5];
973 assert!(item.filename().is_none());
974 assert_eq!(item.content_type().unwrap(), ContentType::Text);
975 assert_eq!(item.ty(), &ItemType::FormData);
976 assert!(item.attachment_type().is_none());
977 let form_payload = item.payload();
978 let form_data_entry = FormDataIter::new(form_payload.as_ref()).next().unwrap();
979 assert_eq!(form_data_entry.key(), "guid");
980 assert_eq!(
981 form_data_entry.value(),
982 "dd46bb04-bb27-448c-aad0-0deb0c134bdb"
983 );
984 }
985}