diff --git a/CHANGELOG.md b/CHANGELOG.md index 67be1550317..5191ae73bc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ **Bug Fixes**: - Store a normalized attachment content type in objectstore so that downloads are served with the correct type. ([#6319](https://github.com/getsentry/relay/pull/6319)) +- Reshape Nintendo Switch crashes so the issue title falls back to the crashing function instead of the raw abort result code, and raise their level to `fatal`. ([#6253](https://github.com/getsentry/relay/pull/6253)) +- Reject Nintendo Switch dying message attachments with an invalid magic number instead of panicking on a short payload. ([#6253](https://github.com/getsentry/relay/pull/6253)) **Internal**: diff --git a/relay-dynamic-config/src/feature.rs b/relay-dynamic-config/src/feature.rs index 8ad4f8422e0..c4a6564a556 100644 --- a/relay-dynamic-config/src/feature.rs +++ b/relay-dynamic-config/src/feature.rs @@ -89,6 +89,9 @@ pub enum Feature { /// Upload non-prosperodmp playstation attachments via the upload endpoint. #[serde(rename = "projects:relay-playstation-uploads")] PlaystationUploads, + /// Enable Nintendo event rewrite. + #[serde(rename = "projects:relay-nintendo-event-rewrite")] + NintendoEventRewrite, /// Stream minidumps to objectstore. #[serde(rename = "projects:relay-minidump-uploads")] MinidumpUploads, diff --git a/relay-server/src/processing/errors/errors/nswitch.rs b/relay-server/src/processing/errors/errors/nswitch.rs index 688f9668fe3..5a3b2f23485 100644 --- a/relay-server/src/processing/errors/errors/nswitch.rs +++ b/relay-server/src/processing/errors/errors/nswitch.rs @@ -65,7 +65,8 @@ impl SentryError for Nswitch { let mut attachments = attachments; attachments.extend(dying_message.attachments); - let event = match (event, dying_message.event) { + #[cfg_attr(not(feature = "processing"), expect(unused_mut))] + let mut event = match (event, dying_message.event) { (Some(event), Some(dying_message)) => { metrics.bytes_ingested_event = Annotated::new((event.len() + dying_message.len()) as u64); @@ -76,6 +77,20 @@ impl SentryError for Nswitch { (None, None) => return Err(ProcessingError::NoEventPayload.into()), }; + // Reshape switch crash title to match other native platforms, and mark it as fatal. + utils::if_processing!(ctx, { + use relay_dynamic_config::Feature; + + if let Some(event) = event.value_mut() + && ctx + .processing + .project_info + .has_feature(Feature::NintendoEventRewrite) + { + crate::utils::reshape_switch_crash(event); + } + }); + Ok(Some(Expansion { event: Box::new(event), attachments, @@ -124,6 +139,8 @@ pub enum SwitchProcessingError { EnvelopeParsing(#[from] EnvelopeError), #[error("unexpected EOF, expected {expected:?}")] UnexpectedEof { expected: &'static str }, + #[error("invalid magic number")] + InvalidMagic, #[error("invalid {0:?} ({1:?})")] InvalidValue(&'static str, usize), #[error("Zstandard error")] @@ -166,6 +183,12 @@ struct ExpandedDyingMessage { /// Parses DyingMessage contents and updates the envelope. /// See dying_message.md for the documentation. fn expand_dying_message(mut payload: Bytes) -> Result { + // `Item::attachment_type` may report `NintendoSwitchDyingMessage` from an explicitly set item + // header, which bypasses the `starts_with(magic)` guard it applies when inferring the type. + // Validate the magic here so a crafted short payload can't panic the `advance` below. + if !payload.starts_with(NNSWITCH_SENTRY_MAGIC) { + return Err(SwitchProcessingError::InvalidMagic); + } payload.advance(NNSWITCH_SENTRY_MAGIC.len()); let version = payload .try_get_u8() @@ -304,13 +327,16 @@ mod tests { use super::*; use relay_config::{Config, OverridableConfig}; + use relay_dynamic_config::Feature; + use relay_event_schema::protocol::Level; use relay_protocol::assert_annotated_snapshot; use std::io::Write; use zstd::bulk::Compressor as ZstdCompressor; use crate::constants::NNSWITCH_DYING_MESSAGE_FILENAME; - use crate::envelope::Item; + use crate::envelope::{ContentType, Item}; use crate::processing; + use crate::services::projects::project::ProjectInfo; fn ctx() -> Context<'static> { static CONFIG: std::sync::LazyLock = std::sync::LazyLock::new(|| { @@ -324,9 +350,20 @@ mod tests { config }); + static PROJECT_INFO: std::sync::LazyLock = std::sync::LazyLock::new(|| { + let mut project_info = ProjectInfo::default(); + project_info + .config + .features + .0 + .insert(Feature::NintendoEventRewrite); + project_info + }); + Context { processing: processing::Context { config: &CONFIG, + project_info: &PROJECT_INFO, ..processing::Context::for_test() }, ..Context::for_test() @@ -470,4 +507,91 @@ mod tests { let _ = Nswitch::try_expand(&mut items, ctx()).unwrap().unwrap(); } + + #[test] + fn test_expand_dying_message_rejects_short_or_invalid_magic() { + // A payload shorter than the 4-byte magic must return an error rather than panic in + // `advance` (reachable when the attachment type is set explicitly in the item header). + for payload in ["", "s", "sn", "snt"] { + assert!(matches!( + expand_dying_message(Bytes::from(payload)), + Err(SwitchProcessingError::InvalidMagic) + )); + } + + // A long-enough payload whose magic doesn't match is rejected too. + assert!(matches!( + expand_dying_message(Bytes::from("xxxx")), + Err(SwitchProcessingError::InvalidMagic) + )); + } + + #[test] + fn test_switch_crash_is_reshaped() { + let mut event = Item::new(ItemType::Event); + event.set_payload( + ContentType::Json, + include_bytes!( + "../../../../../tests/integration/fixtures/native/nnswitch_crportal_event.json" + ) + .as_slice(), + ); + + let mut dying_message = Item::new(ItemType::Attachment); + dying_message.set_filename(NNSWITCH_DYING_MESSAGE_FILENAME); + dying_message.set_attachment_type(AttachmentType::NintendoSwitchDyingMessage); + dying_message.set_payload( + ContentType::OctetStream, + include_bytes!( + "../../../../../tests/integration/fixtures/native/nnswitch_crportal_dying_message_raw.dat" + ) + .as_slice(), + ); + + let mut items = vec![event, dying_message]; + + let parsed = Nswitch::try_expand(&mut items, ctx()).unwrap().unwrap(); + + let event = parsed.event.value().unwrap(); + + // The DyingMessage scope patch is the merge base and contributes the fields the + // forwarded event lacks. + assert_eq!( + event.release.value().map(|release| release.as_str()), + Some("test-app@1.0.0") + ); + assert_eq!( + event.environment.value().map(String::as_str), + Some("integration-test") + ); + + // The crash is rendered as fatal: both the forwarded event and the scope patch carry + // `level: error`, and the reshape asserts the severity of a captured crash. + assert_eq!(event.level.value(), Some(&Level::Fatal)); + + let exception = event + .exceptions + .value() + .unwrap() + .values + .value() + .unwrap() + .last() + .unwrap() + .value() + .unwrap(); + + // Marked synthetic, so Sentry drops the result-code `type` from the title and falls + // back to the crashing function. Nintendo's own `handled: false` passes through. + let mechanism = exception.mechanism.value().unwrap(); + assert_eq!(mechanism.synthetic.value(), Some(&true)); + assert_eq!(mechanism.handled.value(), Some(&false)); + + // The result code and its description are preserved; only the `type`'s influence on the + // title is removed. `value` remains as the issue subtitle. + assert_eq!( + exception.ty.value().map(String::as_str), + Some("2168-0002 ResultAccessViolationData") + ); + } } diff --git a/relay-server/src/utils/native.rs b/relay-server/src/utils/native.rs index e54236fecca..922f75c6c79 100644 --- a/relay-server/src/utils/native.rs +++ b/relay-server/src/utils/native.rs @@ -308,3 +308,40 @@ pub fn process_apple_crash_report(event: &mut Event, additional_exceptions: Addi }; write_native_placeholder(event, placeholder, additional_exceptions); } + +/// Reshapes a Switch crash so it renders the same as crashes on other platforms. +/// +/// Unlike a minidump, a Switch crash reaches Relay straight from the Nintendo crash pipeline: +/// its exception `type` is the raw abort result code (for example +/// `2168-0002 ResultAccessViolationData`). +/// +/// Native crashes that Relay assembles itself (see [`write_native_placeholder`]) mark their +/// exception `synthetic`, which tells Sentry to drop the exception `type` from the title and +/// fall back to the crashing function. We apply the same treatment here, so the issue title +/// matches its Windows/macOS counterpart. +/// +/// The exception `value` is deliberately preserved: as with minidumps, it remains the issue +/// subtitle. +pub fn reshape_switch_crash(event: &mut Event) { + // Sentry derives the issue title from the last exception in the list (see `_get_exception` + // in `sentry/eventtypes/error.py`), so that is the one whose `type` we must neutralize. + let Some(exception) = event + .exceptions + .value_mut() + .as_mut() + .and_then(|values| values.values.value_mut().as_mut()) + .and_then(|exceptions| exceptions.last_mut()) + .and_then(|exception| exception.value_mut().as_mut()) + else { + return; + }; + + let mechanism = exception + .mechanism + .value_mut() + .get_or_insert_with(Mechanism::default); + mechanism.synthetic.set_value(Some(true)); + + // Events have level `error` by default, so set to `fatal` like for other native crashes. + event.level.set_value(Some(Level::Fatal)); +} diff --git a/tests/integration/fixtures/native/nnswitch_crportal_dying_message_raw.dat b/tests/integration/fixtures/native/nnswitch_crportal_dying_message_raw.dat new file mode 100644 index 00000000000..695e364b192 Binary files /dev/null and b/tests/integration/fixtures/native/nnswitch_crportal_dying_message_raw.dat differ diff --git a/tests/integration/fixtures/native/nnswitch_crportal_event.json b/tests/integration/fixtures/native/nnswitch_crportal_event.json new file mode 100644 index 00000000000..53f1628edec --- /dev/null +++ b/tests/integration/fixtures/native/nnswitch_crportal_event.json @@ -0,0 +1,115 @@ +{ + "event_id": "9938e40988cdd00b8b46c1d525f8abea", + "platform": "native", + "logger": "SentryNintendo@3.0.0", + "level": "error", + "timestamp": "2026-07-30T12:55:06+00:00", + "exception": { + "values": [ + { + "type": "2168-0002 ResultAccessViolationData", + "value": "Data access to an invalid memory region was performed. (2: Access Violation Data)", + "mechanism": { + "type": "2168-0002", + "handled": false + }, + "thread_id": 8440, + "stacktrace": { + "frames": [ + { + "package": "nnrtld", + "instruction_addr": "0x00000077616ba2e4" + }, + { + "package": "nnSdk", + "function": "nn::init::Start", + "instruction_addr": "0x0000007761723f10" + }, + { + "package": "SentryIntegrationTest.nss", + "filename": "main.cpp", + "function": "nnMain", + "lineno": 173, + "instruction_addr": "0x00000077601010a8", + "vars": { + "affinity_mask": "0x0000000000000001", + "current_core": 0, + "exception_address": "0x0000000000000000", + "exception_code": 2, + "exception_code_description": "Access Violation Data", + "exception_thread": "8440", + "ideal_core": 0, + "lr_called": true, + "priority": 16, + "stack_bottom": "0x000000775fce6000", + "stack_top": "0x000000775fde6000" + } + } + ] + } + } + ] + }, + "threads": { + "values": [ + { + "id": 8440, + "crashed": true, + "state": "Runnable", + "stacktrace": { + "frames": [ + { + "package": "nnrtld", + "instruction_addr": "0x00000077616ba2e4" + }, + { + "package": "nnSdk", + "function": "nn::init::Start", + "instruction_addr": "0x0000007761723f10" + }, + { + "package": "SentryIntegrationTest.nss", + "filename": "main.cpp", + "function": "nnMain", + "lineno": 173, + "instruction_addr": "0x00000077601010a8" + } + ] + } + }, + { + "id": 8451, + "crashed": false, + "state": "Waiting" + } + ] + }, + "user": { + "id": "2d0a3a3c0acd2ac5ab32276ab14091dceb78884b", + "username": "2d0a3a3c", + "ip_address": "0.0.0.0" + }, + "contexts": { + "app": { + "app_name": "SentryIntegrationTest", + "app_version": "1.0.0", + "app_build": "1" + }, + "device": { + "name": "Nintendo Switch", + "family": "Nintendo Switch", + "model": "Nintendo Switch", + "manufacturer": "Nintendo" + }, + "os": { + "name": "Nintendo OS", + "version": "22.1.0" + } + }, + "extra": { + "application_total_active_time": "1", + "occurrence_timestamp_net": "2026-07-30T12:55:06+00:00", + "posted_at": "2026-07-30T12:55:08+00:00", + "report_identifier": "efed2050-fb20-4f0f-9c7f-635767eed654" + } +}