From 3e0a98304868b2b817e78d23afc427305ee260ce Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Tue, 19 Jan 2021 12:44:54 -0600 Subject: [PATCH] Validates melonDS BIOS files Compares file sizes and MD5 hashes (when relevant) to ensure BIOS files are correct. --- Delta.xcodeproj/project.pbxproj | 4 + .../MelonDSCoreSettingsViewController.swift | 144 +++++++++++++----- Delta/Settings/Cores/SystemBIOS.swift | 123 +++++++++++++++ 3 files changed, 231 insertions(+), 40 deletions(-) create mode 100644 Delta/Settings/Cores/SystemBIOS.swift diff --git a/Delta.xcodeproj/project.pbxproj b/Delta.xcodeproj/project.pbxproj index 68c0cd7..eedf946 100644 --- a/Delta.xcodeproj/project.pbxproj +++ b/Delta.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ /* Begin PBXBuildFile section */ 1FA4ABA79AB72914FE414A61 /* libPods-Delta.a in Frameworks */ = {isa = PBXBuildFile; fileRef = DC866E433B3BA9AE18ABA1EC /* libPods-Delta.a */; }; + BF00BEA625B758AA00C8607D /* SystemBIOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF00BEA525B758AA00C8607D /* SystemBIOS.swift */; }; BF02D5DA1DDEBB3000A5E131 /* openvgdb.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = BF02D5D91DDEBB3000A5E131 /* openvgdb.sqlite */; }; BF04E6FF1DB8625C000F35D3 /* ControllerSkinsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF04E6FE1DB8625C000F35D3 /* ControllerSkinsViewController.swift */; }; BF1020E31F95B05B00313182 /* DeltaToDelta2.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = BF1020E21F95B05B00313182 /* DeltaToDelta2.xcmappingmodel */; }; @@ -157,6 +158,7 @@ /* Begin PBXFileReference section */ 22506DA00971C4300AF90A35 /* Pods.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A19FF50F55441BC2B2248241 /* Pods-Delta.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Delta.release.xcconfig"; path = "Pods/Target Support Files/Pods-Delta/Pods-Delta.release.xcconfig"; sourceTree = ""; }; + BF00BEA525B758AA00C8607D /* SystemBIOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemBIOS.swift; sourceTree = ""; }; BF02D5D91DDEBB3000A5E131 /* openvgdb.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = openvgdb.sqlite; sourceTree = ""; }; BF0418131D01E93400E85BCF /* GBADeltaCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = GBADeltaCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BF04E6FE1DB8625C000F35D3 /* ControllerSkinsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ControllerSkinsViewController.swift; sourceTree = ""; }; @@ -718,6 +720,7 @@ isa = PBXGroup; children = ( BFE9907F24451E15006409A7 /* MelonDSCoreSettingsViewController.swift */, + BF00BEA525B758AA00C8607D /* SystemBIOS.swift */, ); path = Cores; sourceTree = ""; @@ -1142,6 +1145,7 @@ BF6BF3271EB87EB8008E83CD /* PhotoLibraryImportOption.swift in Sources */, BF5942661E09BBB10051894B /* LoadImageURLOperation.swift in Sources */, BF353FF21C5D7FB000C1184C /* PauseViewController.swift in Sources */, + BF00BEA625B758AA00C8607D /* SystemBIOS.swift in Sources */, BF696B801D9B2B02009639E0 /* Theme.swift in Sources */, BF7AE8081C2E858400B1B5BC /* GridMenuViewController.swift in Sources */, BF6BF31A1EB82146008E83CD /* ClipboardImportOption.swift in Sources */, diff --git a/Delta/Settings/Cores/MelonDSCoreSettingsViewController.swift b/Delta/Settings/Cores/MelonDSCoreSettingsViewController.swift index 7da3a22..8686fda 100644 --- a/Delta/Settings/Cores/MelonDSCoreSettingsViewController.swift +++ b/Delta/Settings/Cores/MelonDSCoreSettingsViewController.swift @@ -9,6 +9,7 @@ import UIKit import SafariServices import MobileCoreServices +import CryptoKit import DeltaCore import MelonDSDeltaCore @@ -27,44 +28,77 @@ private extension MelonDSCoreSettingsViewController case changeCore } - enum DSBIOS: Int + enum BIOSError: LocalizedError { - case bios7 - case bios9 - case firmware + case unknownSize(URL) + case incorrectHash(URL, hash: String, expectedHash: String) - var fileURL: URL { + @available(iOS 13, *) + case incorrectSize(URL, size: Int, validSizes: Set>>) + + private static let byteFormatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.includesActualByteCount = true + formatter.countStyle = .binary + return formatter + }() + + var errorDescription: String? { switch self { - case .bios7: return MelonDSEmulatorBridge.shared.bios7URL - case .bios9: return MelonDSEmulatorBridge.shared.bios9URL - case .firmware: return MelonDSEmulatorBridge.shared.firmwareURL + case .unknownSize(let fileURL): + return String(format: NSLocalizedString("%@’s size could not be determined.", comment: ""), fileURL.lastPathComponent) + + case .incorrectHash(let fileURL, let md5Hash, let expectedHash): + return String(format: NSLocalizedString("%@‘s hash does not match the expected hash.\n\nHash:\n%@\n\nExpected:\n%@.", comment: ""), fileURL.lastPathComponent, md5Hash, expectedHash) + + case .incorrectSize(let fileURL, let size, let validSizes): + let actualSize = BIOSError.byteFormatter.string(fromByteCount: Int64(size)) + + if let range = validSizes.first, validSizes.count == 1 + { + if range.lowerBound == range.upperBound + { + // Single value + let expectedSize = BIOSError.byteFormatter.string(fromByteCount: Int64(range.lowerBound.converted(to: .bytes).value)) + return String(format: NSLocalizedString("%@ is %@, but expected size is %@.", comment: ""), fileURL.lastPathComponent, actualSize, expectedSize) + } + else + { + // Range + BIOSError.byteFormatter.includesActualByteCount = false + defer { BIOSError.byteFormatter.includesActualByteCount = true } + + let lowerBound = BIOSError.byteFormatter.string(fromByteCount: Int64(range.lowerBound.converted(to: .bytes).value)) + let upperBound = BIOSError.byteFormatter.string(fromByteCount: Int64(range.upperBound.converted(to: .bytes).value)) + return String(format: NSLocalizedString("%@ is %@, but expected size is between %@ and %@.", comment: ""), fileURL.lastPathComponent, actualSize, lowerBound, upperBound) + } + } + else + { + var description = String(format: NSLocalizedString("%@ is %@, but expected sizes are:", comment: ""), fileURL.lastPathComponent, actualSize) + "\n" + + let sortedRanges = validSizes.sorted(by: { $0.lowerBound < $1.lowerBound }) + for range in sortedRanges + { + // Assume BIOS with multiple valid file sizes don't use (>1 count) ranges. + description += "\n" + BIOSError.byteFormatter.string(fromByteCount: Int64(range.lowerBound.converted(to: .bytes).value)) + } + + return description + } } } - } - - enum DSiBIOS: Int - { - case bios7 - case bios9 - case firmware - case nand - var fileURL: URL { - switch self - { - case .bios7: return MelonDSEmulatorBridge.shared.dsiBIOS7URL - case .bios9: return MelonDSEmulatorBridge.shared.dsiBIOS9URL - case .firmware: return MelonDSEmulatorBridge.shared.dsiFirmwareURL - case .nand: return MelonDSEmulatorBridge.shared.dsiNANDURL - } + var recoverySuggestion: String? { + return NSLocalizedString("Please choose a different BIOS file.", comment: "") } } } class MelonDSCoreSettingsViewController: UITableViewController { - private var importDestinationURL: URL? + private var importingBIOS: SystemBIOS? override func viewDidLoad() { @@ -121,9 +155,9 @@ private extension MelonDSCoreSettingsViewController self.present(safariViewController, animated: true, completion: nil) } - func locateBIOS(for destinationURL: URL) + func locate(_ bios: BIOS) { - self.importDestinationURL = destinationURL + self.importingBIOS = bios var supportedTypes = [kUTTypeItem as String, kUTTypeContent as String, "com.apple.macbinary-archive" /* System UTI for .bin */] @@ -220,7 +254,7 @@ extension MelonDSCoreSettingsViewController cell.contentView.isHidden = (item == nil) case .dsBIOS: - let bios = DSBIOS(rawValue: indexPath.row)! + let bios = DSBIOS.allCases[indexPath.row] if FileManager.default.fileExists(atPath: bios.fileURL.path) { @@ -238,7 +272,7 @@ extension MelonDSCoreSettingsViewController cell.selectionStyle = .default case .dsiBIOS: - let bios = DSiBIOS(rawValue: indexPath.row)! + let bios = DSiBIOS.allCases[indexPath.row] if FileManager.default.fileExists(atPath: bios.fileURL.path) { @@ -288,12 +322,12 @@ extension MelonDSCoreSettingsViewController self.openMetadataURL(for: key) case .dsBIOS: - let bios = DSBIOS(rawValue: indexPath.row)! - self.locateBIOS(for: bios.fileURL) + let bios = DSBIOS.allCases[indexPath.row] + self.locate(bios) case .dsiBIOS: - let bios = DSiBIOS(rawValue: indexPath.row)! - self.locateBIOS(for: bios.fileURL) + let bios = DSiBIOS.allCases[indexPath.row] + self.locate(bios) case .changeCore: self.changeCore() @@ -374,28 +408,58 @@ extension MelonDSCoreSettingsViewController: UIDocumentPickerDelegate { func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { - self.importDestinationURL = nil + self.importingBIOS = nil self.tableView.reloadData() // Reloading index path causes cell to disappear... } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { defer { - self.importDestinationURL = nil + self.importingBIOS = nil self.tableView.reloadData() // Reloading index path causes cell to disappear... } - guard let fileURL = urls.first, let destinationURL = self.importDestinationURL else { return } + guard let fileURL = urls.first, let bios = self.importingBIOS else { return } + + defer { try? FileManager.default.removeItem(at: fileURL) } do { - try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true) + if #available(iOS 13.0, *) + { + // Validate file size first (since that's easiest for users to understand). + + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + guard let fileSize = attributes[.size] as? Int else { throw BIOSError.unknownSize(fileURL) } + + let measurement = Measurement(value: Double(fileSize), unit: .bytes) + guard bios.validFileSizes.contains(where: { $0.contains(measurement) }) else { throw BIOSError.incorrectSize(fileURL, size: fileSize, validSizes: bios.validFileSizes) } + + if let expectedMD5Hash = bios.expectedMD5Hash + { + // If there's an expected hash, make sure it matches. + + let data = try Data(contentsOf: fileURL) + + let md5Hash = Insecure.MD5.hash(data: data) + let hashString = md5Hash.compactMap { String(format: "%02x", $0) }.joined() + guard hashString == expectedMD5Hash else { throw BIOSError.incorrectHash(fileURL, hash: hashString, expectedHash: expectedMD5Hash) } + } + } + + try FileManager.default.copyItem(at: fileURL, to: bios.fileURL, shouldReplace: true) } - catch + catch let error as NSError { - let title = String(format: NSLocalizedString("Could not import %@.", comment: ""), fileURL.lastPathComponent) + let title = String(format: NSLocalizedString("Could not import %@.", comment: ""), bios.filename) - let alertController = UIAlertController(title: title, message: error.localizedDescription, preferredStyle: .alert) + var message = error.localizedDescription + if let recoverySuggestion = error.localizedRecoverySuggestion + { + message += "\n\n" + recoverySuggestion + } + + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) alertController.addAction(.ok) self.present(alertController, animated: true, completion: nil) } diff --git a/Delta/Settings/Cores/SystemBIOS.swift b/Delta/Settings/Cores/SystemBIOS.swift new file mode 100644 index 0000000..5a847cf --- /dev/null +++ b/Delta/Settings/Cores/SystemBIOS.swift @@ -0,0 +1,123 @@ +// +// SystemBIOS.swift +// Delta +// +// Created by Riley Testut on 1/19/21. +// Copyright © 2021 Riley Testut. All rights reserved. +// + +import Foundation + +import MelonDSDeltaCore + +protocol SystemBIOS +{ + var fileURL: URL { get } + var filename: String { get } + + var expectedMD5Hash: String? { get } + + // RangeSet would be preferable, but it's not in Swift stdlib yet. + @available(iOS 13, *) + var validFileSizes: Set>> { get } +} + +extension SystemBIOS +{ + var filename: String { + return self.fileURL.lastPathComponent + } +} + +enum DSBIOS: SystemBIOS, CaseIterable +{ + case bios7 + case bios9 + case firmware + + var fileURL: URL { + switch self + { + case .bios7: return MelonDSEmulatorBridge.shared.bios7URL + case .bios9: return MelonDSEmulatorBridge.shared.bios9URL + case .firmware: return MelonDSEmulatorBridge.shared.firmwareURL + } + } + + var expectedMD5Hash: String? { + switch self + { + case .bios7: return "df692a80a5b1bc90728bc3dfc76cd948" + case .bios9: return "a392174eb3e572fed6447e956bde4b25" + case .firmware: return nil + } + } + + @available(iOS 13, *) + var validFileSizes: Set>> { + // From http://melonds.kuribo64.net/faq.php + switch self + { + case .bios7: + // 16KB + return Set([16].map { Measurement(value: $0, unit: .kibibytes) }.map { $0...$0 }) + case .bios9: + // 4KB + return Set([4].map { Measurement(value: $0, unit: .kibibytes) }.map { $0...$0 }) + case .firmware: + // 128KB, 256KB, or 512KB + return Set([128, 256, 512].map { Measurement(value: $0, unit: .kibibytes) }.map { $0...$0 }) + } + } +} + +enum DSiBIOS: SystemBIOS, CaseIterable +{ + case bios7 + case bios9 + case firmware + case nand + + var fileURL: URL { + switch self + { + case .bios7: return MelonDSEmulatorBridge.shared.dsiBIOS7URL + case .bios9: return MelonDSEmulatorBridge.shared.dsiBIOS9URL + case .firmware: return MelonDSEmulatorBridge.shared.dsiFirmwareURL + case .nand: return MelonDSEmulatorBridge.shared.dsiNANDURL + } + } + + var expectedMD5Hash: String? { + switch self + { + case .bios7: return "559dae4ea78eb9d67702c56c1d791e81" + case .bios9: return "87b665fce118f76251271c3732532777" + case .firmware: return nil + case .nand: return nil + } + } + + @available(iOS 13, *) + var validFileSizes: Set>> { + // From http://melonds.kuribo64.net/faq.php + switch self + { + case .bios7: + // 64KB + return Set([64].map { Measurement(value: $0, unit: .kibibytes) }.map { $0...$0 }) + + case .bios9: + // 64KB + return Set([64].map { Measurement(value: $0, unit: .kibibytes) }.map { $0...$0 }) + + case .firmware: + // 128KB + return Set([128].map { Measurement(value: $0, unit: .kibibytes) }.map { $0...$0 }) + + case .nand: + // 200MB - 300MB + return Set([200...300].map { Measurement(value: $0.lowerBound, unit: .mebibytes) ... Measurement(value: $0.upperBound, unit: .mebibytes) }) + } + } +}