255 lines
10 KiB
Swift
255 lines
10 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import RunnerCore
|
|
|
|
/// Tests for ``PathResolution`` — the CLI's defence against shell quoting.
|
|
///
|
|
/// The bug these exist for: `--ipsw ~/Downloads/UniversalMac_27.0_*.ipsw`
|
|
/// behaves differently depending on whether the shell expanded the glob, so the
|
|
/// tool has to resolve the argument itself and stop caring.
|
|
@Suite("PathResolution")
|
|
struct PathResolutionTests {
|
|
|
|
/// A scratch directory holding `names`, deleted when `body` returns.
|
|
private func withFiles(_ names: [String], _ body: (String) throws -> Void) throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("gmr-path-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
for name in names {
|
|
let path = dir.appendingPathComponent(name)
|
|
#expect(FileManager.default.createFile(atPath: path.path, contents: Data()))
|
|
}
|
|
try body(dir.path)
|
|
}
|
|
|
|
// MARK: - Pattern detection
|
|
|
|
@Test("only glob metacharacters make a string a pattern")
|
|
func patternDetection() {
|
|
#expect(!PathResolution.isPattern("/tmp/UniversalMac_27.0.ipsw"))
|
|
// A tilde is expanded either way; it does not make this a glob.
|
|
#expect(!PathResolution.isPattern("~/Downloads/x.ipsw"))
|
|
#expect(PathResolution.isPattern("/tmp/a_*.ipsw"))
|
|
#expect(PathResolution.isPattern("/tmp/a_?.ipsw"))
|
|
#expect(PathResolution.isPattern("/tmp/a_[12].ipsw"))
|
|
}
|
|
|
|
// MARK: - Passthrough
|
|
|
|
@Test("a plain path is returned untouched")
|
|
func plainPathPassesThrough() throws {
|
|
// Deliberately not checked for existence: the caller's own error knows
|
|
// what the file was for and says something more useful than we could.
|
|
#expect(try PathResolution.resolve("/tmp/nope.ipsw", label: "--ipsw") == "/tmp/nope.ipsw")
|
|
#expect(try PathResolution.resolve("relative/x.ipsw", label: "--ipsw") == "relative/x.ipsw")
|
|
}
|
|
|
|
@Test("a leading tilde is expanded")
|
|
func tildeIsExpanded() throws {
|
|
let home = NSHomeDirectory()
|
|
#expect(try PathResolution.resolve("~/Downloads/x.ipsw", label: "--ipsw") == home + "/Downloads/x.ipsw")
|
|
// Only leading: a tilde inside a path component is an ordinary character.
|
|
#expect(try PathResolution.resolve("/tmp/~x.ipsw", label: "--ipsw") == "/tmp/~x.ipsw")
|
|
}
|
|
|
|
// MARK: - Globbing
|
|
|
|
@Test("a pattern matching exactly one file resolves to it")
|
|
func singleMatchResolves() throws {
|
|
try withFiles(["UniversalMac_27.0_ABC.ipsw", "notes.txt"]) { dir in
|
|
let resolved = try PathResolution.resolve("\(dir)/UniversalMac_27.0_*.ipsw", label: "--ipsw")
|
|
#expect(resolved == "\(dir)/UniversalMac_27.0_ABC.ipsw")
|
|
}
|
|
}
|
|
|
|
@Test("a pattern matching nothing is a clear notFound")
|
|
func zeroMatchesThrows() throws {
|
|
try withFiles(["a_1.ipsw"]) { dir in
|
|
#expect(throws: CoreError.self) {
|
|
try PathResolution.resolve("\(dir)/nope_*.ipsw", label: "--ipsw")
|
|
}
|
|
do {
|
|
_ = try PathResolution.resolve("\(dir)/nope_*.ipsw", label: "--ipsw")
|
|
Issue.record("expected a throw")
|
|
} catch let error as CoreError {
|
|
guard case .notFound(let message) = error else {
|
|
Issue.record("expected .notFound, got \(error)")
|
|
return
|
|
}
|
|
#expect(message.contains("--ipsw"))
|
|
#expect(message.contains("no file matches"))
|
|
#expect(message.contains("\(dir)/nope_*.ipsw"))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("a pattern matching several files lists them, sorted, and refuses")
|
|
func multipleMatchesThrowsAndLists() throws {
|
|
// Created out of order: the message must not depend on creation order,
|
|
// because the operator is being asked to read it and pick one.
|
|
try withFiles(["a_2.ipsw", "a_10.ipsw", "a_1.ipsw"]) { dir in
|
|
do {
|
|
_ = try PathResolution.resolve("\(dir)/a_*.ipsw", label: "--ipsw")
|
|
Issue.record("expected a throw")
|
|
} catch let error as CoreError {
|
|
guard case .configInvalid(let message) = error else {
|
|
Issue.record("expected .configInvalid, got \(error)")
|
|
return
|
|
}
|
|
#expect(message.contains("--ipsw"))
|
|
#expect(message.contains("3 files match"))
|
|
for name in ["a_1.ipsw", "a_2.ipsw", "a_10.ipsw"] {
|
|
#expect(message.contains("\(dir)/\(name)"))
|
|
}
|
|
// Sorted, so two runs read identically.
|
|
let one = message.range(of: "a_1.ipsw")!.lowerBound
|
|
let ten = message.range(of: "a_10.ipsw")!.lowerBound
|
|
let two = message.range(of: "a_2.ipsw")!.lowerBound
|
|
#expect(one < ten)
|
|
#expect(ten < two)
|
|
#expect(message.contains("name exactly one of them"))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("brackets are a character class when they match, and a filename when they do not")
|
|
func bracketsAreGlobOnlyWhenTheyAreOne() throws {
|
|
// Used as a class: `[12]` selects the one file that exists.
|
|
try withFiles(["build_1.ipsw"]) { dir in
|
|
let asClass = try PathResolution.resolve("\(dir)/build_[12].ipsw", label: "--ipsw")
|
|
#expect(asClass == "\(dir)/build_1.ipsw")
|
|
}
|
|
|
|
// A real file whose name contains brackets. Read as a class it matches
|
|
// nothing, so the literal has to win — otherwise a legal filename is
|
|
// unreachable through this flag.
|
|
try withFiles(["report[1].ipsw"]) { dir in
|
|
let asLiteral = try PathResolution.resolve("\(dir)/report[1].ipsw", label: "--ipsw")
|
|
#expect(asLiteral == "\(dir)/report[1].ipsw")
|
|
}
|
|
}
|
|
|
|
@Test("a pattern that resolves is not confused by neighbours of another extension")
|
|
func matchingIsScopedToThePattern() throws {
|
|
try withFiles(["a_1.ipsw", "a_1.ipsw.part", "a_1.txt"]) { dir in
|
|
let resolved = try PathResolution.resolve("\(dir)/a_*.ipsw", label: "--ipsw")
|
|
#expect(resolved == "\(dir)/a_1.ipsw")
|
|
}
|
|
}
|
|
|
|
// MARK: - IPSW pre-validation
|
|
|
|
/// Writes `bytes` at the front of a sparse file of `size` bytes.
|
|
///
|
|
/// Sparse because a valid-size fixture is a gigabyte and nobody should wait
|
|
/// for a gigabyte of zeroes to be written to test a two-byte check.
|
|
private func withIPSW(bytes: [UInt8], size: UInt64, _ body: (String) throws -> Void) throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("gmr-ipsw-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let path = dir.appendingPathComponent("image.ipsw").path
|
|
#expect(FileManager.default.createFile(atPath: path, contents: Data(bytes)))
|
|
let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path))
|
|
try handle.truncate(atOffset: size)
|
|
try handle.close()
|
|
|
|
try body(path)
|
|
}
|
|
|
|
private let pk: [UInt8] = [0x50, 0x4B]
|
|
private let validSize: UInt64 = 2_000_000_000
|
|
|
|
@Test("a plausible restore image passes")
|
|
func healthyIPSWValidates() throws {
|
|
try withIPSW(bytes: pk, size: validSize) { path in
|
|
try IPSWFile.validate(path: path)
|
|
}
|
|
}
|
|
|
|
@Test("a missing file is notFound, not a hang")
|
|
func missingIPSWThrows() throws {
|
|
#expect(throws: CoreError.self) {
|
|
try IPSWFile.validate(path: "/tmp/gmr-definitely-absent.ipsw")
|
|
}
|
|
}
|
|
|
|
@Test("a directory is rejected before the framework sees it")
|
|
func directoryIsRejected() throws {
|
|
try withFiles([]) { dir in
|
|
do {
|
|
try IPSWFile.validate(path: dir)
|
|
Issue.record("expected a throw")
|
|
} catch let error as CoreError {
|
|
#expect("\(error)".contains("directory"))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("a truncated download names its own size and says what happened")
|
|
func truncatedIPSWIsRejected() throws {
|
|
// 4 MB: the shape of a download that stopped early — right magic bytes,
|
|
// nowhere near the right length.
|
|
try withIPSW(bytes: pk, size: 4_000_000) { path in
|
|
do {
|
|
try IPSWFile.validate(path: path)
|
|
Issue.record("expected a throw")
|
|
} catch let error as CoreError {
|
|
guard case .configInvalid(let message) = error else {
|
|
Issue.record("expected .configInvalid, got \(error)")
|
|
return
|
|
}
|
|
#expect(message.contains("3.8 MB"))
|
|
#expect(message.contains("incomplete"))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("an empty file is rejected on size, before anything reads it")
|
|
func emptyIPSWIsRejected() throws {
|
|
try withIPSW(bytes: [], size: 0) { path in
|
|
#expect(throws: CoreError.self) { try IPSWFile.validate(path: path) }
|
|
}
|
|
}
|
|
|
|
@Test("a big file that is not a zip is rejected on its magic bytes")
|
|
func wrongMagicIsRejected() throws {
|
|
try withIPSW(bytes: [0x00, 0x01], size: validSize) { path in
|
|
do {
|
|
try IPSWFile.validate(path: path)
|
|
Issue.record("expected a throw")
|
|
} catch let error as CoreError {
|
|
guard case .configInvalid(let message) = error else {
|
|
Issue.record("expected .configInvalid, got \(error)")
|
|
return
|
|
}
|
|
#expect(message.contains("PK"))
|
|
#expect(message.contains("0001"))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("sizes are rendered the way the operator will compare them")
|
|
func sizeDescriptions() {
|
|
#expect(IPSWFile.describeSize(0) == "0 B")
|
|
#expect(IPSWFile.describeSize(512) == "512 B")
|
|
#expect(IPSWFile.describeSize(22_567_352_533) == "21.0 GB")
|
|
}
|
|
|
|
@Test("the label names the offending flag so the operator knows what to fix")
|
|
func labelAppearsInErrors() throws {
|
|
try withFiles([]) { dir in
|
|
do {
|
|
_ = try PathResolution.resolve("\(dir)/*.xip", label: "--xcode-xip")
|
|
Issue.record("expected a throw")
|
|
} catch let error as CoreError {
|
|
#expect("\(error)".contains("--xcode-xip"))
|
|
}
|
|
}
|
|
}
|
|
}
|