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
34 changes: 30 additions & 4 deletions Sources/MCP/Extensions/Data+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,42 @@ extension Data {
guard let base64Data = Data(base64Encoded: String(encodedData)) else { return nil }
decodedData = base64Data
} else {
guard
let percentDecodedData = String(encodedData).removingPercentEncoding?.data(
using: .utf8)
else { return nil }
guard let percentDecodedData = percentDecoded(encodedData) else { return nil }
decodedData = percentDecodedData
}

return (mimeType: mimeType, data: decodedData)
}

/// Decodes `%XX` escapes directly into bytes, so any byte value is accepted.
///
/// - Parameter string: The percent-encoded string.
/// - Returns: The decoded bytes, or `nil` if an escape is malformed.
private static func percentDecoded(_ string: Substring) -> Data? {
var bytes = Data(capacity: string.utf8.count)
var iterator = string.utf8.makeIterator()
while let byte = iterator.next() {
guard byte == UInt8(ascii: "%") else {
bytes.append(byte)
continue
}
guard let high = iterator.next().flatMap(hexValue),
let low = iterator.next().flatMap(hexValue)
else { return nil }
bytes.append(high << 4 | low)
}
return bytes
}

private static func hexValue(_ byte: UInt8) -> UInt8? {
switch byte {
case UInt8(ascii: "0")...UInt8(ascii: "9"): return byte - UInt8(ascii: "0")
case UInt8(ascii: "a")...UInt8(ascii: "f"): return byte - UInt8(ascii: "a") + 10
case UInt8(ascii: "A")...UInt8(ascii: "F"): return byte - UInt8(ascii: "A") + 10
default: return nil
}
}

/// Encodes the data as a data URL string with an optional MIME type.
///
/// - Parameter mimeType: The MIME type of the data. If `nil`, "text/plain" will be used.
Expand Down
37 changes: 37 additions & 0 deletions Tests/MCPTests/Base64DataTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,43 @@ struct Base64DataTests {
}
}

@Test("Parse percent-encoded bytes that are not valid UTF-8")
func testParseDataURLNonUTF8PercentEncoding() {
let result = Data.parseDataURL("data:application/octet-stream,%FF%00")
#expect(result != nil)
if let (mimeType, data) = result {
#expect(mimeType == "application/octet-stream")
#expect(data == Data([0xFF, 0x00]))
}
}

@Test("Parse RFC 2397 example with non-UTF-8 charset")
func testParseDataURLRFCCharsetExample() {
// Example from RFC 2397: ISO-8859-7 bytes that are not valid UTF-8
let result = Data.parseDataURL("data:text/plain;charset=iso-8859-7,%be%d3%be")
#expect(result != nil)
if let (mimeType, data) = result {
#expect(mimeType == "text/plain;charset=iso-8859-7")
#expect(data == Data([0xBE, 0xD3, 0xBE]))
}
}

@Test("Parse data URL with malformed percent escapes")
func testParseDataURLMalformedPercentEscapes() {
// Truncated escape at end of payload
#expect(Data.parseDataURL("data:,%") == nil)
#expect(Data.parseDataURL("data:,%F") == nil)

// Non-hex digit
#expect(Data.parseDataURL("data:,%fg") == nil)
}

@Test("Parse data URL preserves literal plus sign")
func testParseDataURLPreservesPlus() {
// RFC 2397 uses URL percent-encoding, not form encoding, so `+` is not a space
#expect(Data.parseDataURL("data:,a+b%2B")?.data == Data("a+b+".utf8))
}

@Test("Parse invalid data URLs")
func testParseInvalidDataURLs() {
// Not a data URL
Expand Down
Loading