Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand Down
3 changes: 3 additions & 0 deletions relay-dynamic-config/src/feature.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
128 changes: 126 additions & 2 deletions relay-server/src/processing/errors/errors/nswitch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Comment thread
mujacica marked this conversation as resolved.
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);
Expand All @@ -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,
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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<ExpandedDyingMessage, SwitchProcessingError> {
// `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()
Expand Down Expand Up @@ -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<Config> = std::sync::LazyLock::new(|| {
Expand All @@ -324,9 +350,20 @@ mod tests {
config
});

static PROJECT_INFO: std::sync::LazyLock<ProjectInfo> = 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()
Expand Down Expand Up @@ -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.
Comment thread
sentry[bot] marked this conversation as resolved.
assert_eq!(
exception.ty.value().map(String::as_str),
Some("2168-0002 ResultAccessViolationData")
);
}
}
37 changes: 37 additions & 0 deletions relay-server/src/utils/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Binary file not shown.
115 changes: 115 additions & 0 deletions tests/integration/fixtures/native/nnswitch_crportal_event.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading