Files
vphone-cli/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/CryptexFilesystem/CryptexFilesystemPatcherDiskImage.swift
T

228 lines
9.1 KiB
Swift

// CryptexFilesystemPatcherDiskImage.swift — Disk image attach, convert and copy helpers.
//
// Split out of CryptexFilesystemPatcher.swift. The hdiutil/diskutil layer: attaching and
// detaching images, converting between RAW and UDRW, resizing, unmounting, and the
// copyfile-based volume copy used to merge a cryptex into the target volume.
import Foundation
extension CryptexFilesystemPatcher {
func copyImageContents(source: URL, destination: URL) throws {
// Copy everything from source volume root into destination volume root.
let sourceRoot = source.appendingPathComponent("", isDirectory: true)
let sourcePath = sourceRoot.path
let destinationRoot = destination.appendingPathComponent("", isDirectory: true)
// We delete the files first as we want to replace symlinks with actual files.
let keys: [URLResourceKey] = [.isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey]
guard let enumerator = FileManager.default.enumerator(at: sourceRoot, includingPropertiesForKeys: keys) else {
throw FirmwareManifest.ManifestError.fileNotFound(
"Unable to read \(sourceRoot.path). Check that the image is still mounted, then try again.",
)
}
for case let fileURL as URL in enumerator {
guard fileURL.path.hasPrefix(sourcePath) else { continue }
let values = try fileURL.resourceValues(forKeys: Set(keys))
var suffix = String(fileURL.path.dropFirst(sourcePath.count))
if suffix.hasPrefix("/") {
suffix.removeFirst()
}
let destinationPath = destinationRoot.appendingPathComponent(suffix)
guard let ok = try? destinationPath.checkResourceIsReachable(), ok else {
// try FileManager.default.copyItem(at: fileURL, to: destinationPath)
let result = copyfile(
fileURL.path,
destinationPath.path,
nil,
copyfile_flags_t(COPYFILE_SECURITY | COPYFILE_DATA),
)
if result < 0 {
print("Unable to copy \(destinationPath.path). Check permissions and free space, then try again.")
}
continue
}
let vals = try destinationPath.resourceValues(forKeys: Set(keys))
if values.isDirectory != vals.isDirectory ||
values.isRegularFile != vals.isRegularFile ||
values.isSymbolicLink != vals.isSymbolicLink
{
try FileManager.default.removeItem(at: destinationPath)
// try FileManager.default.copyItem(at: fileURL, to: destinationPath)
let result = copyfile(
fileURL.path,
destinationPath.path,
nil,
copyfile_flags_t(COPYFILE_SECURITY | COPYFILE_DATA),
)
if result < 0 {
print("Unable to copy \(destinationPath.path). Check permissions and free space, then try again.")
}
}
}
}
func convertToRawImage(input: URL, output: URL) throws {
_ = try runProcess("/usr/sbin/diskutil", [
"image", "create", "from",
"--format", "RAW", input.path,
output.path,
])
// Resize to max. Asking diskutil how big it may get returns a plist, and
// reading one value out of it used to be `/bin/sh -c "… | plutil -extract
// max raw -o - -"`: a shell and a second tool to parse what this process
// can read directly — and a pipeline whose stdout is also where diskutil's
// own warnings land, so a noisy run produced a "size" that was a sentence.
let sizes = try runProcess("/usr/sbin/diskutil", [
"image", "resize", "--plist", output.path,
])
let maxsize = try Self.maxResizeSize(fromDiskutilPlist: sizes)
_ = try runProcess("/usr/sbin/diskutil", [
"image", "resize", "--size", maxsize, output.path,
])
}
/// The `max` value out of `diskutil image resize --plist`, as the string
/// `--size` wants back.
///
/// `runProcess` merges stderr into stdout, so the plist may arrive with a
/// line of diskutil's own in front of it; the parse starts at the XML
/// declaration rather than assuming the first byte is one.
static func maxResizeSize(fromDiskutilPlist output: String) throws -> String {
let body = output.range(of: "<?xml").map { String(output[$0.lowerBound...]) } ?? output
guard let data = body.data(using: .utf8),
let root = try? PropertyListSerialization.propertyList(
from: data,
options: [],
format: nil,
) as? [String: Any]
else {
throw ProcessError.failed(0, "diskutil did not return a plist:\n\(output)")
}
switch root["max"] {
case let number as NSNumber: return number.stringValue
case let string as String: return string
default:
throw ProcessError.failed(0, "no 'max' size in diskutil's plist:\n\(output)")
}
}
func convertToUDRWImage(input: URL, output: URL) throws {
if FileManager.default.fileExists(atPath: output.path) {
try FileManager.default.removeItem(at: output)
}
_ = try runProcess("/usr/bin/hdiutil", [
"convert",
input.path,
"-format", "UDRW",
"-o", output.path,
])
}
func shrinkImage(dmg: URL) throws {
_ = try runProcess("/usr/sbin/diskutil", [
"image",
"resize",
"--size", "min",
dmg.path,
])
}
func unmount(mount: String) throws {
_ = try runProcess("/usr/sbin/diskutil", ["unmount", mount])
}
/// attachImage returns the device and mount point
func attachImage(path: URL, readonly: Bool = false, forceRW: Bool = false) throws -> (String, String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil")
process.arguments = if readonly {
[
"attach",
"-readonly",
"-plist",
path.path,
]
} else {
[
"attach",
"-plist",
path.path,
]
}
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard process.terminationStatus == 0 else {
let output = String(data: data, encoding: .utf8) ?? ""
throw ProcessError.failed(process.terminationStatus, output)
}
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 {
guard let entry = entry as? PlistDict,
let volumeKind = entry["volume-kind"] as? String,
volumeKind == "apfs" || volumeKind == "hfs"
else {
continue
}
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 {
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 {
do {
_ = try runProcess("/usr/bin/hdiutil", ["detach", deviceNode])
} catch {
_ = try runProcess("/usr/bin/hdiutil", ["detach", "-force", deviceNode])
}
attachedDevices.remove(deviceNode)
}
}