diff --git a/Sources/MCP/Extensions/Data+Extensions.swift b/Sources/MCP/Extensions/Data+Extensions.swift index 745b4a30..e75f589a 100644 --- a/Sources/MCP/Extensions/Data+Extensions.swift +++ b/Sources/MCP/Extensions/Data+Extensions.swift @@ -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. diff --git a/Tests/MCPTests/Base64DataTests.swift b/Tests/MCPTests/Base64DataTests.swift index 96025935..c80b8f5b 100644 --- a/Tests/MCPTests/Base64DataTests.swift +++ b/Tests/MCPTests/Base64DataTests.swift @@ -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