Skip to main content

relay_server/endpoints/
minidump.rs

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
34/// The field name of a minidump in the multipart form-data upload.
35///
36/// Sentry requires
37const MINIDUMP_FIELD_NAME: &str = "upload_file_minidump";
38
39/// The field name of a view hierarchy file in the multipart form-data upload.
40/// It matches the expected file name of the view hierarchy, as outlined in RFC#33
41/// <https://github.com/getsentry/rfcs/blob/main/text/0033-view-hierarchy.md>
42const VIEW_HIERARCHY_FIELD_NAME: &str = "view-hierarchy.json";
43
44/// File name for a standalone minidump upload.
45///
46/// In contrast to the field name, this is used when a standalone minidump is uploaded not in a
47/// multipart request. The file name is later used to display the event attachment.
48const MINIDUMP_FILE_NAME: &str = "Minidump";
49
50/// Minidump attachments should have these magic bytes, little- and big-endian.
51const 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
55/// Magic bytes for gzip compressed minidump containers.
56const GZIP_MAGIC_HEADER: &[u8] = b"\x1F\x8B";
57/// Magic bytes for xz compressed minidump containers.
58const XZ_MAGIC_HEADER: &[u8] = b"\xFD\x37\x7A\x58\x5A\x00";
59/// Magic bytes for bzip2 compressed minidump containers.
60const BZIP2_MAGIC_HEADER: &[u8] = b"\x42\x5A\x68";
61/// Magic bytes for zstd compressed minidump containers.
62const ZSTD_MAGIC_HEADER: &[u8] = b"\x28\xB5\x2F\xFD";
63
64/// Longest magic header we recognize (XZ is 6 bytes).
65const MAGIC_PEEK: usize = 6;
66
67/// Content types by which standalone uploads can be recognized.
68const 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
74/// Peek the first bytes of `stream` and returns a decoding wrapper if necessary.
75///
76/// Returns raw minidump bytes if the stream is uncompressed, otherwise decompresses
77/// one of the minidump container formats we support for inline uploads.
78async 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
123/// Types of compression we support for minidump payloads.
124enum 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
148/// Tries to decode a minidump using any of the supported compression formats
149/// or returns the provided minidump payload untouched if no format where detected.
150///
151/// Returns an `Overflow` error if the decompressed size exceeds `max_size`.
152async 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            // we detected a compression container but failed to decode it
169            relay_log::trace!("invalid compression container");
170            Err(BadStoreRequest::InvalidCompression(err))
171        }
172    }
173}
174
175/// Removes any compression container file extensions from the minidump
176/// filename so it can be updated in the item. Otherwise, attachments that
177/// have been decoded would still show the extension in the UI, which is misleading.
178fn 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
185/// Extract a minidump from a nested multipart form.
186///
187/// This field is not a minidump (i.e. it doesn't start with the minidump magic header). It could be
188/// a multipart field containing a minidump; this happens in old versions of the Linux Electron SDK.
189///
190/// Unfortunately, the embedded multipart field is not recognized by the multipart parser as a
191/// multipart field containing a multipart body. For this case we will look if the field starts with
192/// a '--' and manually extract the boundary (which is what follows '--' up to the end of line) and
193/// manually construct a multipart with the detected boundary. If we can extract a multipart with an
194/// embedded minidump, then use that field.
195async 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    /// Put the item into the envelope as-is (default behavior).
216    Inline,
217    /// Upload the item to objectstore and put a placeholder into the envelope.
218    ///
219    /// The behavior for supersized items.
220    Upload,
221    /// Drop the item rather than uploading it or putting it in an envelope.
222    ///
223    /// Since we already check the quota in the endpoint an item can be dropped even before being
224    /// uploaded or put into an envelope.
225    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            // GPU dumps are smaller than minidumps. We still stream them under the same
242            // decision instead of inlining them into the envelope.
243            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    /// Information necessary to upload to the objectstore.
255    ///
256    /// This is optional since uploading to the objectstore might not be enabled for a project.
257    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                // Don't bubble up errors caused by large items unless it is the minidump itself.
271                Err(multer::Error::FieldSizeExceeded { .. }) if !is_minidump => Ok(None),
272                r => Ok(Some(r?)),
273            }
274        };
275
276        // If we have no upload context just fall back to the old behavior.
277        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                            // A failed minidump upload should cause the entire request to be rejected.
307                            Err(e) if is_minidump => Err(e),
308                            // A failed attachment upload should not cause the entire request to be rejected.
309                            Err(_) => Ok(None),
310                        }
311                    }
312                }
313            }
314            UploadDecision::Drop(limits) => {
315                // This is best effort, the item here does not yet have its content set hence size
316                // is not correct.
317                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
342/// Wrapper around [`upload_stream`] that decompresses minidumps if necessary.
343pub 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    // Doing these operations does not make sense if we already streamed the minidump to objectstore.
429    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); // decoding the minidump changes its size
445            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
456/// Creates an [UploadContext].
457async 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        // Note: In Proxy mode we should never make it here since the endpoint_fetch_config_enabled
476        // check should already fail.
477        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); // decoding changes its size
558                    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); // decoding the minidump changes its size
595            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    // The minidump can either be transmitted as the request body, or as
628    // `upload_file_minidump` in a multipart form-data/ request.
629    // Minidump request payloads do not have the same structure as usual events from other SDKs. The
630    // minidump can either be transmitted as request body, or as `upload_file_minidump` in a
631    // multipart formdata request.
632
633    // If something goes wrong before the envelope is created, this managed error ensures an
634    // outcome is emitted. This is a best effort outcome, in the sense that we also drop some
635    // attachments but since we know neither the amount or size we can't emit an accurate outcome
636    // for them.
637    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            // The GPU crash is a best-effort duplicate: a failure submitting it must
668            // not drop the CPU crash, which clients do not retry.
669            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    // Never respond with a 429 since clients often retry these
685    common::handle_managed_envelope(&state, envelope)
686        .await?
687        .ignore_rate_limits();
688
689    // The return here is only useful for consistency because the UE4 crash reporter doesn't
690    // care about it.
691    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        // Create a minidump that will decompress to 100 bytes
818        let minidump_data = b"xxxxxxxxxx".repeat(10);
819        let compressed = encode_gzip(&minidump_data)?;
820
821        // With a limit larger than the decompressed size, decoding should succeed
822        let result = decode_minidump(compressed.clone(), 200).await;
823        assert!(result.is_ok());
824        assert_eq!(result.unwrap().len(), 100);
825
826        // With a limit smaller than the decompressed size, decoding should fail with Overflow
827        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        // we expect the multipart body to contain
918        // * one arbitrary attachment from the user (a `config.json`)
919        // * two breadcrumb files
920        // * one event file
921        // * one form-data item
922        // * one view-hierarchy file
923        assert_eq!(6, items.len());
924
925        // `config.json` has no content-type. MIME-detection in later processing will assign this.
926        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        // the first breadcrumb buffer
934        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        // the second breadcrumb buffer is empty since we haven't reached our max in the first
942        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        // the msg-pack encoded event file
950        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        // the next item is the view-hierarchy file
961        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        // the last item is the form-data if any and contains a `guid` from the `crashpad_handler`
972        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}