Files
gitea-macos-vm-orchestrator/Sources/RunnerCore/PathResolution.swift
T

188 lines
8.1 KiB
Swift

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<Character> = ["*", "?", "["]
/// 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..<Int(result.gl_pathc) {
guard let entry = paths[index] else { continue }
found.append(String(cString: entry))
}
return found.sorted()
}
/// Thin shim so the call above reads the same on both platforms; `glob(3)`
/// is otherwise shadowed by the ``glob(pattern:)`` above.
private static func Glibc_glob(
_ pattern: String,
_ result: UnsafeMutablePointer<glob_t>
) -> 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])
}
}