test: extend UPDATE-SELFTEST — reject-unsigned, staged-signed, atomic-install, revert
Same fake-99.0.0-bundle setup as before, but now drives it through the
hardened UpdateChecker end to end, in-process:
(a) reject-unsigned — the fake bundle, copied then plist-edited without
re-signing (editing Info.plist after copy invalidates the inherited
signature on its own — nothing stripped by hand), must be rejected by
checkNow(): updateAvailable stays nil and statusMessage is the exact
"Update is not signed by MMD" text.
(b) staged-signed — codesign --force --deep --sign the same bundle
(SHOTDECK_SELFTEST_SIGN_IDENTITY or the default Apple Development
identity), re-zip, re-serve the same appcast path; must now stage.
(c) atomic-install — installStaged into a throwaway <tmp>/Applications
(never real /Applications) pre-populated with a copy of the actually
running app; asserts the target lands on 99.0.0, Redline.app.previous
holds the original version, and no replacement-directory cruft is left
beside them.
(d) revert — revertToPrevious swaps the rollback copy back in; asserts the
target is back to the original version and .previous now holds 99.0.0.
Caught a real bug while wiring (d): replaceItemAt(target, withItemAt:
previousURL, backupItemName: "Redline.app.previous") self-clobbers, because
the backup name and the withItemAt source resolve to the same path — the
backup write lands before the swap ever reads it, so target ends up
unchanged. Fixed in UpdateChecker by staging previousURL through a throwaway
ditto copy first (same pattern installStaged already used).
Every existing phase (PICKER-SELFTEST, REGION-PERSIST, SEND-TRUTH, and the
final "UPDATE-SELFTEST PASS version=99.0.0") is unchanged and still prints.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -325,6 +325,9 @@ enum PickerSelfTest {
|
|||||||
return true
|
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 {
|
private static func runUpdateSelfTest(outputDirectory: URL) async throws {
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
|
try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
|
||||||
@@ -332,6 +335,9 @@ enum PickerSelfTest {
|
|||||||
guard let sourceApp = ownAppBundleURL() else {
|
guard let sourceApp = ownAppBundleURL() else {
|
||||||
throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))")
|
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)
|
let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true)
|
||||||
if fm.fileExists(atPath: payload.path) {
|
if fm.fileExists(atPath: payload.path) {
|
||||||
@@ -349,17 +355,20 @@ enum PickerSelfTest {
|
|||||||
plist["CFBundleShortVersionString"] = "99.0.0"
|
plist["CFBundleShortVersionString"] = "99.0.0"
|
||||||
let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
try rewritten.write(to: plistURL)
|
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")
|
let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip")
|
||||||
|
let appcastURL = outputDirectory.appendingPathComponent("appcast.json")
|
||||||
|
|
||||||
|
func writeZipAndAppcast() throws {
|
||||||
if fm.fileExists(atPath: zipURL.path) {
|
if fm.fileExists(atPath: zipURL.path) {
|
||||||
try fm.removeItem(at: zipURL)
|
try fm.removeItem(at: zipURL)
|
||||||
}
|
}
|
||||||
try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path])
|
try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path])
|
||||||
|
|
||||||
let zipData = try Data(contentsOf: zipURL)
|
let zipData = try Data(contentsOf: zipURL)
|
||||||
let hex = UpdateChecker.sha256Hex(zipData)
|
let hex = UpdateChecker.sha256Hex(zipData)
|
||||||
|
|
||||||
let appcastURL = outputDirectory.appendingPathComponent("appcast.json")
|
|
||||||
let appcast: [String: String] = [
|
let appcast: [String: String] = [
|
||||||
"version": "99.0.0",
|
"version": "99.0.0",
|
||||||
"zipURL": zipURL.absoluteString,
|
"zipURL": zipURL.absoluteString,
|
||||||
@@ -368,13 +377,15 @@ enum PickerSelfTest {
|
|||||||
]
|
]
|
||||||
let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys])
|
let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys])
|
||||||
try appcastData.write(to: appcastURL)
|
try appcastData.write(to: appcastURL)
|
||||||
|
}
|
||||||
|
try writeZipAndAppcast()
|
||||||
|
|
||||||
let defaults = UserDefaults.standard
|
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)
|
defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey)
|
||||||
defer {
|
defer {
|
||||||
if let previous {
|
if let previousAppcastPref {
|
||||||
defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey)
|
defaults.set(previousAppcastPref, forKey: UpdateChecker.appcastURLDefaultsKey)
|
||||||
} else {
|
} else {
|
||||||
defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey)
|
defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey)
|
||||||
}
|
}
|
||||||
@@ -383,39 +394,128 @@ enum PickerSelfTest {
|
|||||||
let (model, isolatedRoot) = try makeIsolatedUpdateModel()
|
let (model, isolatedRoot) = try makeIsolatedUpdateModel()
|
||||||
defer { try? fm.removeItem(at: isolatedRoot) }
|
defer { try? fm.removeItem(at: isolatedRoot) }
|
||||||
|
|
||||||
|
// (a) NEGATIVE — invalidly-signed payload must never be offered or staged.
|
||||||
await model.updateChecker.checkNow()
|
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 {
|
guard model.updateAvailable?.version == "99.0.0" else {
|
||||||
throw UpdateSelfTestError.detail(
|
throw UpdateSelfTestError.detail(
|
||||||
"updateAvailable=\(model.updateAvailable?.version ?? "nil")"
|
"staged-signed: updateAvailable=\(model.updateAvailable?.version ?? "nil")"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
guard let staged = model.updateChecker.stagedAppURL else {
|
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 {
|
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")
|
let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck")
|
||||||
guard fm.fileExists(atPath: stagedExe.path) else {
|
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)
|
// (c) ATOMIC INSTALL — a throwaway target pre-populated with the real
|
||||||
if fm.fileExists(atPath: targetRoot.path) {
|
// running version; never `/Applications`.
|
||||||
try fm.removeItem(at: targetRoot)
|
let tempAppsRoot = outputDirectory.appendingPathComponent("Applications", isDirectory: true)
|
||||||
|
if fm.fileExists(atPath: tempAppsRoot.path) {
|
||||||
|
try fm.removeItem(at: tempAppsRoot)
|
||||||
}
|
}
|
||||||
let target = targetRoot.appendingPathComponent("Redline.app")
|
try fm.createDirectory(at: tempAppsRoot, withIntermediateDirectories: true)
|
||||||
model.updateChecker.installStaged(to: target)
|
let tempTarget = tempAppsRoot.appendingPathComponent("Redline.app")
|
||||||
|
try fm.copyItem(at: sourceApp, to: tempTarget)
|
||||||
|
|
||||||
let installedPlist = target.appendingPathComponent("Contents/Info.plist")
|
model.updateChecker.installStaged(to: tempTarget)
|
||||||
guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any],
|
|
||||||
let installedVersion = installed["CFBundleShortVersionString"] as? String
|
guard let installedVersion = readShortVersion(atAppURL: tempTarget) else {
|
||||||
else {
|
throw UpdateSelfTestError.detail("atomic-install: installed Info.plist unreadable")
|
||||||
throw UpdateSelfTestError.detail("installed Info.plist unreadable")
|
|
||||||
}
|
}
|
||||||
guard installedVersion == "99.0.0" else {
|
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<String>) 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: ", "))"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user