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, Remote, RequestMeta};
23use crate::service::ServiceState;
24use crate::utils;
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: Multipart<'static>,
168 meta: RequestMeta,
169) -> Result<Box<Envelope>, BadStoreRequest> {
170 let mut items = utils::multipart_items(multipart, infer_attachment_type).await?;
171
172 let minidump_item = items
173 .iter_mut()
174 .find(|item| item.attachment_type() == Some(&AttachmentType::Minidump))
175 .ok_or(BadStoreRequest::MissingMinidump)?;
176
177 let embedded_opt = extract_embedded_minidump(minidump_item.payload()).await?;
178 if let Some(embedded) = embedded_opt {
179 minidump_item.set_payload(Minidump, embedded);
180 }
181
182 minidump_item.set_payload(Minidump, decode_minidump(minidump_item.payload())?);
183
184 validate_minidump(&minidump_item.payload())?;
185
186 if let Some(minidump_filename) = minidump_item.filename() {
187 minidump_item.set_filename(remove_container_extension(minidump_filename).to_owned());
188 }
189
190 let event_id = common::event_id_from_items(&items)?.unwrap_or_else(EventId::new);
191 let mut envelope = Envelope::from_request(Some(event_id), meta);
192
193 for item in items {
194 envelope.add_item(item);
195 }
196
197 Ok(envelope)
198}
199
200fn extract_raw_minidump(data: Bytes, meta: RequestMeta) -> Result<Box<Envelope>, BadStoreRequest> {
201 let mut item = Item::new(ItemType::Attachment);
202
203 item.set_payload(Minidump, decode_minidump(data)?);
204 validate_minidump(&item.payload())?;
205 item.set_filename(MINIDUMP_FILE_NAME);
206 item.set_attachment_type(AttachmentType::Minidump);
207
208 let mut envelope = Envelope::from_request(Some(EventId::new()), meta);
210 envelope.add_item(item);
211 Ok(envelope)
212}
213
214async fn handle(
215 state: ServiceState,
216 meta: RequestMeta,
217 content_type: RawContentType,
218 request: Request,
219) -> axum::response::Result<impl IntoResponse> {
220 let envelope = if MINIDUMP_RAW_CONTENT_TYPES.contains(&content_type.as_ref()) {
226 extract_raw_minidump(request.extract().await?, meta)?
227 } else {
228 let Remote(multipart) = request.extract_with_state(&state).await?;
229 extract_multipart(multipart, meta).await?
230 };
231
232 let id = envelope.event_id();
233
234 match common::handle_envelope(&state, envelope).await {
236 Ok(_) | Err(BadStoreRequest::RateLimited(_)) => (),
237 Err(error) => return Err(error.into()),
238 };
239
240 Ok(TextResponse(id))
243}
244
245pub fn route(config: &Config) -> MethodRouter<ServiceState> {
246 post(handle).route_layer(DefaultBodyLimit::max(config.max_attachment_size()))
249}
250
251#[cfg(test)]
252mod tests {
253 use crate::envelope::ContentType;
254 use crate::utils::{FormDataIter, multipart_items};
255 use axum::body::Body;
256 use bzip2::Compression as BzCompression;
257 use bzip2::write::BzEncoder;
258 use flate2::Compression as GzCompression;
259 use flate2::write::GzEncoder;
260 use liblzma::write::XzEncoder;
261 use relay_config::Config;
262 use std::io::Write;
263 use zstd::stream::Encoder as ZstdEncoder;
264
265 use super::*;
266
267 #[test]
268 fn test_validate_minidump() {
269 let be_minidump = b"PMDMxxxxxx";
270 assert!(validate_minidump(be_minidump).is_ok());
271
272 let le_minidump = b"MDMPxxxxxx";
273 assert!(validate_minidump(le_minidump).is_ok());
274
275 let garbage = b"xxxxxx";
276 assert!(validate_minidump(garbage).is_err());
277 }
278
279 type EncodeFunction = fn(&[u8]) -> Result<Bytes, Box<dyn std::error::Error>>;
280
281 fn encode_gzip(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
282 let mut encoder = GzEncoder::new(Vec::new(), GzCompression::default());
283 encoder.write_all(be_minidump)?;
284 let compressed = encoder.finish()?;
285 Ok(Bytes::from(compressed))
286 }
287 fn encode_bzip(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
288 let mut encoder = BzEncoder::new(Vec::new(), BzCompression::default());
289 encoder.write_all(be_minidump)?;
290 let compressed = encoder.finish()?;
291 Ok(Bytes::from(compressed))
292 }
293 fn encode_xz(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
294 let mut encoder = XzEncoder::new(Vec::new(), 6);
295 encoder.write_all(be_minidump)?;
296 let compressed = encoder.finish()?;
297 Ok(Bytes::from(compressed))
298 }
299 fn encode_zst(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
300 let mut encoder = ZstdEncoder::new(Vec::new(), 0)?;
301 encoder.write_all(be_minidump)?;
302 let compressed = encoder.finish()?;
303 Ok(Bytes::from(compressed))
304 }
305
306 #[test]
307 fn test_validate_encoded_minidump() -> Result<(), Box<dyn std::error::Error>> {
308 let encoders: Vec<EncodeFunction> = vec![encode_gzip, encode_zst, encode_bzip, encode_xz];
309
310 for encoder in &encoders {
311 let be_minidump = b"PMDMxxxxxx";
312 let compressed = encoder(be_minidump)?;
313 let mut decoder = decoder_from(compressed).unwrap();
314 assert!(run_decoder(&mut decoder).is_ok());
315
316 let le_minidump = b"MDMPxxxxxx";
317 let compressed = encoder(le_minidump)?;
318 let mut decoder = decoder_from(compressed).unwrap();
319 assert!(run_decoder(&mut decoder).is_ok());
320
321 let garbage = b"xxxxxx";
322 let compressed = encoder(garbage)?;
323 let mut decoder = decoder_from(compressed).unwrap();
324 let decoded = run_decoder(&mut decoder);
325 assert!(decoded.is_ok());
326 assert!(validate_minidump(&decoded.unwrap()).is_err());
327 }
328
329 Ok(())
330 }
331
332 #[test]
333 fn test_remove_container_extension() -> Result<(), Box<dyn std::error::Error>> {
334 assert_eq!(remove_container_extension("minidump"), "minidump");
335 assert_eq!(remove_container_extension("minidump.gz"), "minidump");
336 assert_eq!(remove_container_extension("minidump.bz2"), "minidump");
337 assert_eq!(remove_container_extension("minidump.xz"), "minidump");
338 assert_eq!(remove_container_extension("minidump.zst"), "minidump");
339 assert_eq!(remove_container_extension("minidump.dmp"), "minidump.dmp");
340 assert_eq!(
341 remove_container_extension("minidump.dmp.gz"),
342 "minidump.dmp"
343 );
344 assert_eq!(
345 remove_container_extension("minidump.dmp.bz2"),
346 "minidump.dmp"
347 );
348 assert_eq!(
349 remove_container_extension("minidump.dmp.xz"),
350 "minidump.dmp"
351 );
352 assert_eq!(
353 remove_container_extension("minidump.dmp.zst"),
354 "minidump.dmp"
355 );
356
357 Ok(())
358 }
359
360 #[tokio::test]
361 async fn test_minidump_multipart_attachments() -> anyhow::Result<()> {
362 let multipart_body: &[u8] =
363 b"-----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
364 Content-Disposition: form-data; name=\"guid\"\x0d\x0a\x0d\x0add46bb04-bb27-448c-aad0-0deb0c134bdb\x0d\x0a\
365 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
366 Content-Disposition: form-data; name=\"config.json\"; filename=\"config.json\"\x0d\x0a\x0d\x0a\
367 \"Sentry\": { \"Dsn\": \"https://ingest.us.sentry.io/xxxxxxx\", \"MaxBreadcrumbs\": 50, \"Debug\": true }\x0d\x0a\
368 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
369 Content-Disposition: form-data; name=\"__sentry-breadcrumb1\"; filename=\"__sentry-breadcrumb1\"\x0d\x0a\
370 Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
371 \x82\
372 \xa9timestamp\xb82024-03-12T16:59:33.069Z\
373 \xa7message\xb5default level is info\x0d\x0a\
374 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
375 Content-Disposition: form-data; name=\"__sentry-breadcrumb2\"; filename=\"__sentry-breadcrumb2\"\x0d\x0a\
376 Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
377 \x0d\x0a\
378 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
379 Content-Disposition: form-data; name=\"__sentry-event\"; filename=\"__sentry-event\"\x0d\x0a\
380 Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
381 \x82\xa5level\xa5fatal\xa8platform\xa6native\x0d\x0a\
382 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
383 Content-Disposition: form-data; name=\"view-hierarchy.json\"; filename=\"view-hierarchy.json\"\x0d\x0a\
384 Content-Type: application/json\x0d\x0a\x0d\x0a\
385 {\"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\
386 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw-----\x0d\x0a";
387
388 let request = Request::builder()
389 .header(
390 "content-type",
391 "multipart/form-data; boundary=---MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---",
392 )
393 .body(Body::from(multipart_body))?;
394
395 let config = Config::default();
396
397 let multipart = utils::multipart_from_request(request, &config)?;
398 let items = multipart_items(multipart, infer_attachment_type).await?;
399
400 assert_eq!(6, items.len());
407
408 let item = &items[0];
410 assert_eq!(item.filename().unwrap(), "config.json");
411 assert!(item.content_type().is_none());
412 assert_eq!(item.ty(), &ItemType::Attachment);
413 assert_eq!(item.attachment_type().unwrap(), &AttachmentType::Attachment);
414 assert_eq!(item.payload().len(), 95);
415
416 let item = &items[1];
418 assert_eq!(item.filename().unwrap(), "__sentry-breadcrumb1");
419 assert_eq!(item.content_type().unwrap(), &ContentType::OctetStream);
420 assert_eq!(item.ty(), &ItemType::Attachment);
421 assert_eq!(
422 item.attachment_type().unwrap(),
423 &AttachmentType::Breadcrumbs
424 );
425 assert_eq!(item.payload().len(), 66);
426
427 let item = &items[2];
429 assert_eq!(item.filename().unwrap(), "__sentry-breadcrumb2");
430 assert_eq!(item.content_type().unwrap(), &ContentType::OctetStream);
431 assert_eq!(item.ty(), &ItemType::Attachment);
432 assert_eq!(
433 item.attachment_type().unwrap(),
434 &AttachmentType::Breadcrumbs
435 );
436 assert_eq!(item.payload().len(), 0);
437
438 let item = &items[3];
440 assert_eq!(item.filename().unwrap(), "__sentry-event");
441 assert_eq!(item.content_type().unwrap(), &ContentType::OctetStream);
442 assert_eq!(item.ty(), &ItemType::Attachment);
443 assert_eq!(
444 item.attachment_type().unwrap(),
445 &AttachmentType::EventPayload
446 );
447 assert_eq!(item.payload().len(), 29);
448
449 let item = &items[4];
451 assert_eq!(item.filename().unwrap(), "view-hierarchy.json");
452 assert_eq!(item.content_type().unwrap(), &ContentType::Json);
453 assert_eq!(item.ty(), &ItemType::Attachment);
454 assert_eq!(
455 item.attachment_type().unwrap(),
456 &AttachmentType::ViewHierarchy
457 );
458 assert_eq!(item.payload().len(), 184);
459
460 let item = &items[5];
462 assert!(item.filename().is_none());
463 assert_eq!(item.content_type().unwrap(), &ContentType::Text);
464 assert_eq!(item.ty(), &ItemType::FormData);
465 assert!(item.attachment_type().is_none());
466 let form_payload = item.payload();
467 let form_data_entry = FormDataIter::new(form_payload.as_ref()).next().unwrap();
468 assert_eq!(form_data_entry.key(), "guid");
469 assert_eq!(
470 form_data_entry.value(),
471 "dd46bb04-bb27-448c-aad0-0deb0c134bdb"
472 );
473
474 Ok(())
475 }
476}