feat: add VPhoneCore library — VM bundle model + host primitives

The library the consolidated CLI is built on:
- bundle/library model (VPhoneBundle, VPhoneLibrary, VPhoneVirtualMachineManifest),
  bundle ops + reporting, restore helpers (VPhoneRestoreOps)
- host process primitives: VPhoneProcessRunner, VPhoneManagedProcess (spawn +
  stdout pattern-match + SIGKILL-escalating terminate), VPhoneLaunchLayout,
  VPhoneBootPatterns
- VPhoneResources: bundled-.app vs dev asset resolution, and Python resolution
  that provisions a per-user venv (~/.vphone/venv) on demand so the app is
  portable — never depends on the repo's .venv
- VPhoneVerbosity (quiet/info/debug/trace) and VPhoneVMPicker
Manifest moves out of the executable target into VPhoneCore. Full unit-test
suite under tests/VPhoneCoreTests.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y4VDqWf5pVakcFLqB23CKe
This commit is contained in:
zqxwce
2026-07-27 19:46:27 +03:00
committed by zqxwce
co-authored by Claude Opus 4.8
parent f92d82cd75
commit 828be6dbc6
27 changed files with 2261 additions and 180 deletions
+14
View File
@@ -22,15 +22,24 @@ let package = Package(
.product(name: "Capstone", package: "libcapstone-spm"), .product(name: "Capstone", package: "libcapstone-spm"),
.product(name: "Img4tool", package: "libimg4-spm"), .product(name: "Img4tool", package: "libimg4-spm"),
.product(name: "MachOKit", package: "MachOKit"), .product(name: "MachOKit", package: "MachOKit"),
"VPhoneCore",
], ],
path: "sources/FirmwarePatcher" path: "sources/FirmwarePatcher"
), ),
.target(
name: "VPhoneCore",
path: "sources/VPhoneCore",
linkerSettings: [
.linkedFramework("Virtualization"),
]
),
.executableTarget( .executableTarget(
name: "vphone-cli", name: "vphone-cli",
dependencies: [ dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Dynamic", package: "Dynamic"), .product(name: "Dynamic", package: "Dynamic"),
"FirmwarePatcher", "FirmwarePatcher",
"VPhoneCore",
], ],
path: "sources/vphone-cli", path: "sources/vphone-cli",
linkerSettings: [ linkerSettings: [
@@ -46,5 +55,10 @@ let package = Package(
dependencies: ["FirmwarePatcher"], dependencies: ["FirmwarePatcher"],
path: "tests/FirmwarePatcherTests" path: "tests/FirmwarePatcherTests"
), ),
.testTarget(
name: "VPhoneCoreTests",
dependencies: ["VPhoneCore"],
path: "tests/VPhoneCoreTests"
),
] ]
) )
@@ -0,0 +1,72 @@
import Foundation
// MARK: - VPhoneBootPatterns
/// Boot-log regex patterns and device-identity normalization, ported VERBATIM
/// from `scripts/setup_machine.sh` so the native `vm create` orchestrator
/// (`VPhoneCreateOrchestrator`, executable target) matches the proven shell
/// choreography exactly. Lives in VPhoneCore (rather than alongside the
/// orchestrator) purely so `VPhoneCoreTests` can unit-test these pure,
/// device-independent pieces the orchestrator itself needs `FirmwarePatcher`
/// and can't live in VPhoneCore without creating a package dependency cycle
/// (FirmwarePatcher already depends on VPhoneCore).
public enum VPhoneBootPatterns {
/// `BOOT_BASH_PROMPT_REGEX` (setup_machine.sh:39) the iosbinpack bash
/// prompt, or a ramdisk/root shell prompt.
public static let promptRegex = #"bash-[0-9]+(\.[0-9]+)+#|:/[^ ]* root#"#
/// `BOOT_PANIC_REGEX` (setup_machine.sh:40).
public static let panicRegex = #"(^|[^p])(panic|kernel panic|panic\.apple\.com|stackshot succeeded)"#
/// `monitor_boot_log_until` (setup_machine.sh:363-388) checks panic and
/// prompt with DIFFERENT case sensitivity `grep -Eiq "$BOOT_PANIC_REGEX"`
/// (case-insensitive) vs. `grep -Eq "$BOOT_BASH_PROMPT_REGEX"` (case-
/// sensitive). A single `NSRegularExpression` has one global case-folding
/// setting, so the panic half is wrapped in an ICU scoped inline modifier
/// (`(?i:...)`) to fold ONLY that half, leaving the prompt half exactly as
/// case-sensitive as the shell's plain `grep -E`.
public static let panicOrPromptRegex = "(?i:\(panicRegex))|\(promptRegex)"
/// `send_first_boot_commands` (setup_machine.sh:344-361) verbatim order,
/// including the exact PATH string (setup_machine.sh:348).
public static let firstBootCommands: [String] = [
"export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/bin/X11:/usr/games:/iosbinpack64/usr/local/sbin:/iosbinpack64/usr/local/bin:/iosbinpack64/usr/sbin:/iosbinpack64/usr/bin:/iosbinpack64/sbin:/iosbinpack64/bin'",
"cp /iosbinpack64/etc/profile /var/profile",
"cp /iosbinpack64/etc/motd /var/motd",
"mkdir -p /var/dropbear",
"dropbearkey -t rsa -f /var/dropbear/dropbear_rsa_host_key",
"dropbearkey -t ecdsa -f /var/dropbear/dropbear_ecdsa_host_key",
"shutdown -h now",
]
/// Port of `normalize_ecid` (setup_machine.sh:80-86): strip one leading
/// `0x` and one leading `0X` (matching the shell's two sequential `#0x`/
/// `#0X` strips), require 1-16 ASCII hex digits, uppercase, and left-pad
/// with zeros to 16 characters. `nil` for anything that isn't valid hex
/// the shell's `return 1` from the `[[ =~ ]]` guard.
public static func normalizeECID(_ raw: String) -> String? {
var value = raw
if value.hasPrefix("0x") { value.removeFirst(2) }
if value.hasPrefix("0X") { value.removeFirst(2) }
guard !value.isEmpty, value.count <= 16, value.allSatisfy(isASCIIHexDigit) else {
return nil
}
return String(repeating: "0", count: 16 - value.count) + value.uppercased()
}
/// ASCII-only hex digit check (`[0-9A-Fa-f]`) deliberately narrower than
/// `Character.isHexDigit`, which also accepts Unicode fullwidth digits
/// that the shell's `[[ =~ ^[0-9A-Fa-f]{1,16}$ ]]` would reject.
private static func isASCIIHexDigit(_ c: Character) -> Bool {
guard let ascii = c.asciiValue else { return false }
return (0x30...0x39).contains(ascii) || (0x41...0x46).contains(ascii) || (0x61...0x66).contains(ascii)
}
/// Pure parse of `sysctl -n kern.hv_vmm_present` output `"1"` means the
/// host is itself an Apple VM (nested), where Virtualization.framework
/// PV=3 guest boot is unavailable. Mirrors the `boot_host_preflight.sh`
/// gate `make boot` applied before native `vm create` replaced it.
public static func parseHVVmmPresent(_ output: String) -> Bool {
output.trimmingCharacters(in: .whitespacesAndNewlines) == "1"
}
}
+28
View File
@@ -0,0 +1,28 @@
import Foundation
// MARK: - Bundle
public struct VPhoneBundle: Sendable {
public let url: URL
public let manifest: VPhoneVirtualMachineManifest
public init(url: URL, manifest: VPhoneVirtualMachineManifest) {
self.url = url
self.manifest = manifest
}
public var name: String { url.lastPathComponent }
public var configURL: URL { url.appendingPathComponent("config.plist") }
public var diskSizeBytes: Int64 {
let disk = url.appendingPathComponent(manifest.diskImage)
let attrs = try? FileManager.default.attributesOfItem(atPath: disk.path)
return (attrs?[.size] as? NSNumber)?.int64Value ?? 0
}
public static func load(at url: URL) throws -> VPhoneBundle {
let manifest = try VPhoneVirtualMachineManifest.load(
from: url.appendingPathComponent("config.plist"))
return VPhoneBundle(url: url, manifest: manifest)
}
}
+221
View File
@@ -0,0 +1,221 @@
import Darwin
import Foundation
public enum VPhoneBundleOpsError: Error, Equatable {
case tarFailed(String)
case badArchive(String)
}
public enum VPhoneBundleOps {
public struct NewBundleSpec: Sendable {
public var name: String
public var cpuCount: UInt
public var memoryMB: UInt64
public var diskSizeGB: UInt64
public var romSource: URL
public var sepromSource: URL
public init(name: String, cpuCount: UInt, memoryMB: UInt64, diskSizeGB: UInt64,
romSource: URL, sepromSource: URL) {
self.name = name; self.cpuCount = cpuCount; self.memoryMB = memoryMB
self.diskSizeGB = diskSizeGB; self.romSource = romSource; self.sepromSource = sepromSource
}
}
private static let frameworkResources = URL(fileURLWithPath:
"/System/Library/Frameworks/Virtualization.framework/Versions/A/Resources")
public static func defaultROMSource() -> URL {
frameworkResources.appendingPathComponent("AVPBooter.vresearch1.bin")
}
public static func defaultSEPROMSource() -> URL {
frameworkResources.appendingPathComponent("AVPSEPBooter.vresearch1.bin")
}
private static func requireValidName(_ name: String) throws {
guard !name.isEmpty, !name.contains("/"), !name.hasPrefix(".") else {
throw VPhoneLibraryError.invalidName(name)
}
}
public static func create(_ spec: NewBundleSpec, in library: VPhoneLibrary) throws -> VPhoneBundle {
try requireValidName(spec.name)
let fm = FileManager.default
let dir = library.url(forName: spec.name)
if fm.fileExists(atPath: dir.path) {
throw VPhoneLibraryError.alreadyExists(name: spec.name)
}
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
// Roll back the partial bundle on any failure after the dir is created,
// so a retry with the same name isn't permanently blocked by the
// alreadyExists check.
do {
// Sparse disk image: create then truncate to size (no bytes written).
let disk = dir.appendingPathComponent("Disk.img")
fm.createFile(atPath: disk.path, contents: nil)
let handle = try FileHandle(forWritingTo: disk)
do {
try handle.truncate(atOffset: spec.diskSizeGB * 1024 * 1024 * 1024)
try handle.close()
} catch {
try? handle.close()
throw error
}
// SEP storage: 512 KB of zeros (real bytes, matches vm_create.sh).
try Data(count: 512 * 1024).write(to: dir.appendingPathComponent("SEPStorage"))
// ROMs.
try fm.copyItem(at: spec.romSource, to: dir.appendingPathComponent("AVPBooter.vresearch1.bin"))
try fm.copyItem(at: spec.sepromSource, to: dir.appendingPathComponent("AVPSEPBooter.vresearch1.bin"))
// Manifest.
let manifest = VPhoneVirtualMachineManifest(
cpuCount: spec.cpuCount,
memorySize: spec.memoryMB * 1024 * 1024,
romImages: .init(avpBooter: "AVPBooter.vresearch1.bin",
avpSEPBooter: "AVPSEPBooter.vresearch1.bin"))
try manifest.write(to: dir.appendingPathComponent("config.plist"))
return VPhoneBundle(url: dir, manifest: manifest)
} catch {
try? fm.removeItem(at: dir)
throw error
}
}
// MARK: - Config editing
public static func updateConfig(
bundleNamed name: String, in library: VPhoneLibrary,
cpuCount: UInt?, memoryMB: UInt64?
) throws -> VPhoneBundle {
let bundle = try library.bundle(named: name)
let updated = bundle.manifest.updating(
cpuCount: cpuCount,
memorySize: memoryMB.map { $0 * 1024 * 1024 },
screenConfig: nil)
try updated.write(to: bundle.configURL)
return VPhoneBundle(url: bundle.url, manifest: updated)
}
// MARK: - Rename / delete
public static func rename(
bundleNamed name: String, to newName: String, in library: VPhoneLibrary
) throws -> VPhoneBundle {
try requireValidName(newName)
let src = try library.bundle(named: name).url
let dst = library.url(forName: newName)
if FileManager.default.fileExists(atPath: dst.path) {
throw VPhoneLibraryError.alreadyExists(name: newName)
}
try FileManager.default.moveItem(at: src, to: dst)
return try VPhoneBundle.load(at: dst)
}
public static func delete(bundleNamed name: String, in library: VPhoneLibrary) throws {
let url = try library.bundle(named: name).url
try FileManager.default.removeItem(at: url)
}
// MARK: - Clone
/// Clone a bundle with a fast APFS copy-on-write clone (fallback: recursive
/// copy), then reset the boot-identity artifacts so the clone comes up as a
/// fresh device on next boot. NOTE: SEPStorage is copied as-is cloning an
/// already-restored VM may need a re-restore for a fully clean identity.
public static func clone(
bundleNamed name: String, to newName: String, in library: VPhoneLibrary
) throws -> VPhoneBundle {
try requireValidName(newName)
let src = try library.bundle(named: name).url
let dst = library.url(forName: newName)
let fm = FileManager.default
if fm.fileExists(atPath: dst.path) { throw VPhoneLibraryError.alreadyExists(name: newName) }
// APFS CoW clone; fall back to a plain recursive copy off-APFS.
if clonefile(src.path, dst.path, 0) != 0 {
try? fm.removeItem(at: dst) // clear any partial clonefile output first
try fm.copyItem(at: src, to: dst)
}
try resetIdentity(inBundleAt: dst)
return try VPhoneBundle.load(at: dst)
}
private static func resetIdentity(inBundleAt dir: URL) throws {
let fm = FileManager.default
for name in ["nvram.bin", "udid-prediction.txt"] {
let u = dir.appendingPathComponent(name)
if fm.fileExists(atPath: u.path) { try fm.removeItem(at: u) }
}
let entries = try fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)
for u in entries where u.pathExtension == "shsh" { try fm.removeItem(at: u) }
let configURL = dir.appendingPathComponent("config.plist")
let manifest = try VPhoneVirtualMachineManifest.load(from: configURL)
try manifest.updating(machineIdentifier: Data()).write(to: configURL)
}
// MARK: - Export / Import
/// Regenerable staging artifacts that never need to travel in an export:
/// `.vphoned.signed` is re-staged on the next launch, and the CFW install
/// inputs/temp are consumed at install time (the result already lives in
/// `Disk.img`). Always excluded.
static let exportExcludePatterns = ["*.vphoned.signed", "*cfw_input*", "*cfw_jb_input*", "*.cfw_temp*"]
public static func export(
bundleNamed name: String, to outFile: URL, includeIPSW: Bool, in library: VPhoneLibrary
) throws {
_ = try library.bundle(named: name) // validate it exists
// xz at max level, multithreaded (threads=0 all cores) the densest
// compressor libarchive offers, for a multi-GB Disk.img.
var args = ["-cf", outFile.path, "-J", "--options", "xz:compression-level=9,xz:threads=0"]
if !includeIPSW { args += ["--exclude", "*_Restore*"] }
for pattern in exportExcludePatterns { args += ["--exclude", pattern] }
args += ["-C", library.root.path, name]
let r = try VPhoneProcessRunner.runCapturing(URL(fileURLWithPath: "/usr/bin/tar"), args)
guard r.succeeded else { throw VPhoneBundleOpsError.tarFailed(r.stderr) }
}
public static func importArchive(
from inFile: URL, name: String?, in library: VPhoneLibrary
) throws -> VPhoneBundle {
let fm = FileManager.default
// Auto-detect the compression (-tf, not -tzf) so both legacy gzip and
// current xz archives import.
let listing = try VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/usr/bin/tar"), ["-tf", inFile.path])
guard listing.succeeded else { throw VPhoneBundleOpsError.tarFailed(listing.stderr) }
let topDirs = Set(listing.stdout.split(whereSeparator: \.isNewline).compactMap {
$0.split(separator: "/").first.map(String.init)
})
guard topDirs.count == 1, let archived = topDirs.first else {
throw VPhoneBundleOpsError.badArchive(
"expected a single top-level bundle directory, found \(topDirs.sorted())")
}
let finalName = name ?? archived
try requireValidName(finalName)
let dst = library.url(forName: finalName)
if fm.fileExists(atPath: dst.path) { throw VPhoneLibraryError.alreadyExists(name: finalName) }
// Extract into a private staging dir so the archive's OWN top-level name
// can never clobber/merge into an existing bundle of that name; only the
// validated destination name is ever placed into the library.
try fm.createDirectory(at: library.root, withIntermediateDirectories: true)
let staging = library.root.appendingPathComponent(".import-\(UUID().uuidString)")
try fm.createDirectory(at: staging, withIntermediateDirectories: true)
defer { try? fm.removeItem(at: staging) }
let extract = try VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/usr/bin/tar"), ["-xf", inFile.path, "-C", staging.path])
guard extract.succeeded else { throw VPhoneBundleOpsError.tarFailed(extract.stderr) }
let extracted = staging.appendingPathComponent(archived)
guard fm.fileExists(atPath: extracted.appendingPathComponent("config.plist").path) else {
throw VPhoneBundleOpsError.badArchive(
"archive did not contain a valid bundle (\(archived)/config.plist)")
}
try fm.moveItem(at: extracted, to: dst)
return try VPhoneBundle.load(at: dst)
}
}
@@ -0,0 +1,15 @@
import Foundation
public struct VPhoneBundleReport: Codable, Equatable, Sendable {
public let name: String
public let cpuCount: Int
public let memoryMB: Int
public let diskSizeBytes: Int64
public init(bundle: VPhoneBundle) {
self.name = bundle.name
self.cpuCount = Int(bundle.manifest.cpuCount)
self.memoryMB = Int(bundle.manifest.memorySize / (1024 * 1024))
self.diskSizeBytes = bundle.diskSizeBytes
}
}
@@ -0,0 +1,50 @@
import Foundation
// MARK: - VPhoneLaunchLayout
public struct VPhoneLaunchLayout: Sendable {
public let resources: VPhoneResources
public init(resources: VPhoneResources) { self.resources = resources }
public init(projectRoot: URL) { self.init(resources: VPhoneResources(base: projectRoot)) }
public var preflightScript: URL { resources.preflightScript }
public var fwPrepareScript: URL { resources.fwPrepareScript }
public var cfwInstallHostScript: URL { resources.cfwInstallHostScript }
public var pmd3Bridge: URL { resources.pmd3Bridge }
public var vphoned: URL { resources.vphoned }
public func python() throws -> URL { try resources.pythonExecutable() }
/// Copy the built vphoned into the bundle if present and different.
@discardableResult
public func stageVphoned(into bundle: VPhoneBundle) throws -> Bool {
let fm = FileManager.default
guard fm.fileExists(atPath: vphoned.path) else { return false }
let dst = bundle.url.appendingPathComponent(".vphoned.signed")
if fm.fileExists(atPath: dst.path),
let a = try? Data(contentsOf: vphoned),
let b = try? Data(contentsOf: dst), a == b {
return false
}
if fm.fileExists(atPath: dst.path) { try fm.removeItem(at: dst) }
try fm.copyItem(at: vphoned, to: dst)
return true
}
}
// MARK: - VPhoneLsof
public enum VPhoneLsof {
public static func parsePIDs(_ output: String) -> [Int32] {
var seen = Set<Int32>()
var pids: [Int32] = []
for line in output.split(whereSeparator: \.isNewline) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard let pid = Int32(trimmed), !seen.contains(pid) else { continue }
seen.insert(pid)
pids.append(pid)
}
return pids.sorted()
}
}
+82
View File
@@ -0,0 +1,82 @@
import Foundation
public enum VPhoneLibraryError: Error, Equatable {
case notFound(name: String)
case alreadyExists(name: String)
case invalidName(String)
}
extension VPhoneLibraryError: CustomStringConvertible, LocalizedError {
public var description: String {
switch self {
case let .notFound(name): "VM '\(name)' not found"
case let .alreadyExists(name): "VM '\(name)' already exists"
case let .invalidName(name): "Invalid VM name '\(name)' (must be non-empty, contain no '/', and not start with '.')"
}
}
public var errorDescription: String? { description }
}
public struct VPhoneLibrarySkip: Sendable {
public let name: String
public let reason: String
public init(name: String, reason: String) {
self.name = name
self.reason = reason
}
}
// MARK: - Library
public struct VPhoneLibrary: Sendable {
public let root: URL
public init(root: URL) { self.root = root }
public static func defaultRoot() -> URL {
if let override = ProcessInfo.processInfo.environment["VPHONE_LIBRARY_ROOT"] {
return URL(fileURLWithPath: override, isDirectory: true)
}
// `~/.vphone/VMs` deliberately space-free: bundle paths flow into the
// shell/make firmware pipeline, and "Application Support" (a space) breaks
// any unquoted expansion there. Keep the default path shell-safe.
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".vphone/VMs", isDirectory: true)
}
public func url(forName name: String) -> URL {
root.appendingPathComponent(name, isDirectory: true)
}
public func scan() throws -> (bundles: [VPhoneBundle], skipped: [VPhoneLibrarySkip]) {
let fm = FileManager.default
guard fm.fileExists(atPath: root.path) else { return ([], []) }
let entries = try fm.contentsOfDirectory(
at: root, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
var bundles: [VPhoneBundle] = []
var skipped: [VPhoneLibrarySkip] = []
for url in entries
where fm.fileExists(atPath: url.appendingPathComponent("config.plist").path) {
do {
bundles.append(try VPhoneBundle.load(at: url))
} catch {
skipped.append(VPhoneLibrarySkip(name: url.lastPathComponent, reason: "\(error)"))
}
}
return (bundles.sorted { $0.name < $1.name }, skipped.sorted { $0.name < $1.name })
}
public func bundles() throws -> [VPhoneBundle] {
try scan().bundles
}
public func bundle(named name: String) throws -> VPhoneBundle {
let url = url(forName: name)
guard FileManager.default.fileExists(
atPath: url.appendingPathComponent("config.plist").path) else {
throw VPhoneLibraryError.notFound(name: name)
}
return try VPhoneBundle.load(at: url)
}
}
@@ -0,0 +1,160 @@
import Foundation
// MARK: - VPhoneMatchResult
public enum VPhoneMatchResult: Equatable, Sendable {
case matched
case exited(Int32)
case timedOut
}
// MARK: - VPhoneManagedProcess
/// A long-lived child process with stdin driving and stdout/stderr
/// regex-waiting the primitive for DFU background boot, first-boot
/// command injection, and boot log analysis.
public final class VPhoneManagedProcess: @unchecked Sendable {
/// Thread-safe accumulator for the combined stdout+stderr bytes, plus an
/// EOF flag so `waitForOutput` can tell "no more output is coming" apart
/// from "the handler just hasn't run yet". `@unchecked Sendable` because
/// access is serialized by its lock, matching `VPhoneProcessRunner.DataBox`.
///
/// Raw `Data` is accumulated (not decoded per-chunk) because a multi-byte
/// UTF-8 character can straddle two `readabilityHandler` reads decoding
/// each chunk independently would turn the split character into U+FFFD.
/// Decoding the whole buffer at match time is always correct.
private final class OutputBox: @unchecked Sendable {
private let lock = NSLock()
private var data = Data()
private var eof = false
func append(_ chunk: Data) {
lock.lock(); defer { lock.unlock() }
data.append(chunk)
}
func snapshotText() -> String {
lock.lock(); defer { lock.unlock() }
return String(decoding: data, as: UTF8.self)
}
func markEOF() {
lock.lock(); defer { lock.unlock() }
eof = true
}
func hasReachedEOF() -> Bool {
lock.lock(); defer { lock.unlock() }
return eof
}
}
private let process = Process()
private let stdinPipe = Pipe()
private let outPipe = Pipe()
private let box = OutputBox()
private let echo: Bool
public init(
_ executable: URL,
_ args: [String],
cwd: URL? = nil,
env: [String: String]? = nil,
echo: Bool = true
) {
self.echo = echo
process.executableURL = executable
process.arguments = args
if let cwd { process.currentDirectoryURL = cwd }
if let env { process.environment = env }
}
/// Spawn the child with a piped stdin and a combined piped stdout/stderr.
/// The read side is drained by a readability handler that appends decoded
/// chunks to `box` and (when `echo`) forwards them live to the terminal.
public func start() throws {
process.standardInput = stdinPipe
process.standardOutput = outPipe
process.standardError = outPipe
let box = self.box
let echo = self.echo
outPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty {
handle.readabilityHandler = nil
box.markEOF()
return
}
box.append(chunk)
if echo {
FileHandle.standardOutput.write(chunk)
}
}
try process.run()
}
/// Poll (~50 ms) until a captured line matches `regex`, the process exits
/// first, or `timeout` elapses. Matches against the FULL accumulated
/// buffer each poll (not just newly-arrived text) so a line that lands
/// between polls is never missed. On exit, waits briefly for the
/// readability handler to deliver the final EOF chunk and re-checks the
/// buffer once more before reporting `.exited` a real match that
/// arrives in the same instant as process exit still wins.
public func waitForOutput(matching regex: String, timeout: TimeInterval) -> VPhoneMatchResult {
let re = try? NSRegularExpression(pattern: regex)
let deadline = Date().addingTimeInterval(timeout)
while true {
if matches(re, box.snapshotText()) { return .matched }
if !process.isRunning {
while !box.hasReachedEOF() && Date() < deadline {
Thread.sleep(forTimeInterval: 0.02)
}
if matches(re, box.snapshotText()) { return .matched }
return .exited(process.terminationStatus)
}
if Date() >= deadline { return .timedOut }
Thread.sleep(forTimeInterval: 0.05)
}
}
private func matches(_ re: NSRegularExpression?, _ text: String) -> Bool {
guard let re else { return false }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return re.firstMatch(in: text, options: [], range: range) != nil
}
/// Write `line + "\n"` to the child's stdin.
public func send(_ line: String) {
guard let data = (line + "\n").data(using: .utf8) else { return }
try? stdinPipe.fileHandleForWriting.write(contentsOf: data)
}
public func waitUntilExit() -> Int32 {
process.waitUntilExit()
return process.terminationStatus
}
/// `interrupt()` (SIGINT), then escalate to an unmaskable `SIGKILL` if
/// the process is still running after a ~2 s grace period. `SIGKILL` (not
/// `Process.terminate()`'s `SIGTERM`) is required here: a child that
/// traps or ignores `SIGTERM` would otherwise survive and hang
/// `terminate()`/`waitUntilExit()` forever exactly the kind of
/// DFU/boot child this class manages.
public func terminate() {
guard process.isRunning else { return }
process.interrupt()
let deadline = Date().addingTimeInterval(2)
while process.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning {
kill(process.processIdentifier, SIGKILL)
}
}
}
@@ -0,0 +1,111 @@
import Foundation
// MARK: - VPhoneProcessResult
public struct VPhoneProcessResult: Sendable {
public let exitCode: Int32
public let stdout: String
public let stderr: String
public init(exitCode: Int32, stdout: String, stderr: String) {
self.exitCode = exitCode
self.stdout = stdout
self.stderr = stderr
}
public var succeeded: Bool { exitCode == 0 }
}
// MARK: - VPhoneProcessRunner
public enum VPhoneProcessRunner {
/// Thread-safe accumulator for a pipe's bytes. `@unchecked Sendable`
/// because access is serialized by its lock, satisfying the readability
/// handler's `@Sendable` requirement under Swift 6 strict concurrency.
private final class DataBox: @unchecked Sendable {
private let lock = NSLock()
private var data = Data()
func append(_ chunk: Data) { lock.lock(); data.append(chunk); lock.unlock() }
func take() -> Data { lock.lock(); defer { lock.unlock() }; return data }
}
/// Run `executable args` to completion, capturing stdout/stderr.
/// Throws only if the process cannot be launched; a nonzero exit is
/// returned in the result, not thrown.
///
/// stdout and stderr are drained CONCURRENTLY via readability handlers
/// a sequential "read stdout fully, then stderr" drain deadlocks when a
/// child fills one pipe's ~64 KB buffer while still writing the other.
public static func runCapturing(
_ executable: URL,
_ args: [String],
cwd: URL? = nil,
env: [String: String]? = nil
) throws -> VPhoneProcessResult {
let process = Process()
process.executableURL = executable
process.arguments = args
if let cwd { process.currentDirectoryURL = cwd }
if let env { process.environment = env }
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
let outBox = DataBox()
let errBox = DataBox()
let group = DispatchGroup()
group.enter()
group.enter()
outPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty { handle.readabilityHandler = nil; group.leave() }
else { outBox.append(chunk) }
}
errPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty { handle.readabilityHandler = nil; group.leave() }
else { errBox.append(chunk) }
}
try process.run()
process.waitUntilExit()
group.wait()
return VPhoneProcessResult(
exitCode: process.terminationStatus,
stdout: String(decoding: outBox.take(), as: UTF8.self),
stderr: String(decoding: errBox.take(), as: UTF8.self))
}
/// Run `executable args`, inheriting the parent's stdout/stderr so output
/// streams live to the terminal (for long-running tools: downloads, restore,
/// CFW install). Returns the child's exit status; throws only on spawn failure.
/// When `echo` is false, the child's stdout/stderr are redirected to the null
/// device so nothing reaches the terminal; the exit status is still returned.
public static func runStreaming(
_ executable: URL,
_ args: [String],
cwd: URL? = nil,
env: [String: String]? = nil,
echo: Bool = true
) throws -> Int32 {
let process = Process()
process.executableURL = executable
process.arguments = args
if let cwd { process.currentDirectoryURL = cwd }
if let env { process.environment = env }
if !echo {
let devNull = FileHandle.nullDevice
process.standardOutput = devNull
process.standardError = devNull
}
// When echo is true: no pipe redirection child inherits our stdio (live streaming).
try process.run()
process.waitUntilExit()
return process.terminationStatus
}
}
+181
View File
@@ -0,0 +1,181 @@
import Foundation
// MARK: - VPhoneResourcesError
public enum VPhoneResourcesError: Error, Equatable {
case pythonNotFound(String)
case venvBootstrapFailed(String)
}
// MARK: - VPhoneResources
public struct VPhoneResources: Sendable {
public let base: URL
public init(base: URL) { self.base = base }
// MARK: - Resolution
public static func resolve(executablePath: String = CommandLine.arguments[0]) -> VPhoneResources {
let exe = URL(fileURLWithPath: executablePath).resolvingSymlinksInPath()
let macos = exe.deletingLastPathComponent() // /Contents/MacOS
if macos.lastPathComponent == "MacOS",
macos.deletingLastPathComponent().lastPathComponent == "Contents" {
return VPhoneResources(base: macos.deletingLastPathComponent()
.appendingPathComponent("Resources")) // /Contents/Resources
}
var dir = macos
for _ in 0..<6 {
if FileManager.default.fileExists(atPath: dir.appendingPathComponent("scripts").path) {
return VPhoneResources(base: dir)
}
dir = dir.deletingLastPathComponent()
}
return VPhoneResources(base: URL(fileURLWithPath: FileManager.default.currentDirectoryPath))
}
// MARK: - Assets
public var scriptsDir: URL { base.appendingPathComponent("scripts") }
public var patchersDir: URL { scriptsDir.appendingPathComponent("patchers") }
public var resourceArchivesDir: URL { scriptsDir.appendingPathComponent("resources") }
public var fwPrepareScript: URL { scriptsDir.appendingPathComponent("fw_prepare.sh") }
public var cfwInstallHostScript: URL { scriptsDir.appendingPathComponent("cfw_install_host.sh") }
public var preflightScript: URL { scriptsDir.appendingPathComponent("boot_host_preflight.sh") }
public var pmd3Bridge: URL { scriptsDir.appendingPathComponent("pymobiledevice3_bridge.py") }
public var cfwPy: URL { patchersDir.appendingPathComponent("cfw.py") }
public var apfsSnapRename: URL { base.appendingPathComponent("tools/apfs_snap_rename.py") }
public var signcert: URL { scriptsDir.appendingPathComponent("vphoned/signcert.p12") }
public var vphoned: URL {
let bundled = base.appendingPathComponent("vphoned.signed")
if FileManager.default.fileExists(atPath: bundled.path) { return bundled }
// Dev fallback: build.sh stages the signed daemon under .build (a
// gitignored build-output dir) rather than cluttering the repo root.
return base.appendingPathComponent(".build/vphoned.signed")
}
// MARK: - Cache dirs
public var userCacheDir: URL {
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".vphone")
}
public var ipswCacheDir: URL { userCacheDir.appendingPathComponent("ipsws") }
public var sealVolumeCacheDir: URL { userCacheDir.appendingPathComponent("tools") }
public var toolsBinDir: URL { base.appendingPathComponent(".tools/bin") }
// MARK: - Python
/// Runtime pip deps, mirrored from requirements.txt (fallback when the
/// bundled requirements.txt is somehow absent).
static let fallbackRequirements =
["typer", "capstone", "keystone-engine", "pyimg4", "pymobiledevice3>=9.5.0", "ipsw-parser"]
/// Bundled/dev requirements list the managed venv is provisioned from.
public var requirementsFile: URL { base.appendingPathComponent("requirements.txt") }
/// Per-user managed venv, created on demand. Deliberately OUTSIDE both the
/// repo and the .app so the app is portable a venv is never moved between
/// machines (its links would break); it is built fresh on each host.
public var managedVenvDir: URL {
if let dir = ProcessInfo.processInfo.environment["VPHONE_VENV_DIR"], !dir.isEmpty {
return URL(fileURLWithPath: dir)
}
return userCacheDir.appendingPathComponent("venv")
}
private var managedVenvPython: URL { managedVenvDir.appendingPathComponent("bin/python3") }
/// A python is usable only if it carries an `ipsw_parser` new enough for the
/// bridge the exact gap behind `IPSW has no attribute 'create_from_path'`
/// when an old system-python build gets picked up.
func pythonIsUsable(_ python: URL) -> Bool {
guard FileManager.default.isExecutableFile(atPath: python.path) else { return false }
let probe = "from ipsw_parser.ipsw import IPSW; import sys; "
+ "sys.exit(0 if hasattr(IPSW, 'create_from_path') else 1)"
return (try? VPhoneProcessRunner.runCapturing(python, ["-c", probe]))?.succeeded == true
}
/// Resolve a python with working deps: an explicit `VPHONE_PYTHON`, the dev
/// repo `.venv`, the managed per-user venv, else provision the managed venv
/// on this machine. Never silently falls back to a stale system python.
public func pythonExecutable() throws -> URL {
if let override = ProcessInfo.processInfo.environment["VPHONE_PYTHON"], !override.isEmpty {
let u = URL(fileURLWithPath: override)
if pythonIsUsable(u) { return u }
}
let devVenv = base.appendingPathComponent(".venv/bin/python3")
if pythonIsUsable(devVenv) { return devVenv }
if pythonIsUsable(managedVenvPython) { return managedVenvPython }
return try bootstrapManagedVenv()
}
/// Provision `~/.vphone/venv`: try each candidate host python for real
/// (build the venv, install deps, verify) and use the first that fully
/// succeeds a candidate that imports `venv` can still fail `-m venv`
/// (e.g. a broken `ensurepip`), so we fall through instead of trusting it.
/// One-time per machine.
private func bootstrapManagedVenv() throws -> URL {
func log(_ s: String) { FileHandle.standardError.write(Data((s + "\n").utf8)) }
let candidates = candidateHostPythons()
guard !candidates.isEmpty else {
throw VPhoneResourcesError.venvBootstrapFailed(
"no host python3 found — install one (e.g. `brew install [email protected]`) or set VPHONE_PYTHON")
}
log("[*] First run: provisioning the vphone Python environment at \(managedVenvDir.path) (one-time)…")
let py = managedVenvPython
let install: [String] = FileManager.default.fileExists(atPath: requirementsFile.path)
? ["-m", "pip", "install", "-r", requirementsFile.path]
: ["-m", "pip", "install"] + Self.fallbackRequirements
var lastError = "no candidate python could build a usable venv"
for host in candidates {
log(" → trying \(host.path)")
try? FileManager.default.removeItem(at: managedVenvDir)
try FileManager.default.createDirectory(at: userCacheDir, withIntermediateDirectories: true)
guard (try? VPhoneProcessRunner.runStreaming(host, ["-m", "venv", managedVenvDir.path])) == 0 else {
lastError = "python -m venv failed with \(host.path)"; continue
}
_ = try? VPhoneProcessRunner.runStreaming(py, ["-m", "pip", "install", "--upgrade", "-q", "pip"])
guard (try? VPhoneProcessRunner.runStreaming(py, install)) == 0 else {
lastError = "pip install failed with \(host.path)"; continue
}
guard pythonIsUsable(py) else {
lastError = "venv from \(host.path) still lacks a usable ipsw_parser (too old?)"; continue
}
log("[+] Python environment ready: \(py.path)")
return py
}
try? FileManager.default.removeItem(at: managedVenvDir)
throw VPhoneResourcesError.venvBootstrapFailed(
lastError + " — install a modern python3 (e.g. `brew install [email protected]`) or set VPHONE_PYTHON")
}
/// Ordered, existence-checked host python3 candidates to bootstrap from.
/// Canonical Homebrew locations first, then versioned names on PATH, then
/// generic `python3`, then system `/usr/bin/python3` (3.9) as a last resort
/// (it resolves an old, broken pymobiledevice3 stack).
private func candidateHostPythons() -> [URL] {
var paths: [String] = []
if let override = ProcessInfo.processInfo.environment["VPHONE_PYTHON"], !override.isEmpty {
paths.append(override)
}
paths += ["/opt/homebrew/bin/python3", "/usr/local/bin/python3"]
for name in ["python3.14", "python3.13", "python3.12", "python3.11", "python3.10"] {
if let p = which(name) { paths.append(p) }
}
if let p = which("python3") { paths.append(p) }
paths.append("/usr/bin/python3")
var seen = Set<String>()
return paths.filter { !$0.isEmpty && seen.insert($0).inserted }
.filter { FileManager.default.isExecutableFile(atPath: $0) }
.map { URL(fileURLWithPath: $0) }
}
private func which(_ name: String) -> String? {
let r = try? VPhoneProcessRunner.runCapturing(URL(fileURLWithPath: "/usr/bin/env"), ["which", name])
guard let r, r.succeeded else { return nil }
let p = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
return p.isEmpty ? nil : p
}
}
+57
View File
@@ -0,0 +1,57 @@
import Foundation
public enum VPhoneRestoreError: Error, Equatable {
case ecidUnresolved
case noSHSH
case noRestoreDir
case aeaDecryptFailed(String)
case aeaStillEncrypted(String)
}
public enum VPhoneRestoreOps {
// MARK: - ECID
/// ECID from `--ecid`, else the `ECID=` line of the bundle's udid-prediction.txt.
public static func resolveECID(explicit: String?, bundle: VPhoneBundle) -> String? {
if let explicit, !explicit.isEmpty { return explicit }
let pred = bundle.url.appendingPathComponent("udid-prediction.txt")
guard let text = try? String(contentsOf: pred, encoding: .utf8) else { return nil }
for line in text.split(whereSeparator: \.isNewline) where line.hasPrefix("ECID=") {
let value = line.dropFirst("ECID=".count).trimmingCharacters(in: .whitespaces)
return value.isEmpty ? nil : value
}
return nil
}
// MARK: - AEA
/// True if the file begins with the AEA1 magic (`41 45 41 31`).
public static func isAEAEncrypted(_ url: URL) throws -> Bool {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
let head = handle.readData(ofLength: 4)
return head == Data([0x41, 0x45, 0x41, 0x31])
}
/// Decrypt every AEA1-encrypted `*.dmg.aea` in `dir` in place (via `ipsw fw aea`),
/// keeping the `.aea` filename with decrypted content (matches make restore_offline).
public static func decryptAEAImages(inRestoreDir dir: URL) throws {
let fm = FileManager.default
let entries = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
for aea in entries where aea.lastPathComponent.hasSuffix(".dmg.aea") {
guard try isAEAEncrypted(aea) else { continue }
let code = try VPhoneProcessRunner.runStreaming(
URL(fileURLWithPath: "/usr/bin/env"), ["ipsw", "fw", "aea", "-o", dir.path, aea.path])
guard code == 0 else { throw VPhoneRestoreError.aeaDecryptFailed(aea.lastPathComponent) }
// ipsw wrote <dir>/<name minus .aea>; move it onto the .aea filename.
// Confirm the decrypted output exists BEFORE removing the original (mv -f semantics).
let decrypted = dir.appendingPathComponent(aea.deletingPathExtension().lastPathComponent)
guard fm.fileExists(atPath: decrypted.path) else {
throw VPhoneRestoreError.aeaDecryptFailed(aea.lastPathComponent)
}
if fm.fileExists(atPath: aea.path) { try fm.removeItem(at: aea) }
try fm.moveItem(at: decrypted, to: aea)
if try isAEAEncrypted(aea) { throw VPhoneRestoreError.aeaStillEncrypted(aea.lastPathComponent) }
}
}
}
+55
View File
@@ -0,0 +1,55 @@
import Foundation
public enum VPhoneVMPickerError: Error, CustomStringConvertible, Equatable {
case emptyLibrary(root: String)
case notInteractive
case aborted
case invalidSelection
public var description: String {
switch self {
case .emptyLibrary(let root):
"no VMs found in \(root) — create one with 'vphone-cli vm create <name>'"
case .notInteractive:
"no VM name given and stdin is not a terminal — pass the name explicitly"
case .aborted:
"selection aborted"
case .invalidSelection:
"no valid selection made"
}
}
}
public enum VPhoneVMPicker {
/// Resolve an existing-VM name. Returns `provided` unchanged when given;
/// otherwise shows a numbered menu of `names` and reads a 1-based index or an
/// exact name. All output goes through `write` (caller routes to stderr); input
/// via `read` (nil = EOF). I/O is injected so the logic is unit-testable.
public static func resolve(
provided: String?,
names: [String],
libraryRoot: String,
isInteractive: Bool,
maxRetries: Int = 5,
read: () -> String?,
write: (String) -> Void
) throws -> String {
if let provided, !provided.isEmpty { return provided }
guard isInteractive else { throw VPhoneVMPickerError.notInteractive }
guard !names.isEmpty else { throw VPhoneVMPickerError.emptyLibrary(root: libraryRoot) }
write("Select a VM:")
for (i, name) in names.enumerated() { write(" [\(i + 1)] \(name)") }
for _ in 0..<maxRetries {
write("Enter number or name: ")
guard let line = read() else { throw VPhoneVMPickerError.aborted }
let choice = line.trimmingCharacters(in: .whitespacesAndNewlines)
if choice.isEmpty { continue }
if let idx = Int(choice), idx >= 1, idx <= names.count { return names[idx - 1] }
if let match = names.first(where: { $0 == choice }) { return match }
write(" '\(choice)' is not a valid selection.")
}
throw VPhoneVMPickerError.invalidSelection
}
}
+23
View File
@@ -0,0 +1,23 @@
/// CLI output verbosity. Higher levels are supersets of lower ones.
///
/// The guest VM serial console is deliberately NOT part of this ladder:
/// `vm launch` always streams it, `vm create` never does neither depends
/// on the verbosity level.
public enum VPhoneVerbosity: Int, Comparable, Sendable {
case quiet = 0 // tool banners + [*]/[+] markers only
case info = 1 // + wrapped-subprocess stdout + pmd3 restore log (INFO, colorful)
case debug = 2 // + pmd3 DEBUG logs (deeper restore detail)
case trace = 3 // + vphone-cli internal trace (spawned argv/env, managed-process events)
public static func < (lhs: VPhoneVerbosity, rhs: VPhoneVerbosity) -> Bool {
lhs.rawValue < rhs.rawValue
}
/// Map a repeated `-v` count (0N) to a level, clamped to `.trace`.
public init(count: Int) {
self = VPhoneVerbosity(rawValue: min(max(count, 0), 3)) ?? .trace
}
public var showsToolDetail: Bool { self >= .info }
public var tracesInternals: Bool { self >= .trace }
}
@@ -0,0 +1,239 @@
import Foundation
import Virtualization
// MARK: - Errors
public enum VPhoneManifestError: Error {
case loadFailed(path: String, underlying: Error)
case parseFailed(path: String, underlying: Error)
case writeFailed(path: String, underlying: Error)
}
extension VPhoneManifestError: CustomStringConvertible, LocalizedError {
public var description: String {
switch self {
case let .loadFailed(path, underlying): "Failed to load manifest from \(path): \(underlying)"
case let .parseFailed(path, underlying): "Failed to parse manifest at \(path): \(underlying)"
case let .writeFailed(path, underlying): "Failed to write manifest to \(path): \(underlying)"
}
}
public var errorDescription: String? { description }
}
/// VPhoneVirtualMachineManifest represents the on-disk VM configuration manifest.
/// Structure is compatible with security-pcc's VMBundle.Config format.
public struct VPhoneVirtualMachineManifest: Codable, Sendable {
// MARK: - Platform
/// Platform type (fixed to vresearch101 for vphone)
public let platformType: PlatformType
/// Platform fusing mode (prod/dev) - determined by host OS capabilities
public let platformFusing: PlatformFusing?
/// Machine identifier (opaque ECID representation)
public let machineIdentifier: Data
// MARK: - Hardware
/// CPU core count
public let cpuCount: UInt
/// Memory size in bytes
public let memorySize: UInt64
// MARK: - Display
/// Screen configuration
public let screenConfig: ScreenConfig
// MARK: - Network
/// Network configuration (NAT mode for vphone)
public let networkConfig: NetworkConfig
// MARK: - Storage
/// Disk image filename
public let diskImage: String
/// NVRAM storage filename
public let nvramStorage: String
// MARK: - ROMs
/// ROM image paths
public let romImages: ROMImages?
// MARK: - SEP
/// SEP storage filename
public let sepStorage: String
// MARK: - Nested Types
public enum PlatformType: String, Codable, Sendable {
case vresearch101
}
public enum PlatformFusing: String, Codable, Sendable {
case prod
case dev
}
public struct ScreenConfig: Codable, Sendable {
public let width: Int
public let height: Int
public let pixelsPerInch: Int
public let scale: Double
public static let `default` = ScreenConfig(
width: 1290,
height: 2796,
pixelsPerInch: 460,
scale: 3.0
)
public init(width: Int, height: Int, pixelsPerInch: Int, scale: Double) {
self.width = width
self.height = height
self.pixelsPerInch = pixelsPerInch
self.scale = scale
}
}
public struct NetworkConfig: Codable, Sendable {
public let mode: NetworkMode
public let macAddress: String
public enum NetworkMode: String, Codable, Sendable {
case nat
case bridged
case hostOnly
case none
}
public static let `default` = NetworkConfig(mode: .nat, macAddress: "")
public init(mode: NetworkMode, macAddress: String) {
self.mode = mode
self.macAddress = macAddress
}
}
public struct ROMImages: Codable, Sendable {
public let avpBooter: String
public let avpSEPBooter: String
public init(avpBooter: String, avpSEPBooter: String) {
self.avpBooter = avpBooter
self.avpSEPBooter = avpSEPBooter
}
}
// MARK: - Init from VM creation parameters
public init(
platformType: PlatformType = .vresearch101,
platformFusing: PlatformFusing? = nil,
machineIdentifier: Data = Data(),
cpuCount: UInt,
memorySize: UInt64,
screenConfig: ScreenConfig = .default,
networkConfig: NetworkConfig = .default,
diskImage: String = "Disk.img",
nvramStorage: String = "nvram.bin",
romImages: ROMImages?,
sepStorage: String = "SEPStorage"
) {
self.platformType = platformType
self.platformFusing = platformFusing
self.machineIdentifier = machineIdentifier
self.cpuCount = cpuCount
self.memorySize = memorySize
self.screenConfig = screenConfig
self.networkConfig = networkConfig
self.diskImage = diskImage
self.nvramStorage = nvramStorage
self.romImages = romImages
self.sepStorage = sepStorage
}
// MARK: - Load/Save
/// Load manifest from a plist file
public static func load(from url: URL) throws -> VPhoneVirtualMachineManifest {
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
throw VPhoneManifestError.loadFailed(path: url.path, underlying: error)
}
let decoder = PropertyListDecoder()
do {
return try decoder.decode(VPhoneVirtualMachineManifest.self, from: data)
} catch {
throw VPhoneManifestError.parseFailed(path: url.path, underlying: error)
}
}
/// Save manifest to a plist file
public func write(to url: URL) throws {
let encoder = PropertyListEncoder()
encoder.outputFormat = .xml
do {
let data = try encoder.encode(self)
try data.write(to: url)
} catch {
throw VPhoneManifestError.writeFailed(path: url.path, underlying: error)
}
}
// MARK: - Convenience
/// Convert to JSON string for logging/debugging
public func asJSON() -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes
do {
return try String(decoding: encoder.encode(self), as: UTF8.self)
} catch {
return "{ }"
}
}
/// Resolve relative path to absolute URL within VM directory
public func resolve(path: String, in vmDirectory: URL) -> URL {
vmDirectory.appendingPathComponent(path)
}
/// Get VZMacMachineIdentifier from manifest data
public func vzMachineIdentifier() -> VZMacMachineIdentifier? {
VZMacMachineIdentifier(dataRepresentation: machineIdentifier)
}
// MARK: - Editing
public func updating(
cpuCount: UInt? = nil,
memorySize: UInt64? = nil,
screenConfig: ScreenConfig? = nil,
machineIdentifier: Data? = nil
) -> VPhoneVirtualMachineManifest {
VPhoneVirtualMachineManifest(
platformType: platformType,
platformFusing: platformFusing,
machineIdentifier: machineIdentifier ?? self.machineIdentifier,
cpuCount: cpuCount ?? self.cpuCount,
memorySize: memorySize ?? self.memorySize,
screenConfig: screenConfig ?? self.screenConfig,
networkConfig: networkConfig,
diskImage: diskImage,
nvramStorage: nvramStorage,
romImages: romImages,
sepStorage: sepStorage
)
}
}
@@ -1,180 +0,0 @@
import Foundation
import Virtualization
/// VPhoneVirtualMachineManifest represents the on-disk VM configuration manifest.
/// Structure is compatible with security-pcc's VMBundle.Config format.
struct VPhoneVirtualMachineManifest: Codable {
// MARK: - Platform
/// Platform type (fixed to vresearch101 for vphone)
let platformType: PlatformType
/// Platform fusing mode (prod/dev) - determined by host OS capabilities
let platformFusing: PlatformFusing?
/// Machine identifier (opaque ECID representation)
let machineIdentifier: Data
// MARK: - Hardware
/// CPU core count
let cpuCount: UInt
/// Memory size in bytes
let memorySize: UInt64
// MARK: - Display
/// Screen configuration
let screenConfig: ScreenConfig
// MARK: - Network
/// Network configuration (NAT mode for vphone)
let networkConfig: NetworkConfig
// MARK: - Storage
/// Disk image filename
let diskImage: String
/// NVRAM storage filename
let nvramStorage: String
// MARK: - ROMs
/// ROM image paths
let romImages: ROMImages?
// MARK: - SEP
/// SEP storage filename
let sepStorage: String
// MARK: - Nested Types
enum PlatformType: String, Codable {
case vresearch101
}
enum PlatformFusing: String, Codable {
case prod
case dev
}
struct ScreenConfig: Codable {
let width: Int
let height: Int
let pixelsPerInch: Int
let scale: Double
static let `default` = ScreenConfig(
width: 1290,
height: 2796,
pixelsPerInch: 460,
scale: 3.0
)
}
struct NetworkConfig: Codable {
let mode: NetworkMode
let macAddress: String
enum NetworkMode: String, Codable {
case nat
case bridged
case hostOnly
case none
}
static let `default` = NetworkConfig(mode: .nat, macAddress: "")
}
struct ROMImages: Codable {
let avpBooter: String
let avpSEPBooter: String
}
// MARK: - Init from VM creation parameters
init(
platformType: PlatformType = .vresearch101,
platformFusing: PlatformFusing? = nil,
machineIdentifier: Data = Data(),
cpuCount: UInt,
memorySize: UInt64,
screenConfig: ScreenConfig = .default,
networkConfig: NetworkConfig = .default,
diskImage: String = "Disk.img",
nvramStorage: String = "nvram.bin",
romImages: ROMImages?,
sepStorage: String = "SEPStorage"
) {
self.platformType = platformType
self.platformFusing = platformFusing
self.machineIdentifier = machineIdentifier
self.cpuCount = cpuCount
self.memorySize = memorySize
self.screenConfig = screenConfig
self.networkConfig = networkConfig
self.diskImage = diskImage
self.nvramStorage = nvramStorage
self.romImages = romImages
self.sepStorage = sepStorage
}
// MARK: - Load/Save
/// Load manifest from a plist file
static func load(from url: URL) throws -> VPhoneVirtualMachineManifest {
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
throw VPhoneError.manifestLoadFailed(path: url.path, underlying: error)
}
let decoder = PropertyListDecoder()
do {
return try decoder.decode(VPhoneVirtualMachineManifest.self, from: data)
} catch {
throw VPhoneError.manifestParseFailed(path: url.path, underlying: error)
}
}
/// Save manifest to a plist file
func write(to url: URL) throws {
let encoder = PropertyListEncoder()
encoder.outputFormat = .xml
do {
let data = try encoder.encode(self)
try data.write(to: url)
} catch {
throw VPhoneError.manifestWriteFailed(path: url.path, underlying: error)
}
}
// MARK: - Convenience
/// Convert to JSON string for logging/debugging
func asJSON() -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes
do {
return try String(decoding: encoder.encode(self), as: UTF8.self)
} catch {
return "{ }"
}
}
/// Resolve relative path to absolute URL within VM directory
func resolve(path: String, in vmDirectory: URL) -> URL {
vmDirectory.appendingPathComponent(path)
}
/// Get VZMacMachineIdentifier from manifest data
func vzMachineIdentifier() -> VZMacMachineIdentifier? {
VZMacMachineIdentifier(dataRepresentation: machineIdentifier)
}
}
+325
View File
@@ -0,0 +1,325 @@
@testable import VPhoneCore
import Foundation
import Testing
struct BundleOpsTests {
private func makeRoot() throws -> URL {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func fakeROM() throws -> URL {
let f = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".bin")
try Data([0xAA, 0xBB, 0xCC]).write(to: f)
return f
}
@Test func createsBundleWithSparseDiskAndManifest() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let spec = VPhoneBundleOps.NewBundleSpec(
name: "newvm", cpuCount: 8, memoryMB: 8192, diskSizeGB: 64,
romSource: rom, sepromSource: seprom)
let bundle = try VPhoneBundleOps.create(spec, in: VPhoneLibrary(root: root))
#expect(bundle.manifest.cpuCount == 8)
#expect(bundle.manifest.memorySize == 8192 * 1024 * 1024)
let disk = bundle.url.appendingPathComponent("Disk.img")
let size = (try FileManager.default.attributesOfItem(atPath: disk.path)[.size] as? NSNumber)?.int64Value
#expect(size == Int64(64 * 1024 * 1024 * 1024))
#expect(FileManager.default.fileExists(atPath: bundle.url.appendingPathComponent("SEPStorage").path))
#expect(FileManager.default.fileExists(atPath: bundle.url.appendingPathComponent("AVPBooter.vresearch1.bin").path))
}
@Test func rejectsDuplicateName() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let spec = VPhoneBundleOps.NewBundleSpec(
name: "dup", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom)
_ = try VPhoneBundleOps.create(spec, in: VPhoneLibrary(root: root))
#expect(throws: VPhoneLibraryError.self) {
_ = try VPhoneBundleOps.create(spec, in: VPhoneLibrary(root: root))
}
}
@Test func rejectsInvalidNames() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
for bad in ["", "a/b", ".hidden"] {
#expect(throws: VPhoneLibraryError.self) {
_ = try VPhoneBundleOps.create(
.init(name: bad, cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
}
}
}
@Test func rollsBackPartialBundleOnFailure() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let lib = VPhoneLibrary(root: root)
// A non-existent ROM source makes copyItem fail AFTER the dir is created.
let missingRom = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".bin")
#expect(throws: (any Error).self) {
_ = try VPhoneBundleOps.create(
.init(name: "partial", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
romSource: missingRom, sepromSource: missingRom), in: lib)
}
// The half-built directory must be removed so the name is reusable.
#expect(!FileManager.default.fileExists(atPath: lib.url(forName: "partial").path))
}
@Test func updateConfigPersistsFields() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
_ = try VPhoneBundleOps.create(
.init(name: "cfg", cpuCount: 8, memoryMB: 8192, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
let updated = try VPhoneBundleOps.updateConfig(
bundleNamed: "cfg", in: lib, cpuCount: 4, memoryMB: nil)
#expect(updated.manifest.cpuCount == 4)
#expect(updated.manifest.memorySize == 8192 * 1024 * 1024)
// Persisted: a fresh load sees the change.
#expect(try lib.bundle(named: "cfg").manifest.cpuCount == 4)
}
@Test func renameThenDelete() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
_ = try VPhoneBundleOps.create(
.init(name: "old", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
let renamed = try VPhoneBundleOps.rename(bundleNamed: "old", to: "shiny", in: lib)
#expect(renamed.name == "shiny")
#expect(throws: VPhoneLibraryError.self) { _ = try lib.bundle(named: "old") }
try VPhoneBundleOps.delete(bundleNamed: "shiny", in: lib)
#expect(try lib.bundles().isEmpty)
}
@Test func renameRejectsExistingTarget() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
for n in ["a", "b"] {
_ = try VPhoneBundleOps.create(
.init(name: n, cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
}
#expect(throws: VPhoneLibraryError.alreadyExists(name: "b")) {
_ = try VPhoneBundleOps.rename(bundleNamed: "a", to: "b", in: lib)
}
}
@Test func cloneCopiesBundleAndResetsIdentity() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
let src = try VPhoneBundleOps.create(
.init(name: "src", cpuCount: 8, memoryMB: 4096, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
// Simulate a booted/restored VM: identity artifacts present + non-empty machineIdentifier.
let fm = FileManager.default
try Data([1, 2, 3]).write(to: src.url.appendingPathComponent("nvram.bin"))
try Data([4]).write(to: src.url.appendingPathComponent("udid-prediction.txt"))
try Data([5]).write(to: src.url.appendingPathComponent("ABC123.shsh"))
let withID = src.manifest.updating(machineIdentifier: Data([9, 9]))
try withID.write(to: src.configURL)
let clone = try VPhoneBundleOps.clone(bundleNamed: "src", to: "dst", in: lib)
// Copy happened (disk + ROMs present in the clone).
#expect(fm.fileExists(atPath: clone.url.appendingPathComponent("Disk.img").path))
#expect(fm.fileExists(atPath: clone.url.appendingPathComponent("AVPBooter.vresearch1.bin").path))
// Identity artifacts cleared in the clone.
#expect(!fm.fileExists(atPath: clone.url.appendingPathComponent("nvram.bin").path))
#expect(!fm.fileExists(atPath: clone.url.appendingPathComponent("udid-prediction.txt").path))
#expect(!fm.fileExists(atPath: clone.url.appendingPathComponent("ABC123.shsh").path))
#expect(clone.manifest.machineIdentifier.isEmpty)
// Original untouched.
#expect(fm.fileExists(atPath: src.url.appendingPathComponent("nvram.bin").path))
#expect(try lib.bundle(named: "src").manifest.machineIdentifier == Data([9, 9]))
}
@Test func cloneRejectsExistingTarget() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
for n in ["a", "b"] {
_ = try VPhoneBundleOps.create(
.init(name: n, cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
}
#expect(throws: VPhoneLibraryError.alreadyExists(name: "b")) {
_ = try VPhoneBundleOps.clone(bundleNamed: "a", to: "b", in: lib)
}
}
@Test func exportThenImportRoundTrips() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
_ = try VPhoneBundleOps.create(
.init(name: "orig", cpuCount: 8, memoryMB: 4096, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
let archive = root.appendingPathComponent("orig.tgz")
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib)
#expect(FileManager.default.fileExists(atPath: archive.path))
// Import into a fresh library, renaming.
let root2 = try makeRoot()
defer { try? FileManager.default.removeItem(at: root2) }
let lib2 = VPhoneLibrary(root: root2)
let imported = try VPhoneBundleOps.importArchive(from: archive, name: "copy", in: lib2)
#expect(imported.name == "copy")
#expect(imported.manifest.cpuCount == 8)
#expect(imported.manifest.memorySize == 4096 * 1024 * 1024)
#expect(try lib2.bundle(named: "copy").manifest.cpuCount == 8)
}
@Test func importRejectsExistingName() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
_ = try VPhoneBundleOps.create(
.init(name: "orig", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
let archive = root.appendingPathComponent("orig.tgz")
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib)
// Importing back under the same existing name must fail.
#expect(throws: VPhoneLibraryError.alreadyExists(name: "orig")) {
_ = try VPhoneBundleOps.importArchive(from: archive, name: nil, in: lib)
}
}
@Test func importWithRenameDoesNotClobberArchivedNameCollision() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
// Existing "orig" (cpu 16) that must NOT be touched by the import.
_ = try VPhoneBundleOps.create(
.init(name: "orig", cpuCount: 16, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
// Archive of a DIFFERENT "orig" (cpu 4) from a separate library.
let root2 = try makeRoot()
defer { try? FileManager.default.removeItem(at: root2) }
let lib2 = VPhoneLibrary(root: root2)
_ = try VPhoneBundleOps.create(
.init(name: "orig", cpuCount: 4, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib2)
let archive = root2.appendingPathComponent("orig.tgz")
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib2)
let imported = try VPhoneBundleOps.importArchive(from: archive, name: "renamed", in: lib)
#expect(imported.name == "renamed")
#expect(imported.manifest.cpuCount == 4)
#expect(try lib.bundle(named: "orig").manifest.cpuCount == 16) // untouched
}
@Test func exportExcludesRestoreDirByDefault() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
let b = try VPhoneBundleOps.create(
.init(name: "orig", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
let restoreDir = b.url.appendingPathComponent("iPhone_Restore")
try FileManager.default.createDirectory(at: restoreDir, withIntermediateDirectories: true)
try Data([0]).write(to: restoreDir.appendingPathComponent("marker"))
let archive = root.appendingPathComponent("orig.tgz")
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib)
let listing = try VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/usr/bin/tar"), ["-tf", archive.path])
#expect(listing.succeeded)
#expect(!listing.stdout.contains("iPhone_Restore"))
}
@Test func exportExcludesRegenerableStagingFiles() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
let b = try VPhoneBundleOps.create(
.init(name: "orig", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
let fm = FileManager.default
try Data([0]).write(to: b.url.appendingPathComponent(".vphoned.signed"))
try fm.createDirectory(at: b.url.appendingPathComponent("cfw_input/jb"), withIntermediateDirectories: true)
try Data([0]).write(to: b.url.appendingPathComponent("cfw_input/jb/f"))
try fm.createDirectory(at: b.url.appendingPathComponent("cfw_jb_input"), withIntermediateDirectories: true)
try Data([0]).write(to: b.url.appendingPathComponent("cfw_jb_input/f"))
try fm.createDirectory(at: b.url.appendingPathComponent(".cfw_temp"), withIntermediateDirectories: true)
try Data([0]).write(to: b.url.appendingPathComponent(".cfw_temp/f"))
let archive = root.appendingPathComponent("orig.tgz")
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib)
let listing = try VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/usr/bin/tar"), ["-tf", archive.path])
#expect(listing.succeeded)
#expect(!listing.stdout.contains(".vphoned.signed"))
#expect(!listing.stdout.contains("cfw_input"))
#expect(!listing.stdout.contains("cfw_jb_input"))
#expect(!listing.stdout.contains(".cfw_temp"))
// The real payload still travels.
#expect(listing.stdout.contains("Disk.img"))
#expect(listing.stdout.contains("config.plist"))
}
@Test func importRejectsMultiTopLevelArchive() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
// An archive with TWO top-level dirs is not a single bundle badArchive.
let src = root.appendingPathComponent("src")
try FileManager.default.createDirectory(
at: src.appendingPathComponent("a"), withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: src.appendingPathComponent("b"), withIntermediateDirectories: true)
try Data([0]).write(to: src.appendingPathComponent("a/x"))
try Data([0]).write(to: src.appendingPathComponent("b/y"))
let archive = root.appendingPathComponent("multi.tgz")
let made = try VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/usr/bin/tar"), ["-czf", archive.path, "-C", src.path, "a", "b"])
#expect(made.succeeded)
#expect(throws: VPhoneBundleOpsError.self) {
_ = try VPhoneBundleOps.importArchive(from: archive, name: nil, in: VPhoneLibrary(root: root))
}
}
}
@@ -0,0 +1,28 @@
@testable import VPhoneCore
import Foundation
import Testing
struct BundleReportTests {
@Test func mapsManifestFields() throws {
let manifest = VPhoneVirtualMachineManifest(
cpuCount: 6, memorySize: 4 * 1024 * 1024 * 1024,
romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
let bundle = VPhoneBundle(url: URL(fileURLWithPath: "/tmp/myvm"), manifest: manifest)
let report = VPhoneBundleReport(bundle: bundle)
#expect(report.name == "myvm")
#expect(report.cpuCount == 6)
#expect(report.memoryMB == 4096)
}
@Test func encodesToJSON() throws {
let manifest = VPhoneVirtualMachineManifest(
cpuCount: 2, memorySize: 2 * 1024 * 1024 * 1024,
romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
let report = VPhoneBundleReport(
bundle: VPhoneBundle(url: URL(fileURLWithPath: "/tmp/x"), manifest: manifest))
let data = try JSONEncoder().encode(report)
let back = try JSONDecoder().decode(VPhoneBundleReport.self, from: data)
#expect(back == report)
}
}
@@ -0,0 +1,108 @@
@testable import VPhoneCore
import Foundation
import Testing
// Tests for `VPhoneBootPatterns` the pure, device-independent pieces of the
// native `vm create` pipeline (`VPhoneCreateOrchestrator`, executable target).
// The orchestrator's live stages (DFU/restore/CFW/first-boot/boot-analysis)
// need a real device and are NOT exercised here; see the Task 2 report.
struct CreateOrchestratorTests {
// MARK: - promptRegex / panicRegex (verbatim ports of setup_machine.sh's
// BOOT_BASH_PROMPT_REGEX / BOOT_PANIC_REGEX)
private func matches(_ pattern: String, _ line: String) throws -> Bool {
let re = try NSRegularExpression(pattern: pattern)
return re.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) != nil
}
@Test func promptRegexMatchesIosbinpackBashPrompt() throws {
#expect(try matches(VPhoneBootPatterns.promptRegex, "bash-3.2#"))
}
@Test func promptRegexMatchesRamdiskRootPrompt() throws {
#expect(try matches(VPhoneBootPatterns.promptRegex, ":/ root#"))
}
@Test func promptRegexDoesNotMatchOrdinaryLogLine() throws {
#expect(try !matches(VPhoneBootPatterns.promptRegex, "vphoned: connected, awaiting handshake"))
}
@Test func panicRegexMatchesKernelPanicLine() throws {
#expect(try matches(VPhoneBootPatterns.panicRegex, "panic(cpu 0 caller 0xfffffff01234): test panic"))
}
@Test func panicRegexMatchesStackshotSucceeded() throws {
#expect(try matches(VPhoneBootPatterns.panicRegex, "stackshot succeeded"))
}
@Test func panicRegexDoesNotMatchOrdinaryLogLine() throws {
#expect(try !matches(VPhoneBootPatterns.panicRegex, "vphoned: connected, awaiting handshake"))
}
// MARK: - panicOrPromptRegex (mixed case-sensitivity, per
// monitor_boot_log_until's `grep -Ei` panic vs. `grep -E` prompt)
@Test func panicOrPromptRegexMatchesPanicCaseInsensitively() throws {
#expect(try matches(VPhoneBootPatterns.panicOrPromptRegex, "PANIC(cpu 0 caller 0x0): uppercase panic"))
}
@Test func panicOrPromptRegexKeepsPromptCaseSensitive() throws {
#expect(try matches(VPhoneBootPatterns.panicOrPromptRegex, "bash-3.2#"))
#expect(try !matches(VPhoneBootPatterns.panicOrPromptRegex, "BASH-3.2#"))
}
// MARK: - normalizeECID (port of setup_machine.sh's normalize_ecid)
@Test func normalizeECIDStripsPrefixAndPads() {
#expect(VPhoneBootPatterns.normalizeECID("0xabc") == "0000000000000ABC")
}
@Test func normalizeECIDPassesThroughSixteenHexDigits() {
#expect(VPhoneBootPatterns.normalizeECID("0011223344556677") == "0011223344556677")
}
@Test func normalizeECIDUppercasesLowerHex() {
#expect(VPhoneBootPatterns.normalizeECID("0xdeadbeef") == "00000000DEADBEEF")
}
@Test func normalizeECIDRejectsNonHex() {
#expect(VPhoneBootPatterns.normalizeECID("zzzz") == nil)
}
@Test func normalizeECIDRejectsOverlongInput() {
#expect(VPhoneBootPatterns.normalizeECID("00112233445566778") == nil) // 17 hex chars
}
@Test func normalizeECIDRejectsEmptyInput() {
#expect(VPhoneBootPatterns.normalizeECID("") == nil)
#expect(VPhoneBootPatterns.normalizeECID("0x") == nil)
}
// MARK: - parseHVVmmPresent (nested-VM host preflight)
@Test func parseHVVmmPresentTrueWhenNested() {
#expect(VPhoneBootPatterns.parseHVVmmPresent("1\n") == true)
}
@Test func parseHVVmmPresentFalseWhenNotNested() {
#expect(VPhoneBootPatterns.parseHVVmmPresent("0") == false)
}
@Test func parseHVVmmPresentFalseWhenEmpty() {
#expect(VPhoneBootPatterns.parseHVVmmPresent("") == false)
}
// MARK: - firstBootCommands (setup_machine.sh:344-361)
@Test func firstBootCommandsMatchSetupMachineVerbatim() {
#expect(VPhoneBootPatterns.firstBootCommands == [
"export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/bin/X11:/usr/games:/iosbinpack64/usr/local/sbin:/iosbinpack64/usr/local/bin:/iosbinpack64/usr/sbin:/iosbinpack64/usr/bin:/iosbinpack64/sbin:/iosbinpack64/bin'",
"cp /iosbinpack64/etc/profile /var/profile",
"cp /iosbinpack64/etc/motd /var/motd",
"mkdir -p /var/dropbear",
"dropbearkey -t rsa -f /var/dropbear/dropbear_rsa_host_key",
"dropbearkey -t ecdsa -f /var/dropbear/dropbear_ecdsa_host_key",
"shutdown -h now",
])
}
}
@@ -0,0 +1,86 @@
@testable import VPhoneCore
import Foundation
import Testing
struct LaunchLayoutTests {
@Test func resolvesArtifactPaths() {
let layout = VPhoneLaunchLayout(projectRoot: URL(fileURLWithPath: "/proj"))
#expect(layout.preflightScript.path == "/proj/scripts/boot_host_preflight.sh")
}
@Test func resolvesToolPaths() {
let layout = VPhoneLaunchLayout(projectRoot: URL(fileURLWithPath: "/proj"))
#expect(layout.fwPrepareScript.path == "/proj/scripts/fw_prepare.sh")
#expect(layout.cfwInstallHostScript.path == "/proj/scripts/cfw_install_host.sh")
#expect(layout.pmd3Bridge.path == "/proj/scripts/pymobiledevice3_bridge.py")
}
@Test func delegatesToResources() {
let resources = VPhoneResources(base: URL(fileURLWithPath: "/proj"))
let layout = VPhoneLaunchLayout(resources: resources)
#expect(layout.fwPrepareScript.path == resources.fwPrepareScript.path)
#expect(layout.vphoned.path == resources.vphoned.path)
}
@Test func parsesLsofPIDs() {
#expect(VPhoneLsof.parsePIDs("123\n456\n123\n\n \nnotapid\n789\n") == [123, 456, 789])
#expect(VPhoneLsof.parsePIDs("") == [])
}
@Test func stageVphonedCopiesWhenSourceExists() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(
at: root.appendingPathComponent(".build"), withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try Data([1, 2, 3]).write(to: root.appendingPathComponent(".build/vphoned.signed"))
let bundleDir = root.appendingPathComponent("bundle")
try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true)
let manifest = VPhoneVirtualMachineManifest(
cpuCount: 2, memorySize: 1024 * 1024,
romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
let bundle = VPhoneBundle(url: bundleDir, manifest: manifest)
let layout = VPhoneLaunchLayout(projectRoot: root)
#expect(try layout.stageVphoned(into: bundle) == true)
#expect(FileManager.default.fileExists(atPath: bundleDir.appendingPathComponent(".vphoned.signed").path))
// Second call is a no-op (already identical).
#expect(try layout.stageVphoned(into: bundle) == false)
}
@Test func stageVphonedReturnsFalseWhenSourceAbsent() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
// No .build/vphoned.signed created source absent.
let bundleDir = root.appendingPathComponent("bundle")
try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true)
let manifest = VPhoneVirtualMachineManifest(
cpuCount: 2, memorySize: 1024 * 1024, romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
let bundle = VPhoneBundle(url: bundleDir, manifest: manifest)
#expect(try VPhoneLaunchLayout(projectRoot: root).stageVphoned(into: bundle) == false)
#expect(!FileManager.default.fileExists(
atPath: bundleDir.appendingPathComponent(".vphoned.signed").path))
}
@Test func stageVphonedOverwritesStaleDestination() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(
at: root.appendingPathComponent(".build"), withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try Data([9, 9, 9, 9]).write(to: root.appendingPathComponent(".build/vphoned.signed"))
let bundleDir = root.appendingPathComponent("bundle")
try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true)
// Pre-populate dst with DIFFERENT bytes.
try Data([1, 1]).write(to: bundleDir.appendingPathComponent(".vphoned.signed"))
let manifest = VPhoneVirtualMachineManifest(
cpuCount: 2, memorySize: 1024 * 1024, romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
let bundle = VPhoneBundle(url: bundleDir, manifest: manifest)
#expect(try VPhoneLaunchLayout(projectRoot: root).stageVphoned(into: bundle) == true)
let staged = try Data(contentsOf: bundleDir.appendingPathComponent(".vphoned.signed"))
#expect(staged == Data([9, 9, 9, 9]))
}
}
+69
View File
@@ -0,0 +1,69 @@
@testable import VPhoneCore
import Foundation
import Testing
struct LibraryTests {
private func makeRoot() throws -> URL {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func writeBundle(_ name: String, in root: URL) throws {
let dir = root.appendingPathComponent(name)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let manifest = VPhoneVirtualMachineManifest(
cpuCount: 8, memorySize: 8 * 1024 * 1024 * 1024,
romImages: .init(avpBooter: "AVPBooter.vresearch1.bin",
avpSEPBooter: "AVPSEPBooter.vresearch1.bin"))
try manifest.write(to: dir.appendingPathComponent("config.plist"))
}
@Test func scansOnlyDirsWithManifest() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
try writeBundle("alpha", in: root)
try writeBundle("beta", in: root)
// A stray dir without config.plist must be ignored.
try FileManager.default.createDirectory(
at: root.appendingPathComponent("junk"), withIntermediateDirectories: true)
let names = try VPhoneLibrary(root: root).bundles().map(\.name)
#expect(names == ["alpha", "beta"])
}
@Test func bundleNamedThrowsWhenMissing() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
#expect(throws: VPhoneLibraryError.self) {
_ = try VPhoneLibrary(root: root).bundle(named: "nope")
}
}
@Test func defaultRootHonorsEnvOverride() {
setenv("VPHONE_LIBRARY_ROOT", "/tmp/vphone-test-root", 1)
defer { unsetenv("VPHONE_LIBRARY_ROOT") }
#expect(VPhoneLibrary.defaultRoot().path == "/tmp/vphone-test-root")
}
@Test func defaultRootIsShellSafe() {
// The default root feeds the shell/make firmware pipeline; a space in it
// (e.g. "Application Support") breaks unquoted expansion. Must stay space-free.
unsetenv("VPHONE_LIBRARY_ROOT")
#expect(!VPhoneLibrary.defaultRoot().path.contains(" "))
}
@Test func scanReportsCorruptBundlesInsteadOfDropping() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
try writeBundle("good", in: root)
// A directory WITH config.plist but corrupt contents must be reported, not silently dropped.
let bad = root.appendingPathComponent("bad")
try FileManager.default.createDirectory(at: bad, withIntermediateDirectories: true)
try Data("not a plist".utf8).write(to: bad.appendingPathComponent("config.plist"))
let result = try VPhoneLibrary(root: root).scan()
#expect(result.bundles.map(\.name) == ["good"])
#expect(result.skipped.map(\.name) == ["bad"])
}
}
@@ -0,0 +1,55 @@
@testable import VPhoneCore
import Foundation
import Testing
struct ManagedProcessTests {
@Test func waitsForOutputMatch() throws {
// Emit a line after a short delay, then sleep; the matcher should catch it.
let p = VPhoneManagedProcess(
URL(fileURLWithPath: "/bin/sh"),
["-c", "sleep 0.2; echo READY-NOW; sleep 2"], echo: false)
try p.start()
let r = p.waitForOutput(matching: "READY-NOW", timeout: 5)
#expect(r == .matched)
p.terminate()
}
@Test func reportsExitBeforeMatch() throws {
let p = VPhoneManagedProcess(URL(fileURLWithPath: "/bin/sh"),
["-c", "echo nope; exit 3"], echo: false)
try p.start()
let r = p.waitForOutput(matching: "WILL-NOT-APPEAR", timeout: 5)
#expect(r == .exited(3))
}
@Test func timesOutWhenNoMatch() throws {
let p = VPhoneManagedProcess(URL(fileURLWithPath: "/bin/sh"),
["-c", "sleep 3"], echo: false)
try p.start()
let r = p.waitForOutput(matching: "NEVER", timeout: 0.5)
#expect(r == .timedOut)
p.terminate()
}
@Test func sendWritesToStdinAndDrivesTheChild() throws {
// Child echoes a marker only after it reads a line from stdin.
let p = VPhoneManagedProcess(URL(fileURLWithPath: "/bin/sh"),
["-c", "read line; echo GOT:$line"], echo: false)
try p.start()
p.send("hello")
let r = p.waitForOutput(matching: "GOT:hello", timeout: 5)
#expect(r == .matched)
#expect(p.waitUntilExit() == 0)
}
@Test func terminateForceKillsAResistantChild() throws {
// Child IGNORES SIGINT and SIGTERM, so terminate() must escalate to SIGKILL
// and waitUntilExit() must return (not hang).
let p = VPhoneManagedProcess(URL(fileURLWithPath: "/bin/sh"),
["-c", "trap '' INT TERM; while :; do sleep 1; done"], echo: false)
try p.start()
_ = p.waitForOutput(matching: "NEVER", timeout: 0.4) // let it install the traps
p.terminate()
#expect(p.waitUntilExit() != 0) // SIGKILL nonzero/signal status, and it RETURNED
}
}
+35
View File
@@ -0,0 +1,35 @@
@testable import VPhoneCore
import Foundation
import Testing
struct ManifestTests {
private func sampleManifest() -> VPhoneVirtualMachineManifest {
VPhoneVirtualMachineManifest(
cpuCount: 8,
memorySize: 8 * 1024 * 1024 * 1024,
romImages: .init(avpBooter: "AVPBooter.vresearch1.bin",
avpSEPBooter: "AVPSEPBooter.vresearch1.bin")
)
}
@Test func roundTripsThroughPlist() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("config.plist")
try sampleManifest().write(to: url)
let loaded = try VPhoneVirtualMachineManifest.load(from: url)
#expect(loaded.cpuCount == 8)
#expect(loaded.memorySize == 8 * 1024 * 1024 * 1024)
#expect(loaded.romImages?.avpBooter == "AVPBooter.vresearch1.bin")
}
@Test func updatingReplacesOnlyGivenFields() {
let updated = sampleManifest().updating(cpuCount: 4, memorySize: nil, screenConfig: nil)
#expect(updated.cpuCount == 4)
#expect(updated.memorySize == 8 * 1024 * 1024 * 1024)
}
}
@@ -0,0 +1,54 @@
@testable import VPhoneCore
import Foundation
import Testing
struct ProcessRunnerTests {
@Test func capturesStdoutAndZeroExit() throws {
let r = try VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/bin/echo"), ["hello", "world"])
#expect(r.exitCode == 0)
#expect(r.succeeded)
#expect(r.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "hello world")
}
@Test func capturesNonzeroExit() throws {
// `/usr/bin/false` exits 1 with no output.
let r = try VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/usr/bin/false"), [])
#expect(r.exitCode == 1)
#expect(!r.succeeded)
}
@Test func passesCwd() throws {
let r = try VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/bin/pwd"), [], cwd: URL(fileURLWithPath: "/tmp"))
// /tmp is a symlink to /private/tmp on macOS; accept either.
let out = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(out == "/tmp" || out == "/private/tmp")
}
@Test func drainsBothPipesConcurrentlyWithoutDeadlock() throws {
// Child writes 100 KB to stderr BEFORE any stdout; a sequential
// "read stdout fully first" drain would deadlock at the ~64 KB stderr
// pipe buffer. Concurrent draining must complete without hanging.
let r = try VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/bin/sh"),
["-c", "yes E | head -c 100000 1>&2; yes X | head -c 100000"])
#expect(r.exitCode == 0)
#expect(r.stdout.utf8.count == 100000)
#expect(r.stderr.utf8.count == 100000)
}
@Test func runStreamingReturnsExitCode() throws {
#expect(try VPhoneProcessRunner.runStreaming(URL(fileURLWithPath: "/usr/bin/true"), []) == 0)
#expect(try VPhoneProcessRunner.runStreaming(URL(fileURLWithPath: "/usr/bin/false"), []) == 1)
}
@Test func runStreamingEchoFalseStillReturnsExitCode() throws {
// Child writes to stdout but echo:false discards it; exit code still propagates.
#expect(try VPhoneProcessRunner.runStreaming(
URL(fileURLWithPath: "/bin/sh"), ["-c", "echo noise; exit 0"], echo: false) == 0)
#expect(try VPhoneProcessRunner.runStreaming(
URL(fileURLWithPath: "/bin/sh"), ["-c", "echo noise >&2; exit 7"], echo: false) == 7)
}
}
@@ -0,0 +1,53 @@
@testable import VPhoneCore
import Foundation
import Testing
struct ResourcesTests {
@Test func bundledLayoutResolvesToContentsResources() {
let exe = "/Applications/vphone-cli.app/Contents/MacOS/vphone-cli"
let r = VPhoneResources.resolve(executablePath: exe)
#expect(r.base.path == "/Applications/vphone-cli.app/Contents/Resources")
#expect(r.fwPrepareScript.path == "/Applications/vphone-cli.app/Contents/Resources/scripts/fw_prepare.sh")
#expect(r.cfwPy.path == "/Applications/vphone-cli.app/Contents/Resources/scripts/patchers/cfw.py")
}
@Test func devLayoutWalksUpToProjectRoot() throws {
// Fake a dev tree: <root>/.build/release/vphone-cli with a <root>/scripts dir.
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(
at: root.appendingPathComponent(".build/release"), withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: root.appendingPathComponent("scripts"), withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let exe = root.appendingPathComponent(".build/release/vphone-cli").path
let r = VPhoneResources.resolve(executablePath: exe)
#expect(r.base.path == root.resolvingSymlinksInPath().path)
#expect(r.resourceArchivesDir.path == root.resolvingSymlinksInPath()
.appendingPathComponent("scripts/resources").path)
}
@Test func cacheDirsAreHomeRelativeAndToolsBinIsBaseRelative() {
let r = VPhoneResources(base: URL(fileURLWithPath: "/Applications/vphone-cli.app/Contents/Resources"))
#expect(r.userCacheDir.path.hasSuffix("/.vphone"))
#expect(r.toolsBinDir.path == r.base.appendingPathComponent(".tools/bin").path)
}
@Test func managedVenvDefaultsUnderDotVphone() {
// The override env var would change this; only assert the default.
if ProcessInfo.processInfo.environment["VPHONE_VENV_DIR"] != nil { return }
let r = VPhoneResources(base: URL(fileURLWithPath: "/x"))
#expect(r.managedVenvDir.path.hasSuffix("/.vphone/venv"))
}
@Test func pythonUsabilityProbeRejectsMissingAcceptsDevVenv() {
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
let r = VPhoneResources(base: cwd)
// A non-existent interpreter is never usable.
#expect(r.pythonIsUsable(URL(fileURLWithPath: "/does/not/exist/python3")) == false)
// The dev .venv (when present) carries a modern ipsw_parser and must pass.
let devVenv = cwd.appendingPathComponent(".venv/bin/python3")
if FileManager.default.isExecutableFile(atPath: devVenv.path) {
#expect(r.pythonIsUsable(devVenv) == true)
}
}
}
@@ -0,0 +1,47 @@
@testable import VPhoneCore
import Foundation
import Testing
struct RestoreOpsTests {
private func bundle(in root: URL) throws -> VPhoneBundle {
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let manifest = VPhoneVirtualMachineManifest(
cpuCount: 2, memorySize: 1024 * 1024, romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
return VPhoneBundle(url: root, manifest: manifest)
}
@Test func resolveECIDPrefersExplicit() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let b = try bundle(in: root)
#expect(VPhoneRestoreOps.resolveECID(explicit: "0xABCD", bundle: b) == "0xABCD")
}
@Test func resolveECIDFromPredictionFile() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let b = try bundle(in: root)
try "UDID=AAAA-1122334455667788\nECID=1122334455667788\n"
.write(to: root.appendingPathComponent("udid-prediction.txt"), atomically: true, encoding: .utf8)
#expect(VPhoneRestoreOps.resolveECID(explicit: nil, bundle: b) == "1122334455667788")
}
@Test func resolveECIDNilWhenMissing() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let b = try bundle(in: root)
#expect(VPhoneRestoreOps.resolveECID(explicit: nil, bundle: b) == nil)
}
@Test func isAEAEncryptedDetectsMagic() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let enc = dir.appendingPathComponent("a.aea")
try (Data([0x41, 0x45, 0x41, 0x31]) + Data([0, 1, 2])).write(to: enc)
let plain = dir.appendingPathComponent("b.dmg")
try Data([0, 0, 0, 0, 9]).write(to: plain)
#expect(try VPhoneRestoreOps.isAEAEncrypted(enc) == true)
#expect(try VPhoneRestoreOps.isAEAEncrypted(plain) == false)
}
}
+71
View File
@@ -0,0 +1,71 @@
@testable import VPhoneCore
import Testing
struct VMPickerTests {
/// A scripted stdin: pops one line per `read()` call.
private func reader(_ lines: [String?]) -> () -> String? {
var i = 0
return { defer { i += 1 }; return i < lines.count ? lines[i] : nil }
}
@Test func returnsProvidedNameUnchanged() throws {
let out = try VPhoneVMPicker.resolve(
provided: "myvm", names: ["a", "b"], libraryRoot: "/r", isInteractive: true,
read: { nil }, write: { _ in })
#expect(out == "myvm") // no prompt when a name is supplied
}
@Test func nonInteractiveWithoutNameThrows() {
#expect(throws: VPhoneVMPickerError.notInteractive) {
_ = try VPhoneVMPicker.resolve(
provided: nil, names: ["a"], libraryRoot: "/r", isInteractive: false,
read: { nil }, write: { _ in })
}
}
@Test func emptyLibraryThrows() {
#expect(throws: VPhoneVMPickerError.emptyLibrary(root: "/r")) {
_ = try VPhoneVMPicker.resolve(
provided: nil, names: [], libraryRoot: "/r", isInteractive: true,
read: { nil }, write: { _ in })
}
}
@Test func selectsByIndex() throws {
let out = try VPhoneVMPicker.resolve(
provided: nil, names: ["alpha", "beta", "gamma"], libraryRoot: "/r",
isInteractive: true, read: reader(["2"]), write: { _ in })
#expect(out == "beta")
}
@Test func selectsByExactName() throws {
let out = try VPhoneVMPicker.resolve(
provided: nil, names: ["alpha", "beta"], libraryRoot: "/r",
isInteractive: true, read: reader(["alpha"]), write: { _ in })
#expect(out == "alpha")
}
@Test func retriesThenSucceeds() throws {
// blank, out-of-range, bad-name, then a good index.
let out = try VPhoneVMPicker.resolve(
provided: nil, names: ["alpha", "beta"], libraryRoot: "/r",
isInteractive: true, read: reader(["", "9", "nope", "1"]), write: { _ in })
#expect(out == "alpha")
}
@Test func eofAborts() {
#expect(throws: VPhoneVMPickerError.aborted) {
_ = try VPhoneVMPicker.resolve(
provided: nil, names: ["alpha"], libraryRoot: "/r", isInteractive: true,
read: { nil }, write: { _ in })
}
}
@Test func tooManyInvalidThrows() {
#expect(throws: VPhoneVMPickerError.invalidSelection) {
_ = try VPhoneVMPicker.resolve(
provided: nil, names: ["alpha"], libraryRoot: "/r", isInteractive: true,
maxRetries: 2, read: reader(["x", "y", "z"]), write: { _ in })
}
}
}
@@ -0,0 +1,22 @@
@testable import VPhoneCore
import Testing
struct VerbosityTests {
@Test func countClampsToRange() {
#expect(VPhoneVerbosity(count: 0) == .quiet)
#expect(VPhoneVerbosity(count: 1) == .info)
#expect(VPhoneVerbosity(count: 2) == .debug)
#expect(VPhoneVerbosity(count: 3) == .trace)
#expect(VPhoneVerbosity(count: 9) == .trace) // clamp up
#expect(VPhoneVerbosity(count: -4) == .quiet) // clamp down
}
@Test func gatesAreMonotonic() {
#expect(VPhoneVerbosity.quiet.showsToolDetail == false)
#expect(VPhoneVerbosity.info.showsToolDetail == true)
#expect(VPhoneVerbosity.debug.tracesInternals == false)
#expect(VPhoneVerbosity.trace.tracesInternals == true)
#expect(VPhoneVerbosity.quiet < .info)
#expect(VPhoneVerbosity.debug < .trace)
}
}