From 828be6dbc68b955c92a400a822b937e3d46040f8 Mon Sep 17 00:00:00 2001 From: zqxwce Date: Mon, 27 Jul 2026 19:08:36 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20add=20VPhoneCore=20library=20=E2=80=94?= =?UTF-8?q?=20VM=20bundle=20model=20+=20host=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Y4VDqWf5pVakcFLqB23CKe --- Package.swift | 14 + sources/VPhoneCore/VPhoneBootPatterns.swift | 72 ++++ sources/VPhoneCore/VPhoneBundle.swift | 28 ++ sources/VPhoneCore/VPhoneBundleOps.swift | 221 ++++++++++++ sources/VPhoneCore/VPhoneBundleReport.swift | 15 + sources/VPhoneCore/VPhoneLaunchLayout.swift | 50 +++ sources/VPhoneCore/VPhoneLibrary.swift | 82 +++++ sources/VPhoneCore/VPhoneManagedProcess.swift | 160 +++++++++ sources/VPhoneCore/VPhoneProcessRunner.swift | 111 ++++++ sources/VPhoneCore/VPhoneResources.swift | 181 ++++++++++ sources/VPhoneCore/VPhoneRestoreOps.swift | 57 +++ sources/VPhoneCore/VPhoneVMPicker.swift | 55 +++ sources/VPhoneCore/VPhoneVerbosity.swift | 23 ++ .../VPhoneVirtualMachineManifest.swift | 239 +++++++++++++ .../VPhoneVirtualMachineManifest.swift | 180 ---------- tests/VPhoneCoreTests/BundleOpsTests.swift | 325 ++++++++++++++++++ tests/VPhoneCoreTests/BundleReportTests.swift | 28 ++ .../CreateOrchestratorTests.swift | 108 ++++++ tests/VPhoneCoreTests/LaunchLayoutTests.swift | 86 +++++ tests/VPhoneCoreTests/LibraryTests.swift | 69 ++++ .../VPhoneCoreTests/ManagedProcessTests.swift | 55 +++ tests/VPhoneCoreTests/ManifestTests.swift | 35 ++ .../VPhoneCoreTests/ProcessRunnerTests.swift | 54 +++ tests/VPhoneCoreTests/ResourcesTests.swift | 53 +++ tests/VPhoneCoreTests/RestoreOpsTests.swift | 47 +++ tests/VPhoneCoreTests/VMPickerTests.swift | 71 ++++ tests/VPhoneCoreTests/VerbosityTests.swift | 22 ++ 27 files changed, 2261 insertions(+), 180 deletions(-) create mode 100644 sources/VPhoneCore/VPhoneBootPatterns.swift create mode 100644 sources/VPhoneCore/VPhoneBundle.swift create mode 100644 sources/VPhoneCore/VPhoneBundleOps.swift create mode 100644 sources/VPhoneCore/VPhoneBundleReport.swift create mode 100644 sources/VPhoneCore/VPhoneLaunchLayout.swift create mode 100644 sources/VPhoneCore/VPhoneLibrary.swift create mode 100644 sources/VPhoneCore/VPhoneManagedProcess.swift create mode 100644 sources/VPhoneCore/VPhoneProcessRunner.swift create mode 100644 sources/VPhoneCore/VPhoneResources.swift create mode 100644 sources/VPhoneCore/VPhoneRestoreOps.swift create mode 100644 sources/VPhoneCore/VPhoneVMPicker.swift create mode 100644 sources/VPhoneCore/VPhoneVerbosity.swift create mode 100644 sources/VPhoneCore/VPhoneVirtualMachineManifest.swift delete mode 100644 sources/vphone-cli/VPhoneVirtualMachineManifest.swift create mode 100644 tests/VPhoneCoreTests/BundleOpsTests.swift create mode 100644 tests/VPhoneCoreTests/BundleReportTests.swift create mode 100644 tests/VPhoneCoreTests/CreateOrchestratorTests.swift create mode 100644 tests/VPhoneCoreTests/LaunchLayoutTests.swift create mode 100644 tests/VPhoneCoreTests/LibraryTests.swift create mode 100644 tests/VPhoneCoreTests/ManagedProcessTests.swift create mode 100644 tests/VPhoneCoreTests/ManifestTests.swift create mode 100644 tests/VPhoneCoreTests/ProcessRunnerTests.swift create mode 100644 tests/VPhoneCoreTests/ResourcesTests.swift create mode 100644 tests/VPhoneCoreTests/RestoreOpsTests.swift create mode 100644 tests/VPhoneCoreTests/VMPickerTests.swift create mode 100644 tests/VPhoneCoreTests/VerbosityTests.swift diff --git a/Package.swift b/Package.swift index 6590161..786d89d 100644 --- a/Package.swift +++ b/Package.swift @@ -22,15 +22,24 @@ let package = Package( .product(name: "Capstone", package: "libcapstone-spm"), .product(name: "Img4tool", package: "libimg4-spm"), .product(name: "MachOKit", package: "MachOKit"), + "VPhoneCore", ], path: "sources/FirmwarePatcher" ), + .target( + name: "VPhoneCore", + path: "sources/VPhoneCore", + linkerSettings: [ + .linkedFramework("Virtualization"), + ] + ), .executableTarget( name: "vphone-cli", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Dynamic", package: "Dynamic"), "FirmwarePatcher", + "VPhoneCore", ], path: "sources/vphone-cli", linkerSettings: [ @@ -46,5 +55,10 @@ let package = Package( dependencies: ["FirmwarePatcher"], path: "tests/FirmwarePatcherTests" ), + .testTarget( + name: "VPhoneCoreTests", + dependencies: ["VPhoneCore"], + path: "tests/VPhoneCoreTests" + ), ] ) diff --git a/sources/VPhoneCore/VPhoneBootPatterns.swift b/sources/VPhoneCore/VPhoneBootPatterns.swift new file mode 100644 index 0000000..a90eccf --- /dev/null +++ b/sources/VPhoneCore/VPhoneBootPatterns.swift @@ -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" + } +} diff --git a/sources/VPhoneCore/VPhoneBundle.swift b/sources/VPhoneCore/VPhoneBundle.swift new file mode 100644 index 0000000..8af12ed --- /dev/null +++ b/sources/VPhoneCore/VPhoneBundle.swift @@ -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) + } +} diff --git a/sources/VPhoneCore/VPhoneBundleOps.swift b/sources/VPhoneCore/VPhoneBundleOps.swift new file mode 100644 index 0000000..ceaaf5f --- /dev/null +++ b/sources/VPhoneCore/VPhoneBundleOps.swift @@ -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) + } +} diff --git a/sources/VPhoneCore/VPhoneBundleReport.swift b/sources/VPhoneCore/VPhoneBundleReport.swift new file mode 100644 index 0000000..47ba084 --- /dev/null +++ b/sources/VPhoneCore/VPhoneBundleReport.swift @@ -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 + } +} diff --git a/sources/VPhoneCore/VPhoneLaunchLayout.swift b/sources/VPhoneCore/VPhoneLaunchLayout.swift new file mode 100644 index 0000000..ff7f88b --- /dev/null +++ b/sources/VPhoneCore/VPhoneLaunchLayout.swift @@ -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() + 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() + } +} diff --git a/sources/VPhoneCore/VPhoneLibrary.swift b/sources/VPhoneCore/VPhoneLibrary.swift new file mode 100644 index 0000000..2dd2a07 --- /dev/null +++ b/sources/VPhoneCore/VPhoneLibrary.swift @@ -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) + } +} diff --git a/sources/VPhoneCore/VPhoneManagedProcess.swift b/sources/VPhoneCore/VPhoneManagedProcess.swift new file mode 100644 index 0000000..3733864 --- /dev/null +++ b/sources/VPhoneCore/VPhoneManagedProcess.swift @@ -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.. 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) + } + } +} diff --git a/sources/VPhoneCore/VPhoneProcessRunner.swift b/sources/VPhoneCore/VPhoneProcessRunner.swift new file mode 100644 index 0000000..d03bf22 --- /dev/null +++ b/sources/VPhoneCore/VPhoneProcessRunner.swift @@ -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 + } +} diff --git a/sources/VPhoneCore/VPhoneResources.swift b/sources/VPhoneCore/VPhoneResources.swift new file mode 100644 index 0000000..0909fa6 --- /dev/null +++ b/sources/VPhoneCore/VPhoneResources.swift @@ -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 python@3.13`) 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 python@3.13`) 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() + 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 + } +} diff --git a/sources/VPhoneCore/VPhoneRestoreOps.swift b/sources/VPhoneCore/VPhoneRestoreOps.swift new file mode 100644 index 0000000..74af9c1 --- /dev/null +++ b/sources/VPhoneCore/VPhoneRestoreOps.swift @@ -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 /; 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) } + } + } +} diff --git a/sources/VPhoneCore/VPhoneVMPicker.swift b/sources/VPhoneCore/VPhoneVMPicker.swift new file mode 100644 index 0000000..32979c7 --- /dev/null +++ b/sources/VPhoneCore/VPhoneVMPicker.swift @@ -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 '" + 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..= 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 + } +} diff --git a/sources/VPhoneCore/VPhoneVerbosity.swift b/sources/VPhoneCore/VPhoneVerbosity.swift new file mode 100644 index 0000000..3669adc --- /dev/null +++ b/sources/VPhoneCore/VPhoneVerbosity.swift @@ -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 (0…N) 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 } +} diff --git a/sources/VPhoneCore/VPhoneVirtualMachineManifest.swift b/sources/VPhoneCore/VPhoneVirtualMachineManifest.swift new file mode 100644 index 0000000..3279f00 --- /dev/null +++ b/sources/VPhoneCore/VPhoneVirtualMachineManifest.swift @@ -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 + ) + } +} diff --git a/sources/vphone-cli/VPhoneVirtualMachineManifest.swift b/sources/vphone-cli/VPhoneVirtualMachineManifest.swift deleted file mode 100644 index 0d8d81b..0000000 --- a/sources/vphone-cli/VPhoneVirtualMachineManifest.swift +++ /dev/null @@ -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) - } -} diff --git a/tests/VPhoneCoreTests/BundleOpsTests.swift b/tests/VPhoneCoreTests/BundleOpsTests.swift new file mode 100644 index 0000000..74d1e3f --- /dev/null +++ b/tests/VPhoneCoreTests/BundleOpsTests.swift @@ -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)) + } + } +} diff --git a/tests/VPhoneCoreTests/BundleReportTests.swift b/tests/VPhoneCoreTests/BundleReportTests.swift new file mode 100644 index 0000000..45653b4 --- /dev/null +++ b/tests/VPhoneCoreTests/BundleReportTests.swift @@ -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) + } +} diff --git a/tests/VPhoneCoreTests/CreateOrchestratorTests.swift b/tests/VPhoneCoreTests/CreateOrchestratorTests.swift new file mode 100644 index 0000000..b33388c --- /dev/null +++ b/tests/VPhoneCoreTests/CreateOrchestratorTests.swift @@ -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", + ]) + } +} diff --git a/tests/VPhoneCoreTests/LaunchLayoutTests.swift b/tests/VPhoneCoreTests/LaunchLayoutTests.swift new file mode 100644 index 0000000..511d8fa --- /dev/null +++ b/tests/VPhoneCoreTests/LaunchLayoutTests.swift @@ -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])) + } +} diff --git a/tests/VPhoneCoreTests/LibraryTests.swift b/tests/VPhoneCoreTests/LibraryTests.swift new file mode 100644 index 0000000..837d1a5 --- /dev/null +++ b/tests/VPhoneCoreTests/LibraryTests.swift @@ -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"]) + } +} diff --git a/tests/VPhoneCoreTests/ManagedProcessTests.swift b/tests/VPhoneCoreTests/ManagedProcessTests.swift new file mode 100644 index 0000000..ab6fc15 --- /dev/null +++ b/tests/VPhoneCoreTests/ManagedProcessTests.swift @@ -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 + } +} diff --git a/tests/VPhoneCoreTests/ManifestTests.swift b/tests/VPhoneCoreTests/ManifestTests.swift new file mode 100644 index 0000000..d2a7d2b --- /dev/null +++ b/tests/VPhoneCoreTests/ManifestTests.swift @@ -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) + } +} diff --git a/tests/VPhoneCoreTests/ProcessRunnerTests.swift b/tests/VPhoneCoreTests/ProcessRunnerTests.swift new file mode 100644 index 0000000..244a692 --- /dev/null +++ b/tests/VPhoneCoreTests/ProcessRunnerTests.swift @@ -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) + } +} diff --git a/tests/VPhoneCoreTests/ResourcesTests.swift b/tests/VPhoneCoreTests/ResourcesTests.swift new file mode 100644 index 0000000..712e66d --- /dev/null +++ b/tests/VPhoneCoreTests/ResourcesTests.swift @@ -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: /.build/release/vphone-cli with a /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) + } + } +} diff --git a/tests/VPhoneCoreTests/RestoreOpsTests.swift b/tests/VPhoneCoreTests/RestoreOpsTests.swift new file mode 100644 index 0000000..4b8af19 --- /dev/null +++ b/tests/VPhoneCoreTests/RestoreOpsTests.swift @@ -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) + } +} diff --git a/tests/VPhoneCoreTests/VMPickerTests.swift b/tests/VPhoneCoreTests/VMPickerTests.swift new file mode 100644 index 0000000..e9fe019 --- /dev/null +++ b/tests/VPhoneCoreTests/VMPickerTests.swift @@ -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 }) + } + } +} diff --git a/tests/VPhoneCoreTests/VerbosityTests.swift b/tests/VPhoneCoreTests/VerbosityTests.swift new file mode 100644 index 0000000..7ec58b1 --- /dev/null +++ b/tests/VPhoneCoreTests/VerbosityTests.swift @@ -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) + } +}