diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift index b30c703..aa5de7c 100644 --- a/Sources/Shotdeck/PickerSelfTest.swift +++ b/Sources/Shotdeck/PickerSelfTest.swift @@ -325,6 +325,9 @@ enum PickerSelfTest { return true } + /// (a) rejects an invalidly-signed payload, (b) stages the same payload once + /// properly signed, (c) installs it atomically into a throwaway target with + /// exactly one rollback copy, (d) reverts back. Never touches `/Applications`. private static func runUpdateSelfTest(outputDirectory: URL) async throws { let fm = FileManager.default try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true) @@ -332,6 +335,9 @@ enum PickerSelfTest { guard let sourceApp = ownAppBundleURL() else { throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))") } + guard let originalVersion = readShortVersion(atAppURL: sourceApp) else { + throw UpdateSelfTestError.detail("own Info.plist has no CFBundleShortVersionString") + } let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true) if fm.fileExists(atPath: payload.path) { @@ -349,32 +355,37 @@ enum PickerSelfTest { plist["CFBundleShortVersionString"] = "99.0.0" let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) try rewritten.write(to: plistURL) + // Editing Info.plist after copying it invalidates the inherited signature — + // Info.plist is a sealed special slot in the CodeDirectory — so this fake + // bundle is genuinely unsigned-in-effect without us stripping anything. let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip") - if fm.fileExists(atPath: zipURL.path) { - try fm.removeItem(at: zipURL) - } - try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path]) - - let zipData = try Data(contentsOf: zipURL) - let hex = UpdateChecker.sha256Hex(zipData) - let appcastURL = outputDirectory.appendingPathComponent("appcast.json") - let appcast: [String: String] = [ - "version": "99.0.0", - "zipURL": zipURL.absoluteString, - "sha256": hex, - "notes": "UPDATE-SELFTEST", - ] - let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys]) - try appcastData.write(to: appcastURL) + + func writeZipAndAppcast() throws { + if fm.fileExists(atPath: zipURL.path) { + try fm.removeItem(at: zipURL) + } + try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path]) + let zipData = try Data(contentsOf: zipURL) + let hex = UpdateChecker.sha256Hex(zipData) + let appcast: [String: String] = [ + "version": "99.0.0", + "zipURL": zipURL.absoluteString, + "sha256": hex, + "notes": "UPDATE-SELFTEST", + ] + let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys]) + try appcastData.write(to: appcastURL) + } + try writeZipAndAppcast() let defaults = UserDefaults.standard - let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey) + let previousAppcastPref = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey) defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey) defer { - if let previous { - defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey) + if let previousAppcastPref { + defaults.set(previousAppcastPref, forKey: UpdateChecker.appcastURLDefaultsKey) } else { defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey) } @@ -383,39 +394,128 @@ enum PickerSelfTest { let (model, isolatedRoot) = try makeIsolatedUpdateModel() defer { try? fm.removeItem(at: isolatedRoot) } + // (a) NEGATIVE — invalidly-signed payload must never be offered or staged. await model.updateChecker.checkNow() + guard model.updateAvailable == nil else { + throw UpdateSelfTestError.detail( + "reject-unsigned: updateAvailable=\(model.updateAvailable?.version ?? "nil") (expected nil)" + ) + } + guard model.updateChecker.statusMessage == "Update is not signed by MMD — not installed." else { + throw UpdateSelfTestError.detail( + "reject-unsigned: statusMessage=\(model.updateChecker.statusMessage ?? "nil")" + ) + } + print("UPDATE-SELFTEST reject-unsigned PASS") + fflush(stdout) + // (b) POSITIVE — re-sign the same bundle, re-zip, re-serve; must now stage. + let signIdentity = ProcessInfo.processInfo.environment["SHOTDECK_SELFTEST_SIGN_IDENTITY"] + ?? "Apple Development: ben@flow-master.ai (QH2H9G2LK5)" + try runCodesign(identity: signIdentity, path: fakeApp.path) + try writeZipAndAppcast() + + await model.updateChecker.checkNow() guard model.updateAvailable?.version == "99.0.0" else { throw UpdateSelfTestError.detail( - "updateAvailable=\(model.updateAvailable?.version ?? "nil")" + "staged-signed: updateAvailable=\(model.updateAvailable?.version ?? "nil")" ) } guard let staged = model.updateChecker.stagedAppURL else { - throw UpdateSelfTestError.detail("staged payload missing") + throw UpdateSelfTestError.detail("staged-signed: staged payload missing") } guard staged.lastPathComponent == "Redline.app" else { - throw UpdateSelfTestError.detail("staged name \(staged.lastPathComponent)") + throw UpdateSelfTestError.detail("staged-signed: staged name \(staged.lastPathComponent)") } let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck") guard fm.fileExists(atPath: stagedExe.path) else { - throw UpdateSelfTestError.detail("staged Contents/MacOS/Shotdeck missing") + throw UpdateSelfTestError.detail("staged-signed: staged Contents/MacOS/Shotdeck missing") } + print("UPDATE-SELFTEST staged-signed PASS") + fflush(stdout) - let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true) - if fm.fileExists(atPath: targetRoot.path) { - try fm.removeItem(at: targetRoot) + // (c) ATOMIC INSTALL — a throwaway target pre-populated with the real + // running version; never `/Applications`. + let tempAppsRoot = outputDirectory.appendingPathComponent("Applications", isDirectory: true) + if fm.fileExists(atPath: tempAppsRoot.path) { + try fm.removeItem(at: tempAppsRoot) } - let target = targetRoot.appendingPathComponent("Redline.app") - model.updateChecker.installStaged(to: target) + try fm.createDirectory(at: tempAppsRoot, withIntermediateDirectories: true) + let tempTarget = tempAppsRoot.appendingPathComponent("Redline.app") + try fm.copyItem(at: sourceApp, to: tempTarget) - let installedPlist = target.appendingPathComponent("Contents/Info.plist") - guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any], - let installedVersion = installed["CFBundleShortVersionString"] as? String - else { - throw UpdateSelfTestError.detail("installed Info.plist unreadable") + model.updateChecker.installStaged(to: tempTarget) + + guard let installedVersion = readShortVersion(atAppURL: tempTarget) else { + throw UpdateSelfTestError.detail("atomic-install: installed Info.plist unreadable") } guard installedVersion == "99.0.0" else { - throw UpdateSelfTestError.detail("installed version \(installedVersion)") + throw UpdateSelfTestError.detail("atomic-install: installed version \(installedVersion)") + } + let previousCopy = tempAppsRoot.appendingPathComponent("Redline.app.previous") + guard let previousVersionAfterInstall = readShortVersion(atAppURL: previousCopy) else { + throw UpdateSelfTestError.detail("atomic-install: Redline.app.previous missing or unreadable") + } + guard previousVersionAfterInstall == originalVersion else { + throw UpdateSelfTestError.detail( + "atomic-install: previous version=\(previousVersionAfterInstall) expected=\(originalVersion)" + ) + } + try assertNoLeftoverEntries(in: tempAppsRoot, expecting: ["Redline.app", "Redline.app.previous"]) + print("UPDATE-SELFTEST atomic-install PASS") + fflush(stdout) + + // (d) REVERT — the rollback copy swaps back in; the just-replaced version + // becomes the new rollback copy, so a revert is itself reversible. + model.updateChecker.revertToPrevious(target: tempTarget) + + guard let revertedVersion = readShortVersion(atAppURL: tempTarget) else { + throw UpdateSelfTestError.detail("revert: reverted Info.plist unreadable") + } + guard revertedVersion == originalVersion else { + throw UpdateSelfTestError.detail("revert: target version=\(revertedVersion) expected=\(originalVersion)") + } + guard let previousVersionAfterRevert = readShortVersion(atAppURL: previousCopy) else { + throw UpdateSelfTestError.detail("revert: Redline.app.previous missing or unreadable") + } + guard previousVersionAfterRevert == "99.0.0" else { + throw UpdateSelfTestError.detail( + "revert: previous version=\(previousVersionAfterRevert) expected=99.0.0" + ) + } + try assertNoLeftoverEntries(in: tempAppsRoot, expecting: ["Redline.app", "Redline.app.previous"]) + print("UPDATE-SELFTEST revert PASS") + fflush(stdout) + } + + private static func readShortVersion(atAppURL url: URL) -> String? { + let plistURL = url.appendingPathComponent("Contents/Info.plist") + guard let dict = NSDictionary(contentsOf: plistURL) as? [String: Any] else { return nil } + return dict["CFBundleShortVersionString"] as? String + } + + private static func runCodesign(identity: String, path: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + process.arguments = ["--force", "--deep", "--sign", identity, path] + let err = Pipe() + process.standardError = err + process.standardOutput = Pipe() + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw UpdateSelfTestError.detail("codesign failed: \(message)") + } + } + + private static func assertNoLeftoverEntries(in directory: URL, expecting expected: Set) throws { + let entries = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] + let unexpected = entries.filter { !expected.contains($0) } + guard unexpected.isEmpty else { + throw UpdateSelfTestError.detail( + "unexpected entries in \(directory.path): \(unexpected.joined(separator: ", "))" + ) } }