Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Sources/OmarchyAppleInstaller/OmarchyRemoval.swift
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,9 @@
_ plan: OmarchyRemovalPlan, record: (String) throws -> Void
) throws {
var removed = Set<String>()
// Delete the startup container first: if a volume is busy, diskutil refuses
// before the Linux partitions are touched. No force-unmount or -force fallback.
// Delete the startup container first, then the Linux partitions. diskutil
// force-unmounts what it deletes or erases, so review refuses a mounted
// EFI partition rather than let it be forced off.
for (index, member) in plan.members.enumerated() {
let current = try disks.snapshot()
try plan.validate(current, removed: removed)
Expand Down
14 changes: 10 additions & 4 deletions Sources/OmarchyAppleInstaller/OmarchyRemovalDiskOperator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,22 @@
stubBootObject: stub.1))
}

/// Reads an EFI partition in place when macOS already mounted it, otherwise
/// straight from its raw device. It must be the one the approved snapshot
/// names, by BSD identifier and UUID, before and after it is read.
/// Reads an EFI partition straight from its raw device; it is never
/// mounted. A mounted one is refused: `diskutil eraseVolume` would later
/// force it off, and on the M2 Max (macOS 27.0) that left FSKit's msdos
/// module holding the mount table, so every disk command hung. It must be
/// the one the approved snapshot names, by BSD identifier and UUID, before
/// and after it is read.
private func withEFIPartition<T>(
_ identifier: String, uuid expected: String, belongs: ([String: Any]) throws -> Bool,
read body: (any RemovalFileReading) throws -> T
) throws -> T {
let info = try reviewed(identifier, uuid: expected, belongs: belongs)
if let mounted = info["MountPoint"] as? String, !mounted.isEmpty {
return try body(openTree(mounted, identifier))
throw RemovalFailure(
message:
"\(identifier) is mounted at \(mounted), and removal doesn’t erase a mounted EFI partition. Restart your Mac, which leaves it unmounted, then try again."
)
}
let volume: any RemovalFileReading
do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,21 @@
}
}

func testOlderInstallIsPlannedEndToEndFromDiskutilAndFilesReadInPlace() throws {
func testOlderInstallIsPlannedEndToEndFromDiskutilWithTheStubReadInPlace() throws {
let model = F.alarm()
let fake = FakeDiskutil(model)
let esp = try temporaryDirectory()
let files = F.files(for: F.alarmInstall, in: model)
fake.espFiles = files
let stub = try temporaryDirectory()
defer {
try? FileManager.default.removeItem(at: esp)
try? FileManager.default.removeItem(at: stub)
}
try write(F.files(for: F.alarmInstall, in: model), esp: esp, stub: stub)
fake.mountPoints = ["disk0s4": esp.path, "disk4s2": stub.path]
defer { try? FileManager.default.removeItem(at: stub) }
try write(files, esp: nil, stub: stub)
fake.mountPoints = ["disk4s2": stub.path]
let plan = try OmarchyRemovalPlan(disks: fake.makeOperator())
XCTAssertEqual(plan.installation?.name, "Asahi Alarm Minimal")
XCTAssertEqual(plan.reclaimBytes, 32_800_505_856)
XCTAssertTrue(
fake.log.allSatisfy {
$0[0] == "info" || $0[0] == "list"
$0[0] == "info" || $0[0] == "list" || $0[0] == "openFAT"
|| ($0[0] == "apfs" && ["list", "listVolumeGroups"].contains($0[1]))
}, "\(fake.log)")
}
Expand Down Expand Up @@ -79,6 +77,52 @@
XCTAssertEqual(fake.log[opened + 1], ["info", "-plist", "disk0s4"], "and after it is read")
}

/// On the M2 Max (macOS 27.0, 2026-10-06) the EFI partition was still
/// mounted through FSKit's msdos module from a backup. Removal read it in
/// place, then `diskutil eraseVolume` force-unmounted it, the module lost
/// its device mid-flush (EIO), and `mount` and `diskutil list` hung until
/// fskit_agent was killed. A mounted EFI partition is now refused before
/// anything is erased, at review and again when removal starts.
func testMountedEFIPartitionIsRefusedBeforeAnythingIsErased() throws {
let model = F.alarm()
let files = F.files(for: F.alarmInstall, in: model)
let esp = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: esp) }
try write(files, esp: esp, stub: nil)
func makeFake() -> FakeDiskutil {
let fake = FakeDiskutil(model)
fake.espFiles = files
fake.onMount = { _, path in
try self.write(files, esp: nil, stub: URL(fileURLWithPath: path))
}
return fake
}
func assertRefused(_ error: any Error, _ fake: FakeDiskutil) {
let message = (error as? RemovalFailure)?.message ?? ""
XCTAssertTrue(message.contains("disk0s4 is mounted at \(esp.path)"), message)
XCTAssertTrue(message.contains("Restart your Mac"), message)
XCTAssertFalse(
fake.log.contains {
$0[0] == "openFAT" || $0.contains("deleteContainer")
|| ($0.last == "disk0s4" && $0[0] != "info")
}, "only diskutil info touched disk0s4 and nothing was deleted: \(fake.log)")
}

let atReview = makeFake()
atReview.mountPoints = ["disk0s4": esp.path]
XCTAssertThrowsError(try OmarchyRemovalPlan(disks: atReview.makeOperator())) {
assertRefused($0, atReview)
}

let afterReview = makeFake()
let plan = try OmarchyRemovalPlan(disks: afterReview.makeOperator())
afterReview.mountPoints["disk0s4"] = esp.path
afterReview.log.removeAll()
XCTAssertThrowsError(
try OmarchyRemovalExecutor(disks: afterReview.makeOperator()).execute(plan) { _ in }
) { assertRefused($0, afterReview) }
}

func testUnreadableEFIPartitionOrStubRefusesWithoutMountingTheEFIPartition() throws {
let model = F.alarm()
let files = F.files(for: F.alarmInstall, in: model)
Expand Down
Loading