- Adaptive iPhone/iPad layout with NavigationSplitView sidebar - Auto-detect SSL-pinned domains, fall back to passthrough - Certificate install via local HTTP server (Safari profile flow) - App Group-backed CA, per-domain leaf cert LRU cache - DB-backed config repository, Darwin notification throttling - Rules engine, breakpoint rules, pinned domain tracking - os.Logger instrumentation across tunnel/proxy/mitm/capture/cert/rules/db/ipc/ui - Fix dyld framework embed, race conditions, thread safety
204 lines
6.9 KiB
Swift
204 lines
6.9 KiB
Swift
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<z_stream>.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
|
|
}
|
|
}
|