1use axum::RequestExt;
2use axum::extract::{DefaultBodyLimit, Request};
3use axum::response::IntoResponse;
4use axum::routing::{MethodRouter, post};
5use bytes::Bytes;
6use bzip2::read::BzDecoder;
7use flate2::read::GzDecoder;
8use liblzma::read::XzDecoder;
9use multer::Multipart;
10use relay_config::Config;
11use relay_event_schema::protocol::EventId;
12use std::convert::Infallible;
13use std::error::Error;
14use std::io::Cursor;
15use std::io::Read;
16use zstd::stream::Decoder as ZstdDecoder;
17
18use crate::constants::{ITEM_NAME_BREADCRUMBS1, ITEM_NAME_BREADCRUMBS2, ITEM_NAME_EVENT};
19use crate::endpoints::common::{self, BadStoreRequest, TextResponse};
20use crate::envelope::ContentType::Minidump;
21use crate::envelope::{AttachmentType, Envelope, Item, ItemType};
22use crate::extractors::{RawContentType, RequestMeta};
23use crate::service::ServiceState;
24use crate::utils::{self, ConstrainedMultipart};
25
26const MINIDUMP_FIELD_NAME: &str = "upload_file_minidump";
30
31const VIEW_HIERARCHY_FIELD_NAME: &str = "view-hierarchy.json";
35
36const MINIDUMP_FILE_NAME: &str = "Minidump";
41
42const MINIDUMP_MAGIC_HEADER_LE: &[u8] = b"MDMP";
44const MINIDUMP_MAGIC_HEADER_BE: &[u8] = b"PMDM";
45
46const GZIP_MAGIC_HEADER: &[u8] = b"\x1F\x8B";
48const XZ_MAGIC_HEADER: &[u8] = b"\xFD\x37\x7A\x58\x5A\x00";
50const BZIP2_MAGIC_HEADER: &[u8] = b"\x42\x5A\x68";
52const ZSTD_MAGIC_HEADER: &[u8] = b"\x28\xB5\x2F\xFD";
54
55const MINIDUMP_RAW_CONTENT_TYPES: &[&str] = &["application/octet-stream", "application/x-dmp"];
57
58fn validate_minidump(data: &[u8]) -> Result<(), BadStoreRequest> {
59 if !data.starts_with(MINIDUMP_MAGIC_HEADER_LE) && !data.starts_with(MINIDUMP_MAGIC_HEADER_BE) {
60 relay_log::trace!("invalid minidump file");
61 return Err(BadStoreRequest::InvalidMinidump);
62 }
63
64 Ok(())
65}
66
67fn run_decoder(decoder: &mut Box<dyn Read>) -> std::io::Result<Vec<u8>> {
69 let mut buffer = Vec::new();
70 decoder.read_to_end(&mut buffer)?;
71 Ok(buffer)
72}
73
74fn decoder_from(minidump_data: Bytes) -> Option<Box<dyn Read>> {
76 if minidump_data.starts_with(GZIP_MAGIC_HEADER) {
77 return Some(Box::new(GzDecoder::new(Cursor::new(minidump_data))));
78 } else if minidump_data.starts_with(XZ_MAGIC_HEADER) {
79 return Some(Box::new(XzDecoder::new(Cursor::new(minidump_data))));
80 } else if minidump_data.starts_with(BZIP2_MAGIC_HEADER) {
81 return Some(Box::new(BzDecoder::new(Cursor::new(minidump_data))));
82 } else if minidump_data.starts_with(ZSTD_MAGIC_HEADER) {
83 return match ZstdDecoder::new(Cursor::new(minidump_data)) {
84 Ok(decoder) => Some(Box::new(decoder)),
85 Err(ref err) => {
86 relay_log::error!(error = err as &dyn Error, "failed to create ZstdDecoder");
87 None
88 }
89 };
90 }
91
92 None
93}
94
95fn decode_minidump(minidump_data: Bytes) -> Result<Bytes, BadStoreRequest> {
98 match decoder_from(minidump_data.clone()) {
99 Some(mut decoder) => {
100 match run_decoder(&mut decoder) {
101 Ok(decoded) => Ok(Bytes::from(decoded)),
102 Err(err) => {
103 relay_log::trace!("invalid compression container");
105 Err(BadStoreRequest::InvalidCompressionContainer(err))
106 }
107 }
108 }
109 None => {
110 Ok(minidump_data)
113 }
114 }
115}
116
117fn remove_container_extension(filename: &str) -> &str {
121 [".gz", ".xz", ".bz2", ".zst"]
122 .into_iter()
123 .find_map(|suffix| filename.strip_suffix(suffix))
124 .unwrap_or(filename)
125}
126
127fn infer_attachment_type(field_name: Option<&str>, _file_name: &str) -> AttachmentType {
128 match field_name.unwrap_or("") {
129 MINIDUMP_FIELD_NAME => AttachmentType::Minidump,
130 ITEM_NAME_BREADCRUMBS1 => AttachmentType::Breadcrumbs,
131 ITEM_NAME_BREADCRUMBS2 => AttachmentType::Breadcrumbs,
132 ITEM_NAME_EVENT => AttachmentType::EventPayload,
133 VIEW_HIERARCHY_FIELD_NAME => AttachmentType::ViewHierarchy,
134 _ => AttachmentType::Attachment,
135 }
136}
137
138async fn extract_embedded_minidump(payload: Bytes) -> Result<Option<Bytes>, BadStoreRequest> {
149 let boundary = match utils::get_multipart_boundary(&payload) {
150 Some(boundary) => boundary,
151 None => return Ok(None),
152 };
153
154 let stream = futures::stream::once(async { Ok::<_, Infallible>(payload.clone()) });
155 let mut multipart = Multipart::new(stream, boundary);
156
157 while let Some(field) = multipart.next_field().await? {
158 if field.name() == Some(MINIDUMP_FIELD_NAME) {
159 return Ok(Some(field.bytes().await?));
160 }
161 }
162
163 Ok(None)
164}
165
166async fn extract_multipart(
167 multipart: ConstrainedMultipart,
168 meta: RequestMeta,
169 config: &Config,
170) -> Result<Box<Envelope>, BadStoreRequest> {
171 let mut items = multipart.items(infer_attachment_type, config).await?;
172
173 let minidump_item = items
174 .iter_mut()
175 .find(|item| item.attachment_type() == Some(&AttachmentType::Minidump))
176 .ok_or(BadStoreRequest::MissingMinidump)?;
177
178 let embedded_opt = extract_embedded_minidump(minidump_item.payload()).await?;
179 if let Some(embedded) = embedded_opt {
180 minidump_item.set_payload(Minidump, embedded);
181 }
182
183 minidump_item.set_payload(Minidump, decode_minidump(minidump_item.payload())?);
184
185 validate_minidump(&minidump_item.payload())?;
186
187 if let Some(minidump_filename) = minidump_item.filename() {
188 minidump_item.set_filename(remove_container_extension(minidump_filename).to_owned());
189 }
190
191 let event_id = common::event_id_from_items(&items)?.unwrap_or_else(EventId::new);
192 let mut envelope = Envelope::from_request(Some(event_id), meta);
193
194 for item in items {
195 envelope.add_item(item);
196 }
197
198 Ok(envelope)
199}
200
201fn extract_raw_minidump(data: Bytes, meta: RequestMeta) -> Result<Box<Envelope>, BadStoreRequest> {
202 let mut item = Item::new(ItemType::Attachment);
203
204 item.set_payload(Minidump, decode_minidump(data)?);
205 validate_minidump(&item.payload())?;
206 item.set_filename(MINIDUMP_FILE_NAME);
207 item.set_attachment_type(AttachmentType::Minidump);
208
209 let mut envelope = Envelope::from_request(Some(EventId::new()), meta);
211 envelope.add_item(item);
212 Ok(envelope)
213}
214
215async fn handle(
216 state: ServiceState,
217 meta: RequestMeta,
218 content_type: RawContentType,
219 request: Request,
220) -> axum::response::Result<impl IntoResponse> {
221 let envelope = if MINIDUMP_RAW_CONTENT_TYPES.contains(&content_type.as_ref()) {
227 extract_raw_minidump(request.extract().await?, meta)?
228 } else {
229 let multipart = request.extract_with_state(&state).await?;
230 extract_multipart(multipart, meta, state.config()).await?
231 };
232
233 let id = envelope.event_id();
234
235 match common::handle_envelope(&state, envelope).await {
237 Ok(_) | Err(BadStoreRequest::RateLimited(_)) => (),
238 Err(error) => return Err(error.into()),
239 };
240
241 Ok(TextResponse(id))
244}
245
246pub fn route(config: &Config) -> MethodRouter<ServiceState> {
247 post(handle).route_layer(DefaultBodyLimit::max(config.max_attachment_size()))
250}
251
252#[cfg(test)]
253mod tests {
254 use crate::envelope::ContentType;
255 use crate::utils::FormDataIter;
256 use axum::body::Body;
257 use bzip2::Compression as BzCompression;
258 use bzip2::write::BzEncoder;
259 use flate2::Compression as GzCompression;
260 use flate2::write::GzEncoder;
261 use liblzma::write::XzEncoder;
262 use relay_config::Config;
263 use std::io::Write;
264 use zstd::stream::Encoder as ZstdEncoder;
265
266 use super::*;
267
268 #[test]
269 fn test_validate_minidump() {
270 let be_minidump = b"PMDMxxxxxx";
271 assert!(validate_minidump(be_minidump).is_ok());
272
273 let le_minidump = b"MDMPxxxxxx";
274 assert!(validate_minidump(le_minidump).is_ok());
275
276 let garbage = b"xxxxxx";
277 assert!(validate_minidump(garbage).is_err());
278 }
279
280 type EncodeFunction = fn(&[u8]) -> Result<Bytes, Box<dyn std::error::Error>>;
281
282 fn encode_gzip(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
283 let mut encoder = GzEncoder::new(Vec::new(), GzCompression::default());
284 encoder.write_all(be_minidump)?;
285 let compressed = encoder.finish()?;
286 Ok(Bytes::from(compressed))
287 }
288 fn encode_bzip(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
289 let mut encoder = BzEncoder::new(Vec::new(), BzCompression::default());
290 encoder.write_all(be_minidump)?;
291 let compressed = encoder.finish()?;
292 Ok(Bytes::from(compressed))
293 }
294 fn encode_xz(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
295 let mut encoder = XzEncoder::new(Vec::new(), 6);
296 encoder.write_all(be_minidump)?;
297 let compressed = encoder.finish()?;
298 Ok(Bytes::from(compressed))
299 }
300 fn encode_zst(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
301 let mut encoder = ZstdEncoder::new(Vec::new(), 0)?;
302 encoder.write_all(be_minidump)?;
303 let compressed = encoder.finish()?;
304 Ok(Bytes::from(compressed))
305 }
306
307 #[test]
308 fn test_validate_encoded_minidump() -> Result<(), Box<dyn std::error::Error>> {
309 let encoders: Vec<EncodeFunction> = vec![encode_gzip, encode_zst, encode_bzip, encode_xz];
310
311 for encoder in &encoders {
312 let be_minidump = b"PMDMxxxxxx";
313 let compressed = encoder(be_minidump)?;
314 let mut decoder = decoder_from(compressed).unwrap();
315 assert!(run_decoder(&mut decoder).is_ok());
316
317 let le_minidump = b"MDMPxxxxxx";
318 let compressed = encoder(le_minidump)?;
319 let mut decoder = decoder_from(compressed).unwrap();
320 assert!(run_decoder(&mut decoder).is_ok());
321
322 let garbage = b"xxxxxx";
323 let compressed = encoder(garbage)?;
324 let mut decoder = decoder_from(compressed).unwrap();
325 let decoded = run_decoder(&mut decoder);
326 assert!(decoded.is_ok());
327 assert!(validate_minidump(&decoded.unwrap()).is_err());
328 }
329
330 Ok(())
331 }
332
333 #[test]
334 fn test_remove_container_extension() -> Result<(), Box<dyn std::error::Error>> {
335 assert_eq!(remove_container_extension("minidump"), "minidump");
336 assert_eq!(remove_container_extension("minidump.gz"), "minidump");
337 assert_eq!(remove_container_extension("minidump.bz2"), "minidump");
338 assert_eq!(remove_container_extension("minidump.xz"), "minidump");
339 assert_eq!(remove_container_extension("minidump.zst"), "minidump");
340 assert_eq!(remove_container_extension("minidump.dmp"), "minidump.dmp");
341 assert_eq!(
342 remove_container_extension("minidump.dmp.gz"),
343 "minidump.dmp"
344 );
345 assert_eq!(
346 remove_container_extension("minidump.dmp.bz2"),
347 "minidump.dmp"
348 );
349 assert_eq!(
350 remove_container_extension("minidump.dmp.xz"),
351 "minidump.dmp"
352 );
353 assert_eq!(
354 remove_container_extension("minidump.dmp.zst"),
355 "minidump.dmp"
356 );
357
358 Ok(())
359 }
360
361 #[tokio::test]
362 async fn test_minidump_multipart_attachments() -> anyhow::Result<()> {
363 let multipart_body: &[u8] =
364 b"-----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
365 Content-Disposition: form-data; name=\"guid\"\x0d\x0a\x0d\x0add46bb04-bb27-448c-aad0-0deb0c134bdb\x0d\x0a\
366 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
367 Content-Disposition: form-data; name=\"config.json\"; filename=\"config.json\"\x0d\x0a\x0d\x0a\
368 \"Sentry\": { \"Dsn\": \"https://ingest.us.sentry.io/xxxxxxx\", \"MaxBreadcrumbs\": 50, \"Debug\": true }\x0d\x0a\
369 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
370 Content-Disposition: form-data; name=\"__sentry-breadcrumb1\"; filename=\"__sentry-breadcrumb1\"\x0d\x0a\
371 Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
372 \x82\
373 \xa9timestamp\xb82024-03-12T16:59:33.069Z\
374 \xa7message\xb5default level is info\x0d\x0a\
375 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
376 Content-Disposition: form-data; name=\"__sentry-breadcrumb2\"; filename=\"__sentry-breadcrumb2\"\x0d\x0a\
377 Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
378 \x0d\x0a\
379 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
380 Content-Disposition: form-data; name=\"__sentry-event\"; filename=\"__sentry-event\"\x0d\x0a\
381 Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
382 \x82\xa5level\xa5fatal\xa8platform\xa6native\x0d\x0a\
383 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
384 Content-Disposition: form-data; name=\"view-hierarchy.json\"; filename=\"view-hierarchy.json\"\x0d\x0a\
385 Content-Type: application/json\x0d\x0a\x0d\x0a\
386 {\"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\
387 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw-----\x0d\x0a";
388
389 let request = Request::builder()
390 .header(
391 "content-type",
392 "multipart/form-data; boundary=---MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---",
393 )
394 .body(Body::from(multipart_body))?;
395
396 let config = Config::default();
397
398 let multipart = ConstrainedMultipart(utils::multipart_from_request(
399 request,
400 multer::Constraints::new(),
401 )?);
402 let items = multipart.items(infer_attachment_type, &config).await?;
403
404 assert_eq!(6, items.len());
411
412 let item = &items[0];
414 assert_eq!(item.filename().unwrap(), "config.json");
415 assert!(item.content_type().is_none());
416 assert_eq!(item.ty(), &ItemType::Attachment);
417 assert_eq!(item.attachment_type().unwrap(), &AttachmentType::Attachment);
418 assert_eq!(item.payload().len(), 95);
419
420 let item = &items[1];
422 assert_eq!(item.filename().unwrap(), "__sentry-breadcrumb1");
423 assert_eq!(item.content_type().unwrap(), &ContentType::OctetStream);
424 assert_eq!(item.ty(), &ItemType::Attachment);
425 assert_eq!(
426 item.attachment_type().unwrap(),
427 &AttachmentType::Breadcrumbs
428 );
429 assert_eq!(item.payload().len(), 66);
430
431 let item = &items[2];
433 assert_eq!(item.filename().unwrap(), "__sentry-breadcrumb2");
434 assert_eq!(item.content_type().unwrap(), &ContentType::OctetStream);
435 assert_eq!(item.ty(), &ItemType::Attachment);
436 assert_eq!(
437 item.attachment_type().unwrap(),
438 &AttachmentType::Breadcrumbs
439 );
440 assert_eq!(item.payload().len(), 0);
441
442 let item = &items[3];
444 assert_eq!(item.filename().unwrap(), "__sentry-event");
445 assert_eq!(item.content_type().unwrap(), &ContentType::OctetStream);
446 assert_eq!(item.ty(), &ItemType::Attachment);
447 assert_eq!(
448 item.attachment_type().unwrap(),
449 &AttachmentType::EventPayload
450 );
451 assert_eq!(item.payload().len(), 29);
452
453 let item = &items[4];
455 assert_eq!(item.filename().unwrap(), "view-hierarchy.json");
456 assert_eq!(item.content_type().unwrap(), &ContentType::Json);
457 assert_eq!(item.ty(), &ItemType::Attachment);
458 assert_eq!(
459 item.attachment_type().unwrap(),
460 &AttachmentType::ViewHierarchy
461 );
462 assert_eq!(item.payload().len(), 184);
463
464 let item = &items[5];
466 assert!(item.filename().is_none());
467 assert_eq!(item.content_type().unwrap(), &ContentType::Text);
468 assert_eq!(item.ty(), &ItemType::FormData);
469 assert!(item.attachment_type().is_none());
470 let form_payload = item.payload();
471 let form_data_entry = FormDataIter::new(form_payload.as_ref()).next().unwrap();
472 assert_eq!(form_data_entry.key(), "guid");
473 assert_eq!(
474 form_data_entry.value(),
475 "dd46bb04-bb27-448c-aad0-0deb0c134bdb"
476 );
477
478 Ok(())
479 }
480}