import Foundation #if canImport(Darwin) import Darwin #elseif canImport(Glibc) import Glibc #endif /// Resolves a path typed on the command line into a single concrete path. /// /// The problem this exists for: an operator writes /// `--ipsw ~/Downloads/UniversalMac_27.0_*.ipsw`. Unquoted, the shell may expand /// the glob before we ever run — or, in `zsh`, refuse to run the command at all /// with `no matches found`. Quoted, we receive the pattern verbatim, tilde and /// asterisk included, and a plain `expandingTildeInPath` leaves a `*` sitting in /// the middle of a path that will never exist. Either way the operator sees a /// tool that "only works with quotes" (or only without them). /// /// So the CLI resolves the argument itself and stops depending on which shell /// ran it. This is deliberately a **CLI-argument affordance only**: values read /// out of `config.json` get tilde expansion (see /// ``RunnerConfig/expandTilde(_:)``) and nothing more, because a config file is /// not typed at a prompt and a stray `*` there is a mistake, not a pattern. public enum PathResolution { /// Characters that make a string a pattern rather than a path. /// /// `~` is not one of them: it is expanded unconditionally, pattern or not. private static let metacharacters: Set = ["*", "?", "["] /// Whether `path` should be treated as a glob pattern. public static func isPattern(_ path: String) -> Bool { path.contains(where: metacharacters.contains) } /// Expands a leading `~`, then resolves any glob pattern to one path. /// /// A string with no metacharacters passes through with only tilde /// expansion — in particular it is *not* checked for existence, so the /// caller's own error (which knows what the file was for) is what an /// operator sees for an ordinary typo. /// /// A string that does contain metacharacters is matched with `glob(3)`. If /// it matches nothing but a file exists at that literal name, the literal /// wins: `report[1].ipsw` is a legal filename, and only failing to match /// tells us it was meant as one rather than as a character class. /// /// - Parameters: /// - path: The raw argument, as typed. /// - label: What the argument names, for error messages (`"--ipsw"`). /// - Returns: A single concrete path. /// - Throws: ``CoreError/notFound(_:)`` when a pattern matches nothing, or /// ``CoreError/configInvalid(_:)`` when it matches more than one file — /// picking one arbitrarily would silently build the wrong image. public static func resolve(_ path: String, label: String) throws -> String { let expanded = RunnerConfig.expandTilde(path) guard isPattern(expanded) else { return expanded } let matches = glob(pattern: expanded) switch matches.count { case 1: return matches[0] case 0: if FileManager.default.fileExists(atPath: expanded) { return expanded } throw CoreError.notFound("\(label): no file matches \(expanded)") default: let listed = matches.map { " \($0)" }.joined(separator: "\n") throw CoreError.configInvalid( "\(label): \(matches.count) files match \(expanded):\n\(listed)\n" + "name exactly one of them" ) } } /// All paths matching `pattern`, sorted. /// /// Sorted explicitly rather than relying on `glob(3)`'s own ordering, which /// is locale-dependent — the error message above lists these, and a listing /// that reorders between runs is a poor thing to ask someone to read. static func glob(pattern: String) -> [String] { var result = glob_t() defer { globfree(&result) } guard Glibc_glob(pattern, &result) == 0 else { return [] } guard let paths = result.gl_pathv else { return [] } var found: [String] = [] for index in 0.. ) -> Int32 { #if canImport(Darwin) return Darwin.glob(pattern, 0, nil, result) #elseif canImport(Glibc) return Glibc.glob(pattern, 0, nil, result) #else return -1 #endif } } /// Cheap sanity checks on a `.ipsw` before Virtualization.framework sees it. /// /// Lives beside ``PathResolution`` because it is the other half of the same /// job — what the CLI does with a path an operator typed — and because /// `RunnerCore` is the only target that unit-tests on both platforms. /// /// The checks earn their place: `VZMacOSRestoreImage.image(from:)` reports no /// progress at all while it works, and on a partial download it can sit for a /// very long time rather than failing. Without these, "I pointed it at the /// wrong file" and "my 21 GB download stopped at 4 GB" both present to the /// operator as an unexplained hang. public enum IPSWFile { /// The floor a real macOS restore image clears by an order of magnitude — /// they run ~15-22 GB. Anything under this is a truncated download, a /// placeholder, or the wrong file entirely. public static let minimumBytes: Int64 = 1_000_000_000 /// The first two bytes of every `.ipsw`: an IPSW is a zip archive. static let zipMagic = Data([0x50, 0x4B]) // "PK" /// Fails fast if `path` cannot be a usable restore image. /// /// - Parameters: /// - path: An already-resolved absolute path (see ``PathResolution``). /// - label: What the file is, for error messages. /// - Throws: ``CoreError/notFound(_:)`` if it is not there or unreadable, /// ``CoreError/configInvalid(_:)`` if it is there but cannot be an IPSW. public static func validate(path: String, label: String = "restore image") throws { var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { throw CoreError.notFound("\(label) not found at \(path)") } guard !isDirectory.boolValue else { throw CoreError.configInvalid( "\(label) at \(path) is a directory, not an .ipsw file" ) } let attributes = try? FileManager.default.attributesOfItem(atPath: path) let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0 guard size >= minimumBytes else { throw CoreError.configInvalid( "\(label) at \(path) is only \(describeSize(size)) — a macOS restore image is " + "~15-22 GB. The download is most likely incomplete; delete the file and " + "fetch it again." ) } guard let handle = FileHandle(forReadingAtPath: path) else { throw CoreError.notFound("\(label) at \(path) could not be opened for reading") } defer { try? handle.close() } let magic = (try? handle.read(upToCount: zipMagic.count)) ?? Data() guard magic == zipMagic else { let found = magic.map { String(format: "%02x", $0) }.joined() throw CoreError.configInvalid( "\(label) at \(path) does not look like an .ipsw: expected a zip archive " + "(magic \"PK\", 504b) but the file starts with \(found.isEmpty ? "nothing" : found). " + "Check the path, or re-download the file." ) } } /// A byte count an operator can compare against "~15 GB" at a glance. static func describeSize(_ bytes: Int64) -> String { let units = ["B", "KB", "MB", "GB", "TB"] var value = Double(bytes) var unit = 0 while value >= 1024, unit < units.count - 1 { value /= 1024 unit += 1 } return unit == 0 ? "\(Int(value)) B" : String(format: "%.1f %@", value, units[unit]) } }