diff --git a/Documents/Guides/create-and-run.md b/Documents/Guides/create-and-run.md index b42e740..69359b1 100644 --- a/Documents/Guides/create-and-run.md +++ b/Documents/Guides/create-and-run.md @@ -55,11 +55,10 @@ The online restore obtains its ticket in process. For an offline restore, see `v | Default path | Contents | | --- | --- | -| `~/.vphone/VMs/` | One bundle per VM, including its disk and `config.plist` | -| `~/.vphone/ipsws/` | Cached source IPSWs | -| `~/.vphone/tools/` | Cached firmware patching tools and artifacts | +| `~/.vphone/machines//` | One VM, including its disk, `config.plist`, and patch work files | +| `~/.vphone/machines//.ipsw-cache/` | Remote source IPSWs downloaded for that VM; local IPSWs are read in place | -`VPHONE_ROOT` relocates the complete tree. `VPHONE_LIBRARY_ROOT` takes precedence for the VM library alone. Source IPSWs remain cached; the prepared restore tree is removed after a successful `vm create` unless `--keep-artifacts` is set. +`VPHONE_ROOT` relocates the VM library. `VPHONE_LIBRARY_ROOT` takes precedence for the library alone. Downloaded IPSWs remain cached inside their VM; the prepared restore tree is removed after a successful `vm create` unless `--keep-artifacts` is set. JB patching does not create `~/.vphone/tools`. ```sh vphone-cli vm list diff --git a/Research/Restore/virtual_dfu_probe.md b/Research/Restore/virtual_dfu_probe.md index f7ee6a1..5117060 100644 --- a/Research/Restore/virtual_dfu_probe.md +++ b/Research/Restore/virtual_dfu_probe.md @@ -90,7 +90,7 @@ Boot the VM first, leaving it running. On a host with AMFI relaxed at boot, that is just: ``` -.../vphone-vm --config ~/.vphone/VMs//config.plist --dfu +.../vphone-vm --config ~/.vphone/machines//config.plist --dfu ``` Where amfid still refuses it, allow that one binary first — in another terminal, diff --git a/Scripts/check_aux.sh b/Scripts/check_aux.sh index 8c70e1d..306e045 100755 --- a/Scripts/check_aux.sh +++ b/Scripts/check_aux.sh @@ -7,6 +7,16 @@ bundle="${1:-$root/.build/XcodeBundle/Build/Products/Debug/VPhone.bundle}" macos="$bundle/Contents/MacOS" resources="$bundle/Contents/Resources" +file_copy_spawns="$(/usr/bin/find "$root/VPhoneExecutable" "$root/VPhoneKit" \ + "$root/VPhoneDaemon" "$root/VPhoneGuestComponents" \ + -type d \( -name Build -o -name .build -o -name '*Tests' -o -name '*TestFixtures' \) -prune -o \ + -type f -name '*.swift' -exec /usr/bin/grep -nE '"/(usr/)?bin/(cp|mv|rm)"' {} + || true)" +[[ -z "$file_copy_spawns" ]] || { + print -u2 "Host file operations must use in-process file APIs, not spawned cp/mv/rm:" + print -u2 -- "$file_copy_spawns" + exit 1 +} + [[ -d "$bundle" ]] || { print -u2 "Missing Xcode bundle: $bundle"; exit 1; } for name in vphone-vm vphone-cli VPhoneEscalator vphoned vphoned.signed \ diff --git a/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcher.swift b/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcher.swift index 52b6be6..dc9b68f 100644 --- a/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcher.swift +++ b/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcher.swift @@ -26,12 +26,12 @@ public final class CryptexFilesystemPatcher: Patcher { public let restoreDir: URL public let verbose: Bool public let noBinpack: Bool - let vphoneCliDirectory = URL(filePath: "./") let resources = VPhoneResources.resolve() var buildManiest: Data var rebuiltData: Data? var tmpDirectories: [URL] = [] + var attachedDevices: Set = [] // MARK: - Init @@ -48,8 +48,15 @@ public final class CryptexFilesystemPatcher: Patcher { } deinit { - for tmp in tmpDirectories { - try? FileManager.default.removeItem(at: tmp) + for device in Array(attachedDevices) { + try? detachImage(deviceNode: device) + } + if attachedDevices.isEmpty { + for tmp in tmpDirectories { + try? FileManager.default.removeItem(at: tmp) + } + } else { + fputs("warning: filesystem patch image is still attached; left VM work files in \(restoreDir.path)\n", stderr) } } @@ -177,9 +184,8 @@ public final class CryptexFilesystemPatcher: Patcher { } func createTmpDir() throws -> URL { - let tmpDir = FileManager.default.temporaryDirectory - .appending(path: "vphone-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + let tmpDir = restoreDir.appending(path: ".filesystem-patch-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false) tmpDirectories.append(tmpDir) return tmpDir } diff --git a/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcherDiskImage.swift b/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcherDiskImage.swift index 305783a..2e6831d 100644 --- a/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcherDiskImage.swift +++ b/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcherDiskImage.swift @@ -167,8 +167,23 @@ extension CryptexFilesystemPatcher { throw ProcessError.failed(process.terminationStatus, output) } - let root = try parsePlist(data: data) + let root: PlistDict + do { + root = try parsePlist(data: data) + } catch { + if let output = String(data: data, encoding: .utf8), + let range = output.range(of: #"/dev/disk[0-9]+"#, options: .regularExpression) + { + _ = try? runProcess("/usr/bin/hdiutil", ["detach", "-force", String(output[range])]) + } + throw error + } guard let entries = root["system-entities"] as? [Any] else { + if let output = String(data: data, encoding: .utf8), + let range = output.range(of: #"/dev/disk[0-9]+"#, options: .regularExpression) + { + _ = try? runProcess("/usr/bin/hdiutil", ["detach", "-force", String(output[range])]) + } throw FirmwareManifest.ManifestError.missingKey("system-entities") } for entry in entries { @@ -180,16 +195,33 @@ extension CryptexFilesystemPatcher { } let device = entry["dev-entry"] as? String ?? "" let mountPoint = entry["mount-point"] as? String ?? "" + guard !device.isEmpty, !mountPoint.isEmpty else { continue } + attachedDevices.insert(device) if forceRW { - _ = try runProcess("/sbin/mount", ["-u", "-w", device, mountPoint]) + do { + _ = try runProcess("/sbin/mount", ["-u", "-w", device, mountPoint]) + } catch { + try? detachImage(deviceNode: device) + throw error + } } return (device, mountPoint) } + if let device = (entries.compactMap { ($0 as? PlistDict)?["dev-entry"] as? String }) + .first(where: { $0.hasPrefix("/dev/disk") }) + { + _ = try? runProcess("/usr/bin/hdiutil", ["detach", "-force", device]) + } throw FirmwareManifest.ManifestError.missingKey("dev-entry or mount-point") } func detachImage(deviceNode: String) throws { - _ = try runProcess("/usr/bin/hdiutil", ["detach", deviceNode]) + do { + _ = try runProcess("/usr/bin/hdiutil", ["detach", deviceNode]) + } catch { + _ = try runProcess("/usr/bin/hdiutil", ["detach", "-force", deviceNode]) + } + attachedDevices.remove(deviceNode) } } diff --git a/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcherSealing.swift b/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcherSealing.swift index 27c5cd7..74ca347 100644 --- a/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcherSealing.swift +++ b/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcherSealing.swift @@ -54,11 +54,9 @@ extension CryptexFilesystemPatcher { private func identifyApfsSealvolume() throws -> URL { let iosVersion = try getProductVersion() - // VPHONE_SEAL_DIR (set by the Command's `fw prepare`/`fw patch`) must agree with - // wherever fw_prepare.sh's download_apfs_sealvolume() wrote the file; unset - // (manual development flow) falls back to the historical repo-relative `.tools/`. + // The optional filesystem merge reads a seal tool staged inside the VM. let sealDir = ProcessInfo.processInfo.environment["VPHONE_SEAL_DIR"].map { URL(fileURLWithPath: $0) } - ?? vphoneCliDirectory.appending(path: ".tools") + ?? restoreDir.appendingPathComponent(".tools") let path = sealDir.appendingPathComponent("apfs_sealvolume_\(iosVersion)") guard FileManager.default.fileExists(atPath: path.path) else { throw FirmwareManifest.ManifestError.fileNotFound(path.path) diff --git a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneCustomFirmwareInstaller.swift b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneCustomFirmwareInstaller.swift index a8b3fce..fbca9c4 100644 --- a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneCustomFirmwareInstaller.swift +++ b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneCustomFirmwareInstaller.swift @@ -70,9 +70,27 @@ struct VPhoneCustomFirmwareInstaller { .split(whereSeparator: \.isWhitespace).first.map(String.init), baseDisk.hasPrefix("/dev/disk") else { + if let range = attached.range(of: #"/dev/disk[0-9]+"#, options: .regularExpression) { + _ = try? tool("/usr/bin/hdiutil", ["detach", "-force", String(attached[range])], quiet: true) + } throw ValidationError("hdiutil attached no disk device") } - defer { _ = try? tool("/usr/bin/hdiutil", ["detach", baseDisk], quiet: true) } + var diskAttached = true + var workToClean: URL? + defer { + if diskAttached, + (try? tool("/usr/bin/hdiutil", ["detach", baseDisk], quiet: true)) == nil + { + _ = try? tool("/usr/bin/hdiutil", ["detach", "-force", baseDisk], quiet: true) + } + if let workToClean { + do { + try removeWorkDirectory(workToClean) + } catch { + fputs("warning: left CFW work directory at \(workToClean.path): \(error)\n", stderr) + } + } + } let info = try tool("/usr/sbin/diskutil", ["info", "-plist", "\(baseDisk)s1"], quiet: true) guard let plist = try PropertyListSerialization.propertyList( @@ -87,21 +105,36 @@ struct VPhoneCustomFirmwareInstaller { let work = bundle.appendingPathComponent(".cfw-native-\(UUID().uuidString)") let system = work.appendingPathComponent("system") let data = work.appendingPathComponent("data") - try fm.createDirectory(at: system, withIntermediateDirectories: true) - try fm.createDirectory(at: data, withIntermediateDirectories: true) + try fm.createDirectory(at: work, withIntermediateDirectories: false) + workToClean = work + var systemMounted = false + var dataMounted = false defer { - _ = try? tool("/sbin/umount", [data.path], quiet: true) - _ = try? tool("/sbin/umount", [system.path], quiet: true) - try? fm.removeItem(at: work) + if dataMounted, + (try? tool("/sbin/umount", [data.path], quiet: true)) == nil + { + _ = try? tool("/sbin/umount", ["-f", data.path], quiet: true) + } + if systemMounted, + (try? tool("/sbin/umount", [system.path], quiet: true)) == nil + { + _ = try? tool("/sbin/umount", ["-f", system.path], quiet: true) + } } + try fm.createDirectory(at: system, withIntermediateDirectories: false) + try fm.createDirectory(at: data, withIntermediateDirectories: false) try tool("/sbin/mount_apfs", ["-o", "rw", "/dev/\(container)s1", system.path]) + systemMounted = true try tool("/sbin/mount_apfs", ["-o", "rw", "/dev/\(container)s3", data.path]) + dataMounted = true print("[*] JB system install: \(bundle.lastPathComponent)") try installMounted(system: system, data: data, work: work) _ = try tool("/sbin/umount", [data.path]) + dataMounted = false _ = try tool("/sbin/umount", [system.path]) + systemMounted = false _ = try tool("/usr/bin/hdiutil", ["detach", baseDisk], quiet: true) - try fm.removeItem(at: work) + diskAttached = false try VPhoneAPFSSnapshot.rename(imageAt: diskImage) print("[+] JB system install complete; vphoned is installed, no package bootstrap was staged") } @@ -209,6 +242,7 @@ struct VPhoneCustomFirmwareInstaller { buildManifest: restore.appendingPathComponent("iPhone-BuildManifest.plist"), ) let encrypted = restore.appendingPathComponent(paths.systemOS) + let appImage = restore.appendingPathComponent(paths.appOS) let plain = work.appendingPathComponent("SystemOS.dmg") let key = try vphoneRunBlocking { try await VPhoneAEA.symmetricKey(of: encrypted) } try tool( @@ -231,7 +265,7 @@ struct VPhoneCustomFirmwareInstaller { try tool( "/usr/bin/hdiutil", ["attach", "-mountpoint", appMount.path, - restore.appendingPathComponent(paths.appOS).path, + appImage.path, "-nobrowse", "-owners", "off"], quiet: true, ) @@ -245,7 +279,9 @@ struct VPhoneCustomFirmwareInstaller { try fm.removeItem(at: destination) } try fm.createDirectory(at: destination, withIntermediateDirectories: true) - try tool("/bin/cp", ["-R", source.appendingPathComponent(".").path, destination.path]) + for entry in try fm.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) { + try fm.copyItem(at: entry, to: destination.appendingPathComponent(entry.lastPathComponent)) + } } } try symlink("../../../System/Cryptexes/OS/System/Library/Caches/com.apple.dyld", @@ -402,10 +438,27 @@ struct VPhoneCustomFirmwareInstaller { } private func replace(_ source: URL, at destination: URL, mode: Int) throws { - try tool("/bin/cp", ["-f", source.path, destination.path], quiet: true) + if fm.fileExists(atPath: destination.path) { + try fm.removeItem(at: destination) + } + try fm.copyItem(at: source, to: destination) try fm.setAttributes([.posixPermissions: NSNumber(value: mode)], ofItemAtPath: destination.path) } + private func removeWorkDirectory(_ work: URL) throws { + guard let mounts = fm.mountedVolumeURLs(includingResourceValuesForKeys: nil, options: []) else { + throw ValidationError("Could not verify that CFW volumes are detached") + } + let root = work.resolvingSymlinksInPath().path + guard !mounts.contains(where: { + let path = $0.resolvingSymlinksInPath().path + return path == root || path.hasPrefix(root + "/") + }) else { + throw ValidationError("CFW volume is still mounted under \(work.path)") + } + try fm.removeItem(at: work) + } + @discardableResult private func patch(_ verb: String, _ arguments: [String]) throws -> String { try tool(executable.path, ["cfw", verb] + arguments) diff --git a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneFirmwareCommand.swift b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneFirmwareCommand.swift index ec3a881..9c1a802 100644 --- a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneFirmwareCommand.swift +++ b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneFirmwareCommand.swift @@ -264,7 +264,6 @@ struct VPhoneFirmwarePrepareCommand: ParsableCommand { let bundle = try lib.library.bundle(named: name) defer { try? VPhoneHostFilePermissions.makeAccessible(at: bundle.url) - try? VPhoneHostFilePermissions.makeAccessible(at: resources.ipswCacheDir) } try VPhoneFirmwarePreparer.prepare( iPhoneSource: phone, @@ -274,7 +273,6 @@ struct VPhoneFirmwarePrepareCommand: ParsableCommand { resources: resources, ) try VPhoneHostFilePermissions.makeAccessible(at: bundle.url) - try VPhoneHostFilePermissions.makeAccessible(at: resources.ipswCacheDir) } } @@ -298,16 +296,8 @@ struct VPhoneFirmwarePatchCommand: ParsableCommand { let bundle = try lib.library.bundle(named: name) defer { try? VPhoneHostFilePermissions.makeAccessible(at: bundle.url) - try? VPhoneHostFilePermissions.makeAccessible(at: VPhoneResources.resolve().sealVolumeCacheDir) } - // In-process pipeline (no subprocess) — CryptexFilesystemPatcher's - // apfs_sealvolume read honors VPHONE_SEAL_DIR from *this* process's - // environment, so set it here to agree with `fw prepare`'s write. - let resources = VPhoneResources.resolve() - try FileManager.default.createDirectory(at: resources.sealVolumeCacheDir, withIntermediateDirectories: true) - setenv("VPHONE_SEAL_DIR", resources.sealVolumeCacheDir.path, 1) - let pipeline = FirmwarePipeline( vmDirectory: bundle.url, variant: .jb, @@ -318,7 +308,6 @@ struct VPhoneFirmwarePatchCommand: ParsableCommand { ) let records = try pipeline.patchAll() try VPhoneHostFilePermissions.makeAccessible(at: bundle.url) - try VPhoneHostFilePermissions.makeAccessible(at: resources.sealVolumeCacheDir) print("[fw patch] applied \(records.count) JB patches") } } diff --git a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneFirmwareDownloadCommand.swift b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneFirmwareDownloadCommand.swift index 7bd538e..50d188c 100644 --- a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneFirmwareDownloadCommand.swift +++ b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneFirmwareDownloadCommand.swift @@ -80,7 +80,7 @@ struct VPhoneFirmwareSealToolCommand: ParsableCommand { } try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) - let ramdisk = try vphoneRunBlocking { try await Self.fetchRamdisk(version: version) } + let ramdisk = try vphoneRunBlocking { try await Self.fetchRamdisk(version: version, in: output) } defer { try? FileManager.default.removeItem(at: ramdisk.deletingLastPathComponent()) } try Self.copyOut(of: ramdisk, to: destination) @@ -105,7 +105,7 @@ struct VPhoneFirmwareSealToolCommand: ParsableCommand { /// Resolve the macOS release, then take BuildManifest.plist and the restore /// ramdisk out of its IPSW without downloading the IPSW. - private static func fetchRamdisk(version: String) async throws -> URL { + private static func fetchRamdisk(version: String, in output: URL) async throws -> URL { let release = try await VPhoneFirmwareIndex.macOSRelease(version: version) print(" macOS \(release.version) (\(release.build))") @@ -137,11 +137,15 @@ struct VPhoneFirmwareSealToolCommand: ParsableCommand { print(" ramdisk: \(path)") let im4p = try await zip.read(zip.entry(endingWith: path)) - let work = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent("vphone-sealtool-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) + let work = output.appendingPathComponent(".vphone-sealtool-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: work, withIntermediateDirectories: false) let dmg = work.appendingPathComponent("ramdisk.dmg") - try IM4P(im4p).payload().write(to: dmg) + do { + try IM4P(im4p).payload().write(to: dmg) + } catch { + try? FileManager.default.removeItem(at: work) + throw error + } return dmg } diff --git a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneFirmwarePreparer.swift b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneFirmwarePreparer.swift index a8f251b..99abc13 100644 --- a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneFirmwarePreparer.swift +++ b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Firmware/VPhoneFirmwarePreparer.swift @@ -44,13 +44,16 @@ enum VPhoneFirmwarePreparer { } } + // Remote IPSWs belong to this VM, not a writable shared directory. + // Local IPSWs are read in place and are never copied into the cache. + let cacheDirectory = bundle.url.appendingPathComponent(".ipsw-cache", isDirectory: true) print("[*] Resolving iPhone IPSW...") let phone = try vphoneRunBlocking { - try await VPhoneIPSWCache.resolve(iPhoneSource, in: resources.ipswCacheDir) + try await VPhoneIPSWCache.resolve(iPhoneSource, in: cacheDirectory) } print("[*] Resolving cloudOS IPSW...") let cloud = try vphoneRunBlocking { - try await VPhoneIPSWCache.resolve(cloudOSSource, in: resources.ipswCacheDir) + try await VPhoneIPSWCache.resolve(cloudOSSource, in: cacheDirectory) } try checkIPhoneName(iPhoneSource, archive: phone) print("[+] iPhone \(phone.version) (\(phone.build)); cloudOS \(cloud.version) (\(cloud.build))") @@ -60,9 +63,17 @@ enum VPhoneFirmwarePreparer { let staging = bundle.url.appendingPathComponent(".firmware-prepare-\(UUID().uuidString)") let phoneTree = staging.appendingPathComponent(name) let cloudTree = staging.appendingPathComponent("cloudOS") - try fm.createDirectory(at: phoneTree, withIntermediateDirectories: true) - try fm.createDirectory(at: cloudTree, withIntermediateDirectories: true) - defer { try? fm.removeItem(at: staging) } + try fm.createDirectory(at: staging, withIntermediateDirectories: false) + defer { + let entries = (try? fm.contentsOfDirectory(atPath: staging.path)) ?? [] + if entries.contains(where: { $0.hasPrefix(".pcc-system-") || $0.hasPrefix(".pcc-restoration-") }) { + fputs("warning: PCC mount may still be active; left staging at \(staging.path)\n", stderr) + } else { + try? fm.removeItem(at: staging) + } + } + try fm.createDirectory(at: phoneTree, withIntermediateDirectories: false) + try fm.createDirectory(at: cloudTree, withIntermediateDirectories: false) print("[*] Extracting iPhone IPSW...") try VPhoneArchiveExtractor.extract(phone.file, into: phoneTree, options: .intoHostDirectory) diff --git a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Host/VPhonePCCGPURecovery.swift b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Host/VPhonePCCGPURecovery.swift index d5f457e..14a0f47 100644 --- a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Host/VPhonePCCGPURecovery.swift +++ b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/Host/VPhonePCCGPURecovery.swift @@ -29,7 +29,7 @@ enum VPhonePCCGPURecovery { ) throws { let fm = FileManager.default let temporaryLibrary = restoreDirectory.deletingLastPathComponent() - .appending(path: ".pcc-restoration") + .appending(path: ".pcc-restoration-\(UUID().uuidString)") let library = VPhoneLibrary(root: temporaryLibrary) let name = "pcc-\(UUID().uuidString.lowercased())" let vm = try VPhoneBundleOperations.create(.init( @@ -40,7 +40,16 @@ enum VPhonePCCGPURecovery { romSource: VPhoneBundleOperations.defaultROMSource(), sepromSource: VPhoneBundleOperations.defaultSEPROMSource(), ), in: library) - defer { try? fm.removeItem(at: temporaryLibrary) } + defer { + let diskImage = vm.url.appendingPathComponent("Disk.img") + if let info = try? run("/usr/bin/hdiutil", ["info"]), + !info.contains(diskImage.path) + { + try? fm.removeItem(at: temporaryLibrary) + } else { + fputs("warning: PCC disk image may still be attached; left \(temporaryLibrary.path)\n", stderr) + } + } // The restore backend accepts a linked directory. Reuse the already // extracted cloudOS tree instead of writing a second copy of its OS image. @@ -107,8 +116,34 @@ enum VPhonePCCGPURecovery { guard let baseDisk = attached.split(whereSeparator: \.isNewline).first? .split(whereSeparator: \.isWhitespace).first.map(String.init), baseDisk.hasPrefix("/dev/disk") - else { throw Error.toolFailed("hdiutil", "attached no disk device") } - defer { _ = try? run("/usr/bin/hdiutil", ["detach", baseDisk]) } + else { + if let range = attached.range(of: #"/dev/disk[0-9]+"#, options: .regularExpression) { + _ = try? run("/usr/bin/hdiutil", ["detach", "-force", String(attached[range])]) + } + throw Error.toolFailed("hdiutil", "attached no disk device") + } + var mountToClean: URL? + defer { + if (try? run("/usr/bin/hdiutil", ["detach", baseDisk])) == nil { + _ = try? run("/usr/bin/hdiutil", ["detach", "-force", baseDisk]) + } + if let mountToClean { + do { + guard let mounts = fm.mountedVolumeURLs( + includingResourceValuesForKeys: nil, options: [], + ) else { + throw Error.toolFailed("hdiutil", "could not verify detached volumes") + } + let root = mountToClean.resolvingSymlinksInPath().path + guard !mounts.contains(where: { $0.resolvingSymlinksInPath().path == root }) else { + throw Error.toolFailed("hdiutil", "PCC volume is still mounted") + } + try fm.removeItem(at: mountToClean) + } catch { + fputs("warning: left PCC mount directory at \(mountToClean.path): \(error)\n", stderr) + } + } + } let info = try run("/usr/sbin/diskutil", ["info", "-plist", "\(baseDisk)s1"]) guard let plist = try PropertyListSerialization.propertyList( @@ -121,10 +156,16 @@ enum VPhonePCCGPURecovery { let mount = restoreDirectory.deletingLastPathComponent() .appending(path: ".pcc-system-\(UUID().uuidString)") - try fm.createDirectory(at: mount, withIntermediateDirectories: true) - defer { try? fm.removeItem(at: mount) } + try fm.createDirectory(at: mount, withIntermediateDirectories: false) + mountToClean = mount + var mounted = false + defer { + if mounted, (try? run("/sbin/umount", [mount.path])) == nil { + _ = try? run("/sbin/umount", ["-f", mount.path]) + } + } try run("/sbin/mount_apfs", ["-o", "rdonly", "/dev/\(container)s1", mount.path]) - defer { _ = try? run("/sbin/umount", [mount.path]) } + mounted = true let source = mount.appending( path: "System/Library/Extensions/\(VPhonePCCGPUDriver.name)", diff --git a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/VirtualMachine/VPhoneVirtualMachineCommand.swift b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/VirtualMachine/VPhoneVirtualMachineCommand.swift index 5f9b63a..0892066 100644 --- a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/VirtualMachine/VPhoneVirtualMachineCommand.swift +++ b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/VirtualMachine/VPhoneVirtualMachineCommand.swift @@ -86,7 +86,7 @@ struct VPhoneVirtualMachineWriteManifestCommand: ParsableCommand { // MARK: - Shared options struct VPhoneLibraryOption: ParsableArguments { - @Option(name: [.customShort("l"), .long], help: "VM library root (default: ~/.vphone/VMs or $VPHONE_LIBRARY_ROOT)") + @Option(name: [.customShort("l"), .long], help: "VM library root (default: ~/.vphone/machines or $VPHONE_LIBRARY_ROOT)") var libraryRoot: String? var library: VPhoneLibrary { diff --git a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/VirtualMachine/VPhoneVirtualMachineCreator.swift b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/VirtualMachine/VPhoneVirtualMachineCreator.swift index 9ff9d51..f137092 100644 --- a/VPhoneExecutable/VPhoneCommand/VPhoneCommand/VirtualMachine/VPhoneVirtualMachineCreator.swift +++ b/VPhoneExecutable/VPhoneCommand/VPhoneCommand/VirtualMachine/VPhoneVirtualMachineCreator.swift @@ -99,7 +99,7 @@ public struct VPhoneVirtualMachineCreator { } let bundleURL = library.url(forName: options.name) - let ownedOutputs = [bundleURL, resources.ipswCacheDir, resources.sealVolumeCacheDir] + let ownedOutputs = [bundleURL] var ownershipRestored = false var permissionsRestored = false defer { @@ -225,12 +225,6 @@ public struct VPhoneVirtualMachineCreator { bundleURL: URL, verbosity v: VPhoneVerbosity, ) throws { - // In-process pipeline (no subprocess) — CryptexFilesystemPatcher's - // apfs_sealvolume read honors VPHONE_SEAL_DIR from *this* process's - // environment, so set it here to agree with `fw prepare`'s write. - try FileManager.default.createDirectory(at: resources.sealVolumeCacheDir, withIntermediateDirectories: true) - setenv("VPHONE_SEAL_DIR", resources.sealVolumeCacheDir.path, 1) - trace("in-process FirmwarePipeline.patchAll variant=jb", v) let pipeline = FirmwarePipeline( vmDirectory: bundleURL, diff --git a/VPhoneExecutable/VPhoneCommand/VPhoneRestore/VPhoneRestore/Restore/VPhoneRestoreService.swift b/VPhoneExecutable/VPhoneCommand/VPhoneRestore/VPhoneRestore/Restore/VPhoneRestoreService.swift index c442825..c8cab9b 100644 --- a/VPhoneExecutable/VPhoneCommand/VPhoneRestore/VPhoneRestore/Restore/VPhoneRestoreService.swift +++ b/VPhoneExecutable/VPhoneCommand/VPhoneRestore/VPhoneRestore/Restore/VPhoneRestoreService.swift @@ -54,9 +54,9 @@ public enum VPhoneRestoreService { // when a file of that name is already there ("SHSH '%s' already // present."), and a stale blob from a previous firmware would then be // what got copied out. - let cacheDirectory = FileManager.default.temporaryDirectory + let cacheDirectory = vmDir .appendingPathComponent("vphone-shsh-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: false) defer { try? FileManager.default.removeItem(at: cacheDirectory) } try VPhoneRestoreRunner.run( diff --git a/VPhoneKit/VPhoneArchiveKit/Transfer/VPhoneIPSWCache.swift b/VPhoneKit/VPhoneArchiveKit/Transfer/VPhoneIPSWCache.swift index 1843703..436df3d 100644 --- a/VPhoneKit/VPhoneArchiveKit/Transfer/VPhoneIPSWCache.swift +++ b/VPhoneKit/VPhoneArchiveKit/Transfer/VPhoneIPSWCache.swift @@ -60,12 +60,32 @@ public enum VPhoneIPSWCache { var request = URLRequest(url: url) request.timeoutInterval = 3 * 60 * 60 - let (downloaded, response) = try await session.download(for: request) - defer { try? fm.removeItem(at: downloaded) } + let (bytes, response) = try await session.bytes(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw Error.unexpectedHTTP(url, (response as? HTTPURLResponse)?.statusCode ?? 0) } - let size = try Int64(fm.attributesOfItem(atPath: downloaded.path)[.size] as? UInt64 ?? 0) + let pending = cacheDirectory.appendingPathComponent(".\(cache.lastPathComponent).\(UUID().uuidString).partial") + defer { try? fm.removeItem(at: pending) } + guard fm.createFile(atPath: pending.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + let output = try FileHandle(forWritingTo: pending) + defer { try? output.close() } + var buffer = Data() + var size: Int64 = 0 + for try await byte in bytes { + buffer.append(byte) + if buffer.count >= 1024 * 1024 { + try output.write(contentsOf: buffer) + size += Int64(buffer.count) + buffer.removeAll(keepingCapacity: true) + } + } + if !buffer.isEmpty { + try output.write(contentsOf: buffer) + size += Int64(buffer.count) + } + try output.close() if response.expectedContentLength > 0, size != response.expectedContentLength { throw Error.incompleteDownload( url, @@ -74,9 +94,6 @@ public enum VPhoneIPSWCache { ) } - let pending = cacheDirectory.appendingPathComponent(".\(cache.lastPathComponent).\(UUID().uuidString).partial") - defer { try? fm.removeItem(at: pending) } - try fm.moveItem(at: downloaded, to: pending) let metadata = try inspect(pending) try fm.moveItem(at: pending, to: cache) try VPhoneHostFilePermissions.makeAccessible(at: cache) diff --git a/VPhoneKit/VPhoneCoreKit/Firmware/VPhoneResources.swift b/VPhoneKit/VPhoneCoreKit/Firmware/VPhoneResources.swift index 872c7e9..84d1b95 100644 --- a/VPhoneKit/VPhoneCoreKit/Firmware/VPhoneResources.swift +++ b/VPhoneKit/VPhoneCoreKit/Firmware/VPhoneResources.swift @@ -114,9 +114,7 @@ public struct VPhoneResources: Sendable { // MARK: - Cache dirs - /// The per-user data root: `$VPHONE_ROOT` when set, else `~/.vphone`. Both - /// `VPhoneResources` (ipsws/tools) and `VPhoneLibrary` (VMs) derive - /// from this so one variable redirects everything vphone-cli creates. + /// The per-user VM library root: `$VPHONE_ROOT` when set, else `~/.vphone`. public static func userDataRoot() -> URL { if let root = ProcessInfo.processInfo.environment["VPHONE_ROOT"], !root.isEmpty { return URL(fileURLWithPath: root, isDirectory: true) @@ -125,14 +123,6 @@ public struct VPhoneResources: Sendable { return home.appendingPathComponent(".vphone") } - public var ipswCacheDir: URL { - Self.userDataRoot().appendingPathComponent("ipsws") - } - - public var sealVolumeCacheDir: URL { - Self.userDataRoot().appendingPathComponent("tools") - } - // MARK: - No interpreter // There is deliberately nothing here any more. diff --git a/VPhoneKit/VPhoneCoreKit/VirtualMachine/VPhoneLibrary.swift b/VPhoneKit/VPhoneCoreKit/VirtualMachine/VPhoneLibrary.swift index 8bc764e..e9cb9a0 100644 --- a/VPhoneKit/VPhoneCoreKit/VirtualMachine/VPhoneLibrary.swift +++ b/VPhoneKit/VPhoneCoreKit/VirtualMachine/VPhoneLibrary.swift @@ -44,11 +44,11 @@ public struct VPhoneLibrary: Sendable { 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 + // `~/.vphone/machines` — 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 VPhoneResources.userDataRoot() - .appendingPathComponent("VMs", isDirectory: true) + .appendingPathComponent("machines", isDirectory: true) } public func url(forName name: String) -> URL { diff --git a/VPhoneKit/VPhoneCoreKitTests/Firmware/ResourcesTests.swift b/VPhoneKit/VPhoneCoreKitTests/Firmware/ResourcesTests.swift index 1411790..24934f0 100644 --- a/VPhoneKit/VPhoneCoreKitTests/Firmware/ResourcesTests.swift +++ b/VPhoneKit/VPhoneCoreKitTests/Firmware/ResourcesTests.swift @@ -37,8 +37,8 @@ struct ResourcesTests { #expect(r.base.path == root.resolvingSymlinksInPath().path) } - @Test func `cache dirs are home relative`() { - // The VPHONE_ROOT override would relocate the cache; assert the default + @Test func `VM root is home relative`() { + // The VPHONE_ROOT override would relocate the library; assert the default // with the variable held clear, rather than bailing out when some other // suite happens to have set it — that skip was the old way of living // with the race `ProcessEnvironment` now closes. @@ -49,10 +49,7 @@ struct ResourcesTests { @Test func `user data root honors VPHONE root`() { ProcessEnvironment.withOverrides(["VPHONE_ROOT": "/tmp/vphone-test-root"]) { - let r = VPhoneResources(base: URL(fileURLWithPath: "/x")) #expect(VPhoneResources.userDataRoot().path == "/tmp/vphone-test-root") - #expect(r.ipswCacheDir.path == "/tmp/vphone-test-root/ipsws") - #expect(r.sealVolumeCacheDir.path == "/tmp/vphone-test-root/tools") } } @@ -60,7 +57,7 @@ struct ResourcesTests { /// scripts under `scriptsDir`, and nothing else — no `PATH` walk, no /// interpreter. That claim is what the deleted venv tests used to guard /// from the other side, so assert it directly: every URL this type hands - /// out is rooted in `base` or in the user data root. + /// out is rooted in `base`. @Test func `every resource is rooted in the base or the data root`() { ProcessEnvironment.withOverrides(["VPHONE_ROOT": "/tmp/vphone-test-root"]) { let base = URL(fileURLWithPath: "/x") @@ -72,9 +69,6 @@ struct ResourcesTests { for url in rooted { #expect(url.path.hasPrefix("/x/"), "\(url.path) escapes the resource base") } - for url in [r.ipswCacheDir, r.sealVolumeCacheDir] { - #expect(url.path.hasPrefix("/tmp/vphone-test-root/")) - } } } diff --git a/VPhoneKit/VPhoneCoreKitTests/VirtualMachine/LibraryTests.swift b/VPhoneKit/VPhoneCoreKitTests/VirtualMachine/LibraryTests.swift index 8f84156..c4ed7c6 100644 --- a/VPhoneKit/VPhoneCoreKitTests/VirtualMachine/LibraryTests.swift +++ b/VPhoneKit/VPhoneCoreKitTests/VirtualMachine/LibraryTests.swift @@ -64,7 +64,7 @@ struct LibraryTests { "VPHONE_LIBRARY_ROOT": nil, "VPHONE_ROOT": "/tmp/vphone-test-root", ]) { - #expect(VPhoneLibrary.defaultRoot().path == "/tmp/vphone-test-root/VMs") + #expect(VPhoneLibrary.defaultRoot().path == "/tmp/vphone-test-root/machines") } }