From 475bc70c7148e477335bae791020d73eb9e578b2 Mon Sep 17 00:00:00 2001 From: Islam <2553451+qalandarov@users.noreply.github.com> Date: Mon, 20 Oct 2025 00:21:23 +0100 Subject: [PATCH] Extract MimeTypes into a separate file + add tests (#843) --- bitchat/Services/{ => BLE}/BLEService.swift | 102 +--------- bitchat/Services/BLE/MimeType.swift | 195 ++++++++++++++++++++ bitchatTests/MimeTypeTests.swift | 90 +++++++++ 3 files changed, 295 insertions(+), 92 deletions(-) rename bitchat/Services/{ => BLE}/BLEService.swift (97%) create mode 100644 bitchat/Services/BLE/MimeType.swift create mode 100644 bitchatTests/MimeTypeTests.swift diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLE/BLEService.swift similarity index 97% rename from bitchat/Services/BLEService.swift rename to bitchat/Services/BLE/BLEService.swift index 67cbb0c3..6902e471 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1060,43 +1060,29 @@ final class BLEService: NSObject { return } - let mime = (filePacket.mimeType ?? "application/octet-stream").lowercased() + let mime = MimeType(filePacket.mimeType) ?? .octetStream - // Validate MIME type against whitelist - guard isAllowedMimeType(mime) else { - SecureLogger.warning("🚫 MIME REJECT: '\(mime)' not in whitelist. Size=\(filePacket.content.count)b from \(peerID.id.prefix(8))...", category: .security) + guard mime.isAllowed else { + SecureLogger.warning("🚫 MIME REJECT: '\(mime)' not supported. Size=\(filePacket.content.count)b from \(peerID.id.prefix(8))...", category: .security) return } // Validate content matches declared MIME type (magic byte check) - guard validateContentMatchesMime(data: filePacket.content, declaredMime: mime) else { + guard mime.matches(data: filePacket.content) else { let prefix = filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ") SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(filePacket.content.count)b prefix=[\(prefix)] from \(peerID.id.prefix(8))...", category: .security) return } - let category: IncomingMediaCategory - if mime.hasPrefix("audio/") { - category = .audio - } else if mime.hasPrefix("image/") { - category = .image - } else { - category = .other - } - - let fallbackExt = defaultExtension(for: mime) ?? (category == .image ? "jpg" : category == .audio ? "m4a" : "bin") + let fallbackExt = mime.defaultExtension let subdirectory: String - let prefix: String - switch category { + switch mime.category { case .audio: subdirectory = "voicenotes/incoming" - prefix = "voice" case .image: subdirectory = "images/incoming" - prefix = "image" - case .other: + case .file: subdirectory = "files/incoming" - prefix = "file" } guard let destination = saveIncomingFile( @@ -1104,19 +1090,19 @@ final class BLEService: NSObject { preferredName: filePacket.fileName, subdirectory: subdirectory, fallbackExtension: fallbackExt, - defaultPrefix: prefix + defaultPrefix: mime.category.rawValue ) else { return } let marker: String let fileName = destination.lastPathComponent - switch category { + switch mime.category { case .audio: marker = "[voice] \(fileName)" case .image: marker = "[image] \(fileName)" - case .other: + case .file: marker = "[file] \(fileName)" } @@ -1217,74 +1203,6 @@ final class BLEService: NSObject { } // MARK: - Helper Functions - - private enum IncomingMediaCategory { - case audio - case image - case other - } - - private func isAllowedMimeType(_ mime: String) -> Bool { - let allowed: Set = [ - "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", - "audio/mp4", "audio/m4a", "audio/aac", "audio/mpeg", "audio/mp3", - "audio/wav", "audio/x-wav", "audio/ogg", - "application/pdf", "application/octet-stream" - ] - return allowed.contains(mime.lowercased()) - } - - private func validateContentMatchesMime(data: Data, declaredMime: String) -> Bool { - guard !data.isEmpty else { return false } - let mime = declaredMime.lowercased() - - // Generic type - can't validate - if mime == "application/octet-stream" { return true } - - switch mime { - case "image/jpeg", "image/jpg": - return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF - - case "image/png": - return data.count >= 8 && - data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 && - data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A - - case "image/gif": - return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 && - data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61 - - case "image/webp": - return data.count >= 12 && - data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 && - data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 - - case "audio/m4a", "audio/mp4", "audio/aac": - // AVAudioRecorder output varies by platform - be lenient - // Security: size already capped + sandboxed execution - return data.count > 100 // Min reasonable audio size - - case "audio/mpeg", "audio/mp3": - if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 { return true } - return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0 - - case "audio/wav", "audio/x-wav": - return data.count >= 12 && - data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 && - data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45 - - case "audio/ogg": - return data.count >= 4 && - data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53 - - case "application/pdf": - return data.count >= 4 && - data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 - - default: - return false - } - } private func applicationFilesDirectory() throws -> URL { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) diff --git a/bitchat/Services/BLE/MimeType.swift b/bitchat/Services/BLE/MimeType.swift new file mode 100644 index 00000000..b424ea95 --- /dev/null +++ b/bitchat/Services/BLE/MimeType.swift @@ -0,0 +1,195 @@ +// +// MimeType.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import UniformTypeIdentifiers + +// MARK: - Extensions for missing UTTypes + +extension UTType { + static let webP = UTType(importedAs: "image/webp") + static let aac = UTType(importedAs: "audio/aac") + static let m4a = UTType(importedAs: "audio/m4a") + static let ogg = UTType(importedAs: "audio/ogg") +} + +// MARK: - MimeType Enum + +enum MimeType: CaseIterable, Hashable { + case jpeg + case jpg + case png + case gif + case webp + case mp4Audio + case m4a + case aac + case mpeg + case mp3 + case wav + case xWav + case ogg + case pdf + case octetStream + + var utType: UTType { + switch self { + case .jpeg, .jpg: .jpeg + case .png: .png + case .gif: .gif + case .webp: .webP + case .aac: .aac + case .m4a: .m4a + case .mp4Audio: .mpeg4Audio + case .mp3, .mpeg: .mp3 + case .wav, .xWav: .wav + case .ogg: .ogg + case .pdf: .pdf + case .octetStream: .data + } + } + + var category: Category { + switch self { + case .jpeg, .jpg, .png, .gif, .webp: + return .image + case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg: + return .audio + case .pdf, .octetStream: + return .file + } + } + + + var mimeString: String { + switch self { + case .jpeg, .jpg: "image/jpeg" + case .png: "image/png" + case .gif: "image/gif" + case .webp: "image/webp" + case .mp4Audio: "audio/mp4" + case .m4a: "audio/m4a" + case .aac: "audio/aac" + case .mpeg: "audio/mpeg" + case .mp3: "audio/mp3" + case .wav: "audio/wav" + case .xWav: "audio/x-wav" + case .ogg: "audio/ogg" + case .pdf: "application/pdf" + case .octetStream: "application/octet-stream" + } + } + + var defaultExtension: String { + switch self { + case .jpeg, .jpg: "jpg" + case .png: "png" + case .webp: "webp" + case .gif: "gif" + case .mp4Audio, .m4a, .aac: "m4a" + case .mpeg, .mp3: "mp3" + case .wav, .xWav: "wav" + case .ogg: "ogg" + case .pdf: "pdf" + case .octetStream: "bin" + } + } + + static var allowed: Set = [ + .jpeg, .jpg, .png, .gif, .webp, + .mp4Audio, .m4a, .aac, .mpeg, .mp3, + .wav, .xWav, .ogg, + .pdf, .octetStream + ] + + var isAllowed: Bool { + Self.allowed.contains(self) + } + + // MARK: - Byte signature validation + func matches(data: Data) -> Bool { + guard !data.isEmpty else { return false } + + // Generic type → skip validation + if self == .octetStream { return true } + + switch self { + case .jpeg, .jpg: + return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF + + case .png: + return data.count >= 8 && + data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 && + data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A + + case .gif: + return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 && + data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61 + + case .webp: + return data.count >= 12 && + data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 && + data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 + + case .m4a, .mp4Audio, .aac: + // AVAudioRecorder output varies by platform - be lenient + // Security: size already capped + sandboxed execution + return data.count > 100 + + case .mpeg, .mp3: + if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 { + return true // ID3 header + } + return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0 + + case .wav, .xWav: + return data.count >= 12 && + data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 && + data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45 + + case .ogg: + return data.count >= 4 && + data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53 + + case .pdf: + return data.count >= 4 && + data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 + + default: + return false + } + } + + // MARK: - Convenience Initializers + + init?(_ mimeString: String?) { + guard let mimeString else { return nil } + + let normalized = mimeString.lowercased() + + // Direct match with our canonical list + if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) { + self = match + return + } + + // Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc. + if let type = UTType(mimeType: normalized), + let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) { + self = match + return + } + + return nil + } +} + +extension MimeType { + enum Category: String { + case audio, image, file + } +} diff --git a/bitchatTests/MimeTypeTests.swift b/bitchatTests/MimeTypeTests.swift new file mode 100644 index 00000000..a6b20b10 --- /dev/null +++ b/bitchatTests/MimeTypeTests.swift @@ -0,0 +1,90 @@ +// +// MimeTypeTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import bitchat + +// MARK: - MimeType Mapping and Signature Tests + +struct MimeTypeTests { + + // MARK: MIME → Enum Parsing + Default Extension + @Test(arguments: [ + ("image/jpeg", MimeType.jpeg, "jpg"), + ("image/jpg", MimeType.jpeg, "jpg"), + ("image/png", MimeType.png, "png"), + ("image/gif", MimeType.gif, "gif"), + ("image/webp", MimeType.webp, "webp"), + ("audio/mp4", MimeType.mp4Audio, "m4a"), + ("audio/m4a", MimeType.m4a, "m4a"), + ("audio/aac", MimeType.aac, "m4a"), + ("audio/mpeg", MimeType.mpeg, "mp3"), + ("audio/mp3", MimeType.mp3, "mp3"), + ("audio/wav", MimeType.wav, "wav"), + ("audio/x-wav", MimeType.xWav, "wav"), + ("audio/ogg", MimeType.ogg, "ogg"), + ("application/pdf", MimeType.pdf, "pdf"), + ("application/octet-stream", MimeType.octetStream, "bin") + ]) + func mimeTypeParsingAndExtensions( + mimeString: String, + expectedType: MimeType, + expectedExt: String + ) throws { + guard let mime = MimeType(mimeString) else { + Issue.record("Failed to parse \(mimeString)") + return + } + + #expect(mime == expectedType, "Expected \(expectedType) for \(mimeString)") + #expect(mime.mimeString == expectedType.mimeString) + #expect(mime.defaultExtension == expectedExt) + #expect(mime.isAllowed) + } + + // MARK: - File Signature Validation + @Test(arguments: [ + // === Image types === + (MimeType.jpeg, [0xFF, 0xD8, 0xFF]), + (MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), + (MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a" + (MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, + 0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP" + + // === Audio types === + (MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3" + (MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, + 0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE" + (MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS" + + // === Application types === + (MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF" + ]) + func validSignatures(mime: MimeType, bytes: [UInt8]) throws { + let data = Data(bytes) + #expect(mime.matches(data: data), + "Expected \(mime.mimeString) to match its signature") + } + + // MARK: - Negative Tests + @Test func invalidDataDoesNotMatch() throws { + let badData = Data(repeating: 0x00, count: 16) + for mime in MimeType.allCases where mime != .octetStream { + #expect(!mime.matches(data: badData), + "Unexpectedly matched \(mime.mimeString) with zeroed data") + } + } + + // MARK: - Octet-stream (generic binary) + @Test func octetStreamAlwaysMatches() throws { + let randomData = Data([0x00, 0x11, 0x22, 0x33]) + #expect(MimeType.octetStream.matches(data: randomData), + "application/octet-stream should always be considered valid") + } +}