//! The Sentry Envelope format (port of `crash/sentry_envelope.zig`):
//!
//!
//! The envelope is our primary crash report format. It is an open,
//! publicly documented format so reports can be consumed by any system.
//!
//! This implements reading and writing the envelope format. It is
//! incomplete; only what Ghostty needs is implemented.
use std::io::{self, BufRead, Read, Write};
use serde_json::{Map, Value};
/// A JSON object preserving insertion order (Zig: `std.json.ObjectMap`).
pub type ObjectMap = Map;
/// Limit of header lines (1MB, arbitrary choice upstream).
const LINE_LIMIT: u64 = 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("invalid json: {0}")]
Json(#[from] serde_json::Error),
#[error("EnvelopeMalformedHeaders")]
MalformedHeaders,
#[error("EnvelopeItemMalformedHeaders")]
ItemMalformedHeaders,
#[error("EnvelopeItemTypeMissing")]
ItemTypeMissing,
#[error("EnvelopeItemLengthMalformed")]
ItemLengthMalformed,
#[error("EnvelopeItemPayloadTooShort")]
ItemPayloadTooShort,
#[error("EnvelopeItemPayloadNoNewline")]
ItemPayloadNoNewline,
/// The header line exceeded the limit (Zig: `StreamTooLong`).
#[error("StreamTooLong")]
StreamTooLong,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum DecodeError {
#[error("MissingRequiredField")]
MissingRequiredField,
#[error("InvalidFieldType")]
InvalidFieldType,
#[error("UnsupportedType")]
UnsupportedType,
}
/// The various item types that can be in an envelope. Unknown types take
/// the `Unknown` value.
///
///
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemType {
Unknown,
Event,
Transaction,
Attachment,
Session,
Sessions,
Statsd,
MetricMeta,
UserFeedback,
ClientReport,
ReplayEvent,
ReplayRecording,
Profile,
CheckIn,
}
impl ItemType {
/// Parse the wire name (Zig `std.meta.stringToEnum`, so the snake_case
/// field names, including "unknown").
#[must_use]
pub fn from_name(s: &str) -> Option {
Some(match s {
"unknown" => ItemType::Unknown,
"event" => ItemType::Event,
"transaction" => ItemType::Transaction,
"attachment" => ItemType::Attachment,
"session" => ItemType::Session,
"sessions" => ItemType::Sessions,
"statsd" => ItemType::Statsd,
"metric_meta" => ItemType::MetricMeta,
"user_feedback" => ItemType::UserFeedback,
"client_report" => ItemType::ClientReport,
"replay_event" => ItemType::ReplayEvent,
"replay_recording" => ItemType::ReplayRecording,
"profile" => ItemType::Profile,
"check_in" => ItemType::CheckIn,
_ => return None,
})
}
}
/// An encoded item: payload is bytes, headers are decoded to a JSON object.
#[derive(Debug, Clone, PartialEq)]
pub struct EncodedItem {
pub headers: ObjectMap,
pub item_type: ItemType,
pub payload: Vec,
}
/// An arbitrary file attachment.
///
///
#[derive(Debug, Clone, PartialEq)]
pub struct Attachment {
/// The name of the uploaded file without a path component.
pub filename: String,
/// A special "type" associated with the attachment (`attachment_type`).
pub attachment_type: Option,
/// Additional headers for the attachment.
pub headers_extra: ObjectMap,
/// The data for the attachment.
pub payload: Vec,
}
impl Attachment {
/// A fresh attachment with no extra headers.
#[must_use]
pub fn new(filename: impl Into, payload: Vec) -> Attachment {
Attachment {
filename: filename.into(),
attachment_type: None,
headers_extra: ObjectMap::new(),
payload,
}
}
pub fn decode(item: &EncodedItem) -> Result {
let filename = match item.headers.get("filename") {
Some(Value::String(s)) => s.clone(),
Some(_) => return Err(DecodeError::InvalidFieldType),
None => return Err(DecodeError::MissingRequiredField),
};
let attachment_type = match item.headers.get("attachment_type") {
Some(Value::String(s)) => Some(s.clone()),
Some(_) => return Err(DecodeError::InvalidFieldType),
None => None,
};
Ok(Attachment {
filename,
attachment_type,
headers_extra: item.headers.clone(),
payload: item.payload.clone(),
})
}
pub fn encode(&mut self) -> EncodedItem {
// Deviation: upstream never writes the required "type" header for
// attachments built in memory (only parsed ones carry it), which
// yields an unparseable item. We add it when absent.
if !self.headers_extra.contains_key("type") {
self.headers_extra
.insert("type".into(), Value::String("attachment".into()));
}
self.headers_extra
.insert("filename".into(), Value::String(self.filename.clone()));
if let Some(v) = &self.attachment_type {
self.headers_extra
.insert("attachment_type".into(), Value::String(v.clone()));
} else {
self.headers_extra.swap_remove("attachment_type");
}
EncodedItem {
headers: self.headers_extra.clone(),
item_type: ItemType::Attachment,
payload: self.payload.clone(),
}
}
}
/// An item in the envelope, either encoded or decoded.
#[derive(Debug, Clone, PartialEq)]
pub enum Item {
Encoded(EncodedItem),
Attachment(Attachment),
}
impl Item {
/// Convert the item to an encoded item, modifying it in place.
pub fn encode(&mut self) -> &EncodedItem {
if let Item::Attachment(a) = self {
let enc = a.encode();
*self = Item::Encoded(enc);
}
match self {
Item::Encoded(e) => e,
Item::Attachment(_) => unreachable!("just encoded"),
}
}
/// The type of item, whether it is encoded or not.
#[must_use]
pub fn item_type(&self) -> ItemType {
match self {
Item::Encoded(e) => e.item_type,
Item::Attachment(_) => ItemType::Attachment,
}
}
/// Decode the item if it is encoded (modifies itself). On failure the
/// encoded item remains unmodified.
pub fn decode(&mut self) -> Result<(), DecodeError> {
let Item::Encoded(enc) = self else {
return Ok(());
};
match enc.item_type {
ItemType::Attachment => {
let a = Attachment::decode(enc)?;
*self = Item::Attachment(a);
Ok(())
}
_ => Err(DecodeError::UnsupportedType),
}
}
}
/// A parsed or constructed envelope.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Envelope {
/// The headers of the envelope decoded into a JSON object.
pub headers: ObjectMap,
/// The items in the envelope in the order they're encoded.
pub items: Vec,
}
/// Read up to the next `\n` (exclusive), limited to `LINE_LIMIT` bytes
/// (Zig: `streamDelimiterLimit` + `discardDelimiterInclusive`). Returns
/// `StreamTooLong` if the limit is hit before a delimiter or end of stream.
fn read_line(r: &mut R) -> Result, Error> {
let mut buf = Vec::new();
// Read one byte past the limit... Zig errors only if the limit is
// reached and the next byte isn't the delimiter/EOF.
r.by_ref().take(LINE_LIMIT).read_until(b'\n', &mut buf)?;
if buf.last() == Some(&b'\n') {
buf.pop();
return Ok(buf);
}
if buf.len() as u64 == LINE_LIMIT {
// Exactly at the limit: fine only if the stream ends or the next
// byte is the delimiter.
let next = r.fill_buf()?;
match next.first() {
None => {}
Some(b'\n') => r.consume(1),
Some(_) => return Err(Error::StreamTooLong),
}
}
Ok(buf)
}
fn trim_ws(b: &[u8]) -> &[u8] {
let is_ws = |c: &u8| *c == b' ' || *c == b'\t';
let start = b.iter().position(|c| !is_ws(c)).unwrap_or(b.len());
let end = b.iter().rposition(|c| !is_ws(c)).map_or(start, |i| i + 1);
&b[start..end]
}
impl Envelope {
/// Parse an envelope from a reader. The full envelope is read into memory.
pub fn parse(reader: &mut R) -> Result {
let headers = Self::parse_header(reader)?;
let items = Self::parse_items(reader)?;
Ok(Envelope { headers, items })
}
/// Convenience for parsing from a byte slice.
pub fn parse_slice(mut data: &[u8]) -> Result {
Self::parse(&mut data)
}
fn parse_header(reader: &mut R) -> Result {
let line = read_line(reader)?;
match serde_json::from_slice::(&line)? {
Value::Object(m) => Ok(m),
_ => Err(Error::MalformedHeaders),
}
}
fn parse_items(reader: &mut R) -> Result, Error> {
let mut items = Vec::new();
while let Some(item) = Self::parse_one_item(reader)? {
items.push(item);
}
Ok(items)
}
fn parse_one_item(reader: &mut R) -> Result