import Foundation import zlib public enum HTTPBodyDecoder { private static let inflateChunkSize = 64 * 1024 private static let maxDecodedBodyBytes = 8 * 1024 * 1024 public static func headerValue(named name: String, in headers: [String: String]) -> String? { headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value } public static func decodingHint(for body: Data?, headers: [String: String]) -> String { guard let body, !body.isEmpty else { return "empty" } let encodings = contentEncodings(in: headers) if encodings.isEmpty { return hasGzipMagic(body) ? "gzip-magic-no-header" : "identity" } var current = body var applied: [String] = [] for encoding in encodings.reversed() { switch encoding { case "identity": continue case "gzip", "x-gzip": guard let decoded = inflatePayload(current, windowBits: 47) else { return "failed(\(encoding))" } current = decoded applied.append(encoding) case "deflate": guard let decoded = inflateDeflatePayload(current) else { return "failed(\(encoding))" } current = decoded applied.append(encoding) default: return "unsupported(\(encoding))" } } if applied.isEmpty { return "identity" } return "decoded(\(applied.joined(separator: ",")))" } public static func decodedBodyData(from body: Data?, headers: [String: String]) -> Data? { guard let body, !body.isEmpty else { return nil } let encodings = contentEncodings(in: headers) if encodings.isEmpty { return hasGzipMagic(body) ? inflatePayload(body, windowBits: 47) : body } var current = body for encoding in encodings.reversed() { switch encoding { case "identity": continue case "gzip", "x-gzip": guard let decoded = inflatePayload(current, windowBits: 47) else { return nil } current = decoded case "deflate": guard let decoded = inflateDeflatePayload(current) else { return nil } current = decoded default: return nil } } return current } public static func searchableText(from body: Data?, headers: [String: String]) -> String? { guard let body, !body.isEmpty else { return nil } let candidate = decodedBodyData(from: body, headers: headers) ?? body if let jsonText = searchableJSONText(from: candidate) { return jsonText } guard let string = String(data: candidate, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), !string.isEmpty else { return nil } return string } private static func contentEncodings(in headers: [String: String]) -> [String] { guard let value = headerValue(named: "Content-Encoding", in: headers)? .lowercased() .trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return [] } return value .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } private static func searchableJSONText(from data: Data) -> String? { guard let jsonObject = try? JSONSerialization.jsonObject(with: data) else { return nil } let fragments = flattenedSearchFragments(from: jsonObject) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } guard !fragments.isEmpty else { return nil } return fragments.joined(separator: "\n") } private static func flattenedSearchFragments(from value: Any) -> [String] { switch value { case let dict as [String: Any]: return dict.sorted(by: { $0.key < $1.key }).flatMap { key, nestedValue in [key] + flattenedSearchFragments(from: nestedValue) } case let array as [Any]: return array.flatMap(flattenedSearchFragments(from:)) case let string as String: return [string] case let number as NSNumber: return ["\(number)"] case _ as NSNull: return [] default: return ["\(value)"] } } private static func inflateDeflatePayload(_ data: Data) -> Data? { inflatePayload(data, windowBits: 47) ?? inflatePayload(data, windowBits: 15) ?? inflatePayload(data, windowBits: -15) } private static func inflatePayload(_ data: Data, windowBits: Int32) -> Data? { guard !data.isEmpty else { return Data() } return data.withUnsafeBytes { rawBuffer in guard let sourceBytes = rawBuffer.bindMemory(to: Bytef.self).baseAddress else { return Data() } var stream = z_stream() stream.next_in = UnsafeMutablePointer(mutating: sourceBytes) stream.avail_in = uInt(data.count) let status = inflateInit2_(&stream, windowBits, ZLIB_VERSION, Int32(MemoryLayout.size)) guard status == Z_OK else { return nil } defer { inflateEnd(&stream) } var output = Data() var buffer = [UInt8](repeating: 0, count: inflateChunkSize) while true { let result = buffer.withUnsafeMutableBufferPointer { outputBuffer -> Int32 in stream.next_out = outputBuffer.baseAddress stream.avail_out = uInt(outputBuffer.count) return zlib.inflate(&stream, Z_SYNC_FLUSH) } let produced = inflateChunkSize - Int(stream.avail_out) if produced > 0 { buffer.withUnsafeBufferPointer { outputBuffer in guard let baseAddress = outputBuffer.baseAddress else { return } output.append(baseAddress, count: produced) } if output.count >= maxDecodedBodyBytes { return Data(output.prefix(maxDecodedBodyBytes)) } } switch result { case Z_STREAM_END: return output case Z_OK: continue default: return nil } } } } private static func hasGzipMagic(_ data: Data) -> Bool { guard data.count >= 2 else { return false } return data[data.startIndex] == 0x1f && data[data.index(after: data.startIndex)] == 0x8b } }