Skip to content
Open
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
50 changes: 36 additions & 14 deletions Sentry.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions SentryTestUtils/TestStreamableObject.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
@testable import Sentry

private class ErrorInputStream: InputStream {
override var hasBytesAvailable: Bool {
return true
}

override func read(_ buffer: UnsafeMutablePointer<UInt8>, maxLength len: Int) -> Int {
return -1 // Simulate read error
}

override func open() {
// No-op
}

override func close() {
// No-op
}
}

public class TestStreamableObject: NSObject, SentryStreamable {

private let shouldReturnNilInputStream: Bool
private let streamSizeValue: UInt?
private let shouldReturnErrorStream: Bool

public init(streamSize: UInt?, shouldReturnNilInputStream: Bool, shouldReturnErrorStream: Bool = false) {
self.streamSizeValue = streamSize
self.shouldReturnNilInputStream = shouldReturnNilInputStream
self.shouldReturnErrorStream = shouldReturnErrorStream
super.init()
}

public func asInputStream() -> InputStream? {
if shouldReturnNilInputStream {
return nil
}
if shouldReturnErrorStream {
return ErrorInputStream()
}
return InputStream(data: Data())
}

public func streamSize() -> UInt? {
return streamSizeValue
}

// MARK: - Convenience factory methods for common test scenarios

public static func objectWithNilInputStream() -> TestStreamableObject {
return TestStreamableObject(streamSize: 10, shouldReturnNilInputStream: true)
}

public static func objectWithZeroSize() -> TestStreamableObject {
return TestStreamableObject(streamSize: 0, shouldReturnNilInputStream: false)
}

public static func objectWithNegativeSize() -> TestStreamableObject {
return TestStreamableObject(streamSize: nil, shouldReturnNilInputStream: false)
}

public static func objectWithErrorStream() -> TestStreamableObject {
return TestStreamableObject(streamSize: 10, shouldReturnNilInputStream: false, shouldReturnErrorStream: true)
}

public static func objectWithZeroBytesRead() -> TestStreamableObject {
return TestStreamableObject(streamSize: 10, shouldReturnNilInputStream: false, shouldReturnErrorStream: false)
}

public static func objectWithLargeSize() -> TestStreamableObject {
// Return size larger than UInt32.max to test truncation
return TestStreamableObject(
streamSize: UInt.max,
shouldReturnNilInputStream: false,
shouldReturnErrorStream: false
)
}
}
1 change: 0 additions & 1 deletion Sources/Sentry/SentryClient.m
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
#import "SentryMechanismMeta.h"
#import "SentryMessage.h"
#import "SentryMeta.h"
#import "SentryMsgPackSerializer.h"
#import "SentryNSDictionarySanitize.h"
#import "SentryNSError.h"
#import "SentryOptions+Private.h"
Expand Down
108 changes: 0 additions & 108 deletions Sources/Sentry/SentryMsgPackSerializer.m

This file was deleted.

31 changes: 0 additions & 31 deletions Sources/Sentry/include/SentryMsgPackSerializer.h

This file was deleted.

1 change: 0 additions & 1 deletion Sources/Sentry/include/SentryPrivate.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
#import "SentryLevelHelper.h"
#import "SentryMeta.h"
#import "SentryModels+Serializable.h"
#import "SentryMsgPackSerializer.h"
#import "SentryNSDictionarySanitize.h"
#import "SentryOptions+Private.h"
#import "SentryProfiler+Private.h"
Expand Down
9 changes: 9 additions & 0 deletions Sources/Swift/Tools/MsgPack/Data+SentryStreamable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
extension Data: SentryStreamable {
func asInputStream() -> InputStream? {
return InputStream(data: self)
}

func streamSize() -> UInt? {
return UInt(self.count)
}
}
98 changes: 98 additions & 0 deletions Sources/Swift/Tools/MsgPack/SentryMsgPackSerializer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* This is a partial implementation of the MessagePack format.
* We only need to concatenate a list of NSData into an envelope item.
*/
class SentryMsgPackSerializer {
@objc
static func serializeDictionary(toMessagePack dictionary: [String: Any], intoFile fileURL: URL) -> Bool {
do {
let data = try serializeDictionaryToMessagePack(dictionary)
try data.write(to: fileURL)
return true
} catch {
SentrySDKLog.error("Failed to serialize dictionary to MessagePack or write to file - Error: \(error)")
return false
}
}

static func serializeDictionaryToMessagePack(_ dictionary: [String: Any]) throws -> Data { // swiftlint:disable:this function_body_length
let outputStream = OutputStream.toMemory()
outputStream.open()
defer { outputStream.close() }

let mapHeader = UInt8(0x80 | dictionary.count) // Map up to 15 elements
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Serializer Memory Leak & Map Header Crash

The SentryMsgPackSerializer accumulates all data in memory before writing to a file, potentially causing high memory usage or OOM errors for large datasets. Also, the map header's dictionary count calculation may crash at runtime if the count exceeds 255, unlike the original Objective-C truncation behavior.

Fix in Cursor Fix in Web

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this is true? At least the first part about accumulating everything to memory. Any reason we can't use the ObjC behavior here of having the output stream write to a file?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll double-check why I changed it, it's been a while

_ = outputStream.write([mapHeader], maxLength: 1)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The map header implementation silently truncates dictionaries with more than 15 elements by using UInt8(0x80 | dictionary.count). This produces incorrect MessagePack format for dictionaries with 16+ elements. Consider either:

  1. Using fixmap16 (0xDE) or fixmap32 (0xDF) format for larger dictionaries
  2. Validating dictionary size and throwing an error if it exceeds 15 elements
  3. Adding explicit documentation/warning about this limitation

The current silent truncation can lead to data corruption that's difficult to debug.

Suggested change
// Validate dictionary size for proper MessagePack format
if dictionary.count > 15 {
throw SentryMsgPackSerializerError.dictionaryTooLarge
}
let mapHeader = UInt8(0x80 | dictionary.count) // fixmap format for up to 15 elements

Did we get this right? 👍 / 👎 to inform future reviews.

for (key, anyValue) in dictionary {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream write operations discard the return value which indicates the number of bytes actually written. If the write fails or writes fewer bytes than expected, this will silently corrupt the MessagePack data. Consider checking write return values and throwing errors on failure.

Suggested change
for (key, anyValue) in dictionary {
let bytesWritten = outputStream.write([mapHeader], maxLength: 1)
if bytesWritten != 1 {
throw SentryMsgPackSerializerError.outputError("Failed to write map header")
}

Did we get this right? 👍 / 👎 to inform future reviews.

guard let value = anyValue as? SentryStreamable else {
throw SentryMsgPackSerializerError.invalidValue("Value does not conform to SentryStreamable: \(anyValue)")
}
guard let keyData = key.data(using: .utf8) else {
throw SentryMsgPackSerializerError.invalidInput("Could not encode key as UTF-8: \(key)")
}

let str8Header: UInt8 = 0xD9 // String up to 255 characters
let keyLength = UInt8(truncatingIfNeeded: keyData.count) // Truncates if > 255, matching Objective-C behavior
_ = outputStream.write([str8Header], maxLength: 1)
_ = outputStream.write([keyLength], maxLength: 1)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Key length is silently truncated to UInt8 using truncatingIfNeeded when the UTF-8 encoded key exceeds 255 bytes. This will corrupt the MessagePack output if a key is longer than 255 bytes. Consider validating the key length and throwing an error instead of silently truncating.

Note: A key with 255 UTF-8 characters might actually have more than 255 bytes if it contains multi-byte Unicode characters.

Suggested change
let str8Header: UInt8 = 0xD9 // String up to 255 bytes
if keyData.count > 255 {
throw SentryMsgPackSerializerError.invalidInput("Key exceeds 255 bytes: \(key)")
}
let keyLength = UInt8(keyData.count)

Did we get this right? 👍 / 👎 to inform future reviews.

try keyData.withUnsafeBytes { bytes in
guard let bufferAddress = bytes.bindMemory(to: UInt8.self).baseAddress else {
throw SentryMsgPackSerializerError.invalidInput("Could not get buffer address for key: \(key)")
}
_ = outputStream.write(bufferAddress, maxLength: keyData.count)
}

guard let dataLength = value.streamSize(), dataLength > 0 else {
// MsgPack is being used strictly for session replay.
Comment on lines +41 to +47
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The keyData.withUnsafeBytes closure writes to the output stream, but if the stream is in an error state or closed, this could lead to undefined behavior. Consider checking the stream status before writing.

Did we get this right? 👍 / 👎 to inform future reviews.

// An item with a length of 0 will not be useful.
// If we plan to use MsgPack for something else,
// this needs to be re-evaluated.
SentrySDKLog.error("Data for MessagePack dictionary has no content - Input: \(value)")
throw SentryMsgPackSerializerError.emptyData("Empty data for MessagePack dictionary")
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Serializer Fails on Empty Streams

The SentryMsgPackSerializer incorrectly rejects valid zero-length data streams, treating them as errors. This diverges from the Objective-C implementation, breaking backward compatibility and causing SentrySDKLog.error messages for what should be non-error scenarios.

Fix in Cursor Fix in Web


let valueLength = UInt32(truncatingIfNeeded: dataLength)
// We will always use the 4 bytes data length for simplicity.
// Worst case we're losing 3 bytes.
let bin32Header: UInt8 = 0xC6
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Value length is truncated to UInt32 using truncatingIfNeeded when streamSize exceeds UInt32.max (4GB). This will cause a mismatch between the declared length in the MessagePack header and the actual data length, leading to corrupted data. Consider validating the size and throwing an error for excessively large values.

Suggested change
let bin32Header: UInt8 = 0xC6
if dataLength > UInt32.max {
throw SentryMsgPackSerializerError.invalidInput("Data size exceeds UInt32.max: \(dataLength)")
}
let valueLength = UInt32(dataLength)

Did we get this right? 👍 / 👎 to inform future reviews.

_ = outputStream.write([bin32Header], maxLength: 1)

// Write UInt32 as big endian bytes
let lengthBytes = [
UInt8((valueLength >> 24) & 0xFF),
UInt8((valueLength >> 16) & 0xFF),
UInt8((valueLength >> 8) & 0xFF),
UInt8(valueLength & 0xFF)
]
_ = outputStream.write(lengthBytes, maxLength: 4)

guard let inputStream = value.asInputStream() else {
SentrySDKLog.error("Could not get input stream - Input: \(value)")
throw SentryMsgPackSerializerError.streamError("Could not get input stream from value")
}

inputStream.open()
defer { inputStream.close() }

var buffer = [UInt8](repeating: 0, count: 1_024)
var bytesRead: Int

while inputStream.hasBytesAvailable {
bytesRead = inputStream.read(&buffer, maxLength: buffer.count)
if bytesRead > 0 {
_ = outputStream.write(buffer, maxLength: bytesRead)
Comment on lines +83 to +84
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream write operations inside the while loop don't check the return value, which could lead to partial writes and corrupted MessagePack data. The loop should verify that all bytes are written successfully.

Suggested change
if bytesRead > 0 {
_ = outputStream.write(buffer, maxLength: bytesRead)
let bytesWritten = outputStream.write(buffer, maxLength: bytesRead)
if bytesWritten != bytesRead {
throw SentryMsgPackSerializerError.outputError("Failed to write all bytes: wrote \(bytesWritten) of \(bytesRead)")
}

Did we get this right? 👍 / 👎 to inform future reviews.

} else if bytesRead < 0 {
SentrySDKLog.error("Error reading bytes from input stream - Input: \(value) - \(bytesRead)")
throw SentryMsgPackSerializerError.streamError("Error reading bytes from input stream")
}
}
}

guard let data = outputStream.property(forKey: .dataWrittenToMemoryStreamKey) as? Data else {
throw SentryMsgPackSerializerError.outputError("Could not retrieve data from memory stream")
}

return data
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
enum SentryMsgPackSerializerError: Error {
case dictionaryTooLarge
case invalidValue(String)
case invalidInput(String)
case emptyData(String)
case streamError(String)
case outputError(String)
}
4 changes: 4 additions & 0 deletions Sources/Swift/Tools/MsgPack/SentryStreamable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
protocol SentryStreamable {
func asInputStream() -> InputStream?
func streamSize() -> UInt?
}
20 changes: 20 additions & 0 deletions Sources/Swift/Tools/MsgPack/URL+SentryStreamable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
extension URL: SentryStreamable {
func asInputStream() -> InputStream? {
return InputStream(url: self)
}

func streamSize() -> UInt? {
let attributes: [FileAttributeKey: Any]
do {
attributes = try FileManager.default.attributesOfItem(atPath: path)
} catch {
SentrySDKLog.error("Could not read file attributes - File: \(self) - Error: \(error)")
return nil
}
guard let fileSize = attributes[.size] as? NSNumber else {
SentrySDKLog.error("Could not read file size attribute - File: \(self)")
return nil
}
return fileSize.uintValue
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Inconsistent Error Handling in Streamable Protocol

The SentryStreamable.streamSize() protocol method returns UInt? instead of NSInteger. This prevents signaling errors with negative values (like -1), which existing serializer logic expects. For example, the URL extension returns nil for file read errors, but the serializer expects a negative value to indicate an issue, breaking error handling compatibility.

Additional Locations (1)

Fix in Cursor Fix in Web

}
6 changes: 3 additions & 3 deletions Sources/Swift/Tools/SentryEnvelopeItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,9 @@
let envelopeContentUrl = video.deletingPathExtension().appendingPathExtension("dat")

let pack: [String: SentryStreamable] = [
"replay_event": replayEventData as NSData,
"replay_recording": recording as NSData,
"replay_video": video as NSURL
"replay_event": replayEventData,
"replay_recording": recording,
"replay_video": video
]
let success = SentryMsgPackSerializer.serializeDictionary(toMessagePack:
pack,
Expand Down
Loading
Loading