bleam 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,9 +13,11 @@ private struct DownloadRequest: Decodable {
13
13
  let serviceURL: URL
14
14
  let bundleIdentifier: String
15
15
  let teamIdentifier: String
16
+ let platformVersion: String
17
+ let platformFingerprint: String
16
18
  }
17
19
 
18
- private struct ApplyRequest: Decodable {
20
+ private struct ApplyRequest: Codable {
19
21
  let releaseId: String
20
22
  let bundleIdentifier: String
21
23
  let teamIdentifier: String
@@ -23,6 +25,30 @@ private struct ApplyRequest: Decodable {
23
25
  let appPID: Int32
24
26
  }
25
27
 
28
+ private struct ApplyTransaction: Codable {
29
+ enum Stage: String, Codable {
30
+ case accepted, waitingForExit, verifying, replacing, failed
31
+ }
32
+
33
+ let request: ApplyRequest
34
+ var stage: Stage
35
+ var updatedAt: Date
36
+ var failure: String?
37
+ }
38
+
39
+ private struct VerifiedReceipt: Codable {
40
+ let releaseId: String
41
+ let bundleIdentifier: String
42
+ let teamIdentifier: String
43
+ let appVersion: String
44
+ let buildNumber: String
45
+ let platformVersion: String
46
+ let platformFingerprint: String
47
+ let executable: String
48
+ let architecture: String
49
+ let appPath: String
50
+ }
51
+
26
52
  private struct PlatformRelease: Decodable {
27
53
  struct Artifact: Decodable {
28
54
  let format: String
@@ -38,6 +64,8 @@ private struct PlatformRelease: Decodable {
38
64
  let appVersion: String
39
65
  let buildNumber: String
40
66
  let bleamPlatformVersion: String
67
+ let platformFingerprint: String
68
+ let redeploy: Bool?
41
69
  let target: Target
42
70
  let artifact: Artifact
43
71
 
@@ -46,13 +74,27 @@ private struct PlatformRelease: Decodable {
46
74
  }
47
75
  }
48
76
 
49
- private final class PlatformHelper: NSObject, PlatformHelperProtocol {
77
+ private final class PlatformHelper: NSObject {
78
+ private let bundleIdentifier: String
79
+ private let teamIdentifier: String
80
+ private let applyQueue = DispatchQueue(label: "dev.bleam.platform-helper.apply", qos: .userInitiated)
81
+
82
+ init(bundleIdentifier: String, teamIdentifier: String) {
83
+ self.bundleIdentifier = bundleIdentifier
84
+ self.teamIdentifier = teamIdentifier
85
+ super.init()
86
+ applyQueue.async { [weak self] in
87
+ self?.resumePendingApplies()
88
+ }
89
+ }
90
+
50
91
  func health(reply: @escaping (Data?) -> Void) {
92
+ let environment = ProcessInfo.processInfo.environment
51
93
  let response: [String: Any] = [
52
94
  "healthy": true,
53
95
  "protocolVersion": 1,
54
- "helperVersion": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown",
55
- "platformVersion": Bundle.main.object(forInfoDictionaryKey: "BleamPlatformVersion") as? String ?? "unknown",
96
+ "helperVersion": environment["BLEAM_HELPER_VERSION"] ?? "unknown",
97
+ "platformVersion": environment["BLEAM_PLATFORM_VERSION"] ?? "unknown",
56
98
  "capabilities": ["platform-updates"],
57
99
  ]
58
100
  reply(try? JSONSerialization.data(withJSONObject: response))
@@ -61,6 +103,8 @@ private final class PlatformHelper: NSObject, PlatformHelperProtocol {
61
103
  func downloadPlatformUpdate(requestData: Data, reply: @escaping (Data?) -> Void) {
62
104
  guard
63
105
  let request = try? JSONDecoder().decode(DownloadRequest.self, from: requestData),
106
+ request.bundleIdentifier == bundleIdentifier,
107
+ request.teamIdentifier == teamIdentifier,
64
108
  let resolverURL = URL(
65
109
  string: "/v1/apps/\(request.bundleIdentifier)/platform/macos-arm64",
66
110
  relativeTo: request.serviceURL
@@ -78,6 +122,8 @@ private final class PlatformHelper: NSObject, PlatformHelperProtocol {
78
122
  release.kind == "platform",
79
123
  release.bundleIdentifier == request.bundleIdentifier,
80
124
  release.teamIdentifier == request.teamIdentifier,
125
+ release.bleamPlatformVersion.compare(request.platformVersion, options: .numeric) != .orderedAscending,
126
+ release.platformFingerprint != request.platformFingerprint || release.redeploy == true,
81
127
  release.artifact.format == "bleam-macos-app-zip-v1",
82
128
  release.releaseId.range(of: "^[a-z][a-z0-9_-]{2,127}$", options: .regularExpression) != nil
83
129
  else {
@@ -119,26 +165,21 @@ private final class PlatformHelper: NSObject, PlatformHelperProtocol {
119
165
  "appVersion": release.appVersion,
120
166
  "buildNumber": release.buildNumber,
121
167
  "platformVersion": release.bleamPlatformVersion,
168
+ "platformFingerprint": release.platformFingerprint,
169
+ "redeploy": release.redeploy == true,
122
170
  "artifactPath": artifactURL.path,
123
171
  "appPath": appURL.path,
124
172
  "sizeBytes": release.artifact.sizeBytes,
125
173
  "sha256": release.artifact.sha256,
126
174
  "verified": true,
127
175
  ]
128
- let receipt: [String: Any] = [
129
- "releaseId": release.releaseId,
130
- "bundleIdentifier": request.bundleIdentifier,
131
- "teamIdentifier": request.teamIdentifier,
132
- "appPath": appURL.path,
133
- ]
134
- let receiptData = try PropertyListSerialization.data(
135
- fromPropertyList: receipt,
136
- format: .binary,
137
- options: 0
176
+ let receiptData = try PropertyListEncoder().encode(
177
+ self.verifiedReceipt(appURL: appURL, release: release, request: request)
138
178
  )
139
179
  try receiptData.write(to: cache.appendingPathComponent("verified.plist"), options: .atomic)
140
180
  reply(try? JSONSerialization.data(withJSONObject: result))
141
181
  } catch {
182
+ NSLog("Bleam platform download failed: %@", String(describing: error))
142
183
  reply(try? JSONSerialization.data(withJSONObject: [
143
184
  "error": String(describing: error),
144
185
  ]))
@@ -147,62 +188,248 @@ private final class PlatformHelper: NSObject, PlatformHelperProtocol {
147
188
  }.resume()
148
189
  }
149
190
 
150
- func applyPlatformUpdate(requestData: Data, reply: @escaping (Bool) -> Void) {
191
+ func applyPlatformUpdate(
192
+ requestData: Data,
193
+ clientProcessIdentifier: pid_t,
194
+ reply: @escaping (Bool) -> Void
195
+ ) {
196
+ guard let request = try? JSONDecoder().decode(ApplyRequest.self, from: requestData) else {
197
+ NSLog("Bleam platform apply rejected: invalid request")
198
+ reply(false)
199
+ return
200
+ }
201
+ guard
202
+ request.bundleIdentifier == bundleIdentifier,
203
+ request.teamIdentifier == teamIdentifier,
204
+ clientProcessIdentifier == request.appPID,
205
+ validReleaseId(request.releaseId)
206
+ else {
207
+ NSLog("Bleam platform apply rejected: request identity mismatch")
208
+ reply(false)
209
+ return
210
+ }
151
211
  guard
152
- let request = try? JSONDecoder().decode(ApplyRequest.self, from: requestData),
153
212
  let installedURL = supportedInstalledURL(path: request.installedAppPath),
154
- let candidateURL = verifiedCandidate(request: request)
213
+ (try? validateInstalledBundle(at: installedURL)) != nil
155
214
  else {
215
+ NSLog("Bleam platform apply rejected: installed app identity mismatch")
216
+ reply(false)
217
+ return
218
+ }
219
+ guard
220
+ let candidate = verifiedCandidate(request: request),
221
+ let destinationURL = replacementURL(installedURL: installedURL, candidateURL: candidate.appURL),
222
+ destinationURL == installedURL || !FileManager.default.fileExists(atPath: destinationURL.path)
223
+ else {
224
+ NSLog("Bleam platform apply rejected: verified candidate unavailable")
225
+ reply(false)
226
+ return
227
+ }
228
+ guard acceptTransaction(request) else {
229
+ NSLog("Bleam platform apply rejected: transaction could not be persisted")
156
230
  reply(false)
157
231
  return
158
232
  }
159
-
160
233
  reply(true)
234
+ applyQueue.async { [weak self] in
235
+ self?.performApply(request)
236
+ }
237
+ }
238
+
239
+ private func resumePendingApplies() {
240
+ let receipts = receiptsURL(bundleIdentifier: bundleIdentifier)
241
+ guard let files = try? FileManager.default.contentsOfDirectory(
242
+ at: receipts,
243
+ includingPropertiesForKeys: nil
244
+ ) else {
245
+ clearPendingMarkerIfIdle()
246
+ return
247
+ }
248
+ for file in files where file.pathExtension == "applying" {
249
+ guard
250
+ let data = try? Data(contentsOf: file),
251
+ let transaction = try? PropertyListDecoder().decode(ApplyTransaction.self, from: data),
252
+ transaction.request.bundleIdentifier == bundleIdentifier,
253
+ validReleaseId(transaction.request.releaseId),
254
+ transaction.stage != .failed
255
+ else { continue }
256
+ performApply(transaction.request)
257
+ }
258
+ clearPendingMarkerIfIdle()
259
+ }
260
+
261
+ private func performApply(_ request: ApplyRequest) {
262
+ let completionURL = receiptsURL(bundleIdentifier: request.bundleIdentifier)
263
+ .appendingPathComponent("\(request.releaseId).complete")
264
+ if FileManager.default.fileExists(atPath: completionURL.path) {
265
+ try? FileManager.default.removeItem(at: transactionURL(for: request))
266
+ clearPendingMarkerIfIdle()
267
+ return
268
+ }
269
+
161
270
  let activity = ProcessInfo.processInfo.beginActivity(
162
- options: [.userInitiated, .idleSystemSleepDisabled, .suddenTerminationDisabled],
271
+ options: [.userInitiated, .idleSystemSleepDisabled],
163
272
  reason: "Applying a verified Bleam platform update"
164
273
  )
165
- DispatchQueue.global(qos: .userInitiated).async {
166
- defer { ProcessInfo.processInfo.endActivity(activity) }
167
- for _ in 0..<50 where kill(request.appPID, 0) == 0 {
274
+ defer { ProcessInfo.processInfo.endActivity(activity) }
275
+
276
+ guard
277
+ request.bundleIdentifier == bundleIdentifier,
278
+ request.teamIdentifier == teamIdentifier,
279
+ let installedURL = supportedInstalledURL(path: request.installedAppPath),
280
+ let candidate = verifiedCandidate(request: request),
281
+ let destinationURL = replacementURL(installedURL: installedURL, candidateURL: candidate.appURL)
282
+ else {
283
+ recordFailure(request, error: VerificationError.invalidTransaction)
284
+ return
285
+ }
286
+ let candidateURL = candidate.appURL
287
+ let releaseRoot = candidateURL
288
+ .deletingLastPathComponent().deletingLastPathComponent()
289
+ .deletingLastPathComponent()
290
+ let backupURL = releaseRoot.appendingPathComponent("previous.app")
291
+
292
+ do {
293
+ try writeTransaction(request: request, stage: .waitingForExit)
294
+ for _ in 0..<50 where isApplicationProcessRunning(request) {
168
295
  usleep(100_000)
169
296
  }
170
- if kill(request.appPID, 0) == 0 {
297
+ if isApplicationProcessRunning(request) {
171
298
  _ = kill(request.appPID, SIGTERM)
172
- for _ in 0..<50 where kill(request.appPID, 0) == 0 {
299
+ for _ in 0..<50 where isApplicationProcessRunning(request) {
173
300
  usleep(100_000)
174
301
  }
175
302
  }
176
- guard kill(request.appPID, 0) != 0 else {
177
- NSLog("Bleam platform replacement failed: app did not terminate")
303
+ guard !isApplicationProcessRunning(request) else {
304
+ throw VerificationError.appDidNotTerminate
305
+ }
306
+
307
+ try writeTransaction(request: request, stage: .verifying)
308
+ if isValidBundle(at: destinationURL, expected: candidate.receipt) {
309
+ try finishApply(request: request, destinationURL: destinationURL)
178
310
  return
179
311
  }
180
312
 
181
- let releaseRoot = candidateURL
182
- .deletingLastPathComponent().deletingLastPathComponent()
183
- .deletingLastPathComponent()
184
- let backupURL = releaseRoot.appendingPathComponent("previous.app")
185
- do {
313
+ try validateBundle(at: candidateURL, expected: candidate.receipt)
314
+ if FileManager.default.fileExists(atPath: installedURL.path) {
315
+ try validateInstalledBundle(at: installedURL)
186
316
  try? FileManager.default.removeItem(at: backupURL)
187
317
  try FileManager.default.moveItem(at: installedURL, to: backupURL)
188
- do {
189
- try FileManager.default.moveItem(at: candidateURL, to: installedURL)
190
- } catch {
318
+ } else if !FileManager.default.fileExists(atPath: backupURL.path) {
319
+ throw VerificationError.invalidTransaction
320
+ }
321
+
322
+ try writeTransaction(request: request, stage: .replacing)
323
+ try FileManager.default.moveItem(at: candidateURL, to: destinationURL)
324
+ try validateBundle(at: destinationURL, expected: candidate.receipt)
325
+ try finishApply(request: request, destinationURL: destinationURL)
326
+ } catch {
327
+ if FileManager.default.fileExists(atPath: backupURL.path) {
328
+ try? FileManager.default.removeItem(at: destinationURL)
329
+ if !FileManager.default.fileExists(atPath: installedURL.path) {
191
330
  try? FileManager.default.moveItem(at: backupURL, to: installedURL)
192
- throw error
331
+ _ = try? run("/usr/bin/open", [installedURL.path])
193
332
  }
194
- let receipts = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
195
- .appendingPathComponent("Library/Application Support/Bleam/Updates", isDirectory: true)
196
- .appendingPathComponent(request.bundleIdentifier, isDirectory: true)
197
- try FileManager.default.createDirectory(at: receipts, withIntermediateDirectories: true)
198
- try Data().write(to: receipts.appendingPathComponent("\(request.releaseId).complete"))
199
- _ = try self.run("/usr/bin/open", [installedURL.path])
200
- } catch {
201
- NSLog("Bleam platform replacement failed: %@", error.localizedDescription)
202
333
  }
334
+ recordFailure(request, error: error)
335
+ }
336
+ }
337
+
338
+ private func finishApply(request: ApplyRequest, destinationURL: URL) throws {
339
+ let receipts = receiptsURL(bundleIdentifier: request.bundleIdentifier)
340
+ try FileManager.default.createDirectory(at: receipts, withIntermediateDirectories: true)
341
+ try Data().write(
342
+ to: receipts.appendingPathComponent("\(request.releaseId).complete"),
343
+ options: .atomic
344
+ )
345
+ try? FileManager.default.removeItem(at: transactionURL(for: request))
346
+ try? FileManager.default.removeItem(at: failureURL(for: request))
347
+ clearPendingMarkerIfIdle()
348
+ _ = try? run("/usr/bin/open", [destinationURL.path])
349
+ }
350
+
351
+ private func recordFailure(_ request: ApplyRequest, error: Error) {
352
+ NSLog("Bleam platform replacement failed: %@", String(describing: error))
353
+ do {
354
+ try writeTransaction(
355
+ request: request,
356
+ stage: .failed,
357
+ failure: String(describing: error),
358
+ destination: failureURL(for: request)
359
+ )
360
+ try? FileManager.default.removeItem(at: transactionURL(for: request))
361
+ clearPendingMarkerIfIdle()
362
+ } catch {
363
+ NSLog("Bleam platform failure receipt could not be written: %@", String(describing: error))
364
+ }
365
+ }
366
+
367
+ private func acceptTransaction(_ request: ApplyRequest) -> Bool {
368
+ do {
369
+ try writeTransaction(request: request, stage: .accepted)
370
+ try Data().write(to: pendingMarkerURL(), options: .atomic)
371
+ return true
372
+ } catch {
373
+ try? FileManager.default.removeItem(at: transactionURL(for: request))
374
+ clearPendingMarkerIfIdle()
375
+ return false
376
+ }
377
+ }
378
+
379
+ @discardableResult
380
+ private func writeTransaction(
381
+ request: ApplyRequest,
382
+ stage: ApplyTransaction.Stage,
383
+ failure: String? = nil,
384
+ destination: URL? = nil
385
+ ) throws -> URL {
386
+ let url = destination ?? transactionURL(for: request)
387
+ try FileManager.default.createDirectory(
388
+ at: url.deletingLastPathComponent(),
389
+ withIntermediateDirectories: true
390
+ )
391
+ let data = try PropertyListEncoder().encode(
392
+ ApplyTransaction(request: request, stage: stage, updatedAt: Date(), failure: failure)
393
+ )
394
+ try data.write(to: url, options: .atomic)
395
+ return url
396
+ }
397
+
398
+ private func transactionURL(for request: ApplyRequest) -> URL {
399
+ receiptsURL(bundleIdentifier: request.bundleIdentifier)
400
+ .appendingPathComponent("\(request.releaseId).applying")
401
+ }
402
+
403
+ private func failureURL(for request: ApplyRequest) -> URL {
404
+ receiptsURL(bundleIdentifier: request.bundleIdentifier)
405
+ .appendingPathComponent("\(request.releaseId).failed")
406
+ }
407
+
408
+ private func pendingMarkerURL() -> URL {
409
+ receiptsURL(bundleIdentifier: bundleIdentifier).appendingPathComponent("platform-apply.pending")
410
+ }
411
+
412
+ private func clearPendingMarkerIfIdle() {
413
+ let receipts = receiptsURL(bundleIdentifier: bundleIdentifier)
414
+ let hasPendingTransaction = (try? FileManager.default.contentsOfDirectory(
415
+ at: receipts,
416
+ includingPropertiesForKeys: nil
417
+ ).contains(where: { $0.pathExtension == "applying" })) == true
418
+ if !hasPendingTransaction {
419
+ try? FileManager.default.removeItem(at: pendingMarkerURL())
203
420
  }
204
421
  }
205
422
 
423
+ private func receiptsURL(bundleIdentifier: String) -> URL {
424
+ URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
425
+ .appendingPathComponent("Library/Application Support/Bleam/Updates", isDirectory: true)
426
+ .appendingPathComponent(bundleIdentifier, isDirectory: true)
427
+ }
428
+
429
+ private func validReleaseId(_ releaseId: String) -> Bool {
430
+ releaseId.range(of: "^[a-z][a-z0-9_-]{2,127}$", options: .regularExpression) != nil
431
+ }
432
+
206
433
  private func supportedInstalledURL(path: String) -> URL? {
207
434
  let applications = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
208
435
  .appendingPathComponent("Applications", isDirectory: true).standardizedFileURL
@@ -214,7 +441,25 @@ private final class PlatformHelper: NSObject, PlatformHelperProtocol {
214
441
  return candidate
215
442
  }
216
443
 
217
- private func verifiedCandidate(request: ApplyRequest) -> URL? {
444
+ private func replacementURL(installedURL: URL, candidateURL: URL) -> URL? {
445
+ let replacement = installedURL.deletingLastPathComponent()
446
+ .appendingPathComponent(candidateURL.lastPathComponent, isDirectory: true)
447
+ .standardizedFileURL
448
+ guard
449
+ replacement.pathExtension == "app",
450
+ replacement.deletingLastPathComponent() == installedURL.deletingLastPathComponent()
451
+ else { return nil }
452
+ return replacement
453
+ }
454
+
455
+ private func isApplicationProcessRunning(_ request: ApplyRequest) -> Bool {
456
+ var path = [CChar](repeating: 0, count: Int(MAXPATHLEN) * 4)
457
+ guard proc_pidpath(request.appPID, &path, UInt32(path.count)) > 0 else { return false }
458
+ let executablePath = String(cString: path)
459
+ return executablePath.hasPrefix(request.installedAppPath + "/Contents/MacOS/")
460
+ }
461
+
462
+ private func verifiedCandidate(request: ApplyRequest) -> (appURL: URL, receipt: VerifiedReceipt)? {
218
463
  let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
219
464
  .appendingPathComponent("Bleam/Platform", isDirectory: true)
220
465
  .appendingPathComponent(request.bundleIdentifier, isDirectory: true)
@@ -222,13 +467,18 @@ private final class PlatformHelper: NSObject, PlatformHelperProtocol {
222
467
  let receiptURL = root.appendingPathComponent("verified.plist")
223
468
  guard
224
469
  let data = try? Data(contentsOf: receiptURL),
225
- let receipt = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: String],
226
- receipt["releaseId"] == request.releaseId,
227
- receipt["bundleIdentifier"] == request.bundleIdentifier,
228
- receipt["teamIdentifier"] == request.teamIdentifier,
229
- let path = receipt["appPath"]
470
+ let receipt = try? PropertyListDecoder().decode(VerifiedReceipt.self, from: data),
471
+ receipt.releaseId == request.releaseId,
472
+ receipt.bundleIdentifier == request.bundleIdentifier,
473
+ receipt.teamIdentifier == request.teamIdentifier
230
474
  else { return nil }
231
- return URL(fileURLWithPath: path, isDirectory: true)
475
+ let appURL = URL(fileURLWithPath: receipt.appPath, isDirectory: true).standardizedFileURL
476
+ let appRoot = root.appendingPathComponent("extracted/app", isDirectory: true).standardizedFileURL
477
+ guard
478
+ appURL.pathExtension == "app",
479
+ appURL.deletingLastPathComponent() == appRoot
480
+ else { return nil }
481
+ return (appURL, receipt)
232
482
  }
233
483
 
234
484
  private func verify(
@@ -264,13 +514,76 @@ private final class PlatformHelper: NSObject, PlatformHelperProtocol {
264
514
  try FileManager.default.createDirectory(at: extracted, withIntermediateDirectories: true)
265
515
  _ = try run("/usr/bin/ditto", ["-x", "-k", artifactURL.path, extracted.path])
266
516
  let appURL = extracted.appendingPathComponent("app/\(appName)")
517
+ try validateBundle(at: appURL, expected: verifiedReceipt(appURL: appURL, release: release, request: request))
518
+
519
+ let required = release.target.minimumMacOSVersion.split(separator: ".").compactMap { Int($0) }
520
+ let current = ProcessInfo.processInfo.operatingSystemVersion
521
+ let installed = [current.majorVersion, current.minorVersion, current.patchVersion]
522
+ guard installed.lexicographicallyPrecedes(required) == false else {
523
+ throw VerificationError.unsupportedOS
524
+ }
525
+ return appURL
526
+ }
527
+
528
+ private func verifiedReceipt(
529
+ appURL: URL,
530
+ release: PlatformRelease,
531
+ request: DownloadRequest
532
+ ) throws -> VerifiedReceipt {
267
533
  guard
268
534
  let info = NSDictionary(contentsOf: appURL.appendingPathComponent("Contents/Info.plist")),
269
- info["CFBundleIdentifier"] as? String == request.bundleIdentifier,
270
- info["CFBundleShortVersionString"] as? String == release.appVersion,
271
- info["CFBundleVersion"] as? String == release.buildNumber,
272
535
  let executable = info["CFBundleExecutable"] as? String
273
536
  else { throw VerificationError.invalidIdentity }
537
+ return VerifiedReceipt(
538
+ releaseId: release.releaseId,
539
+ bundleIdentifier: request.bundleIdentifier,
540
+ teamIdentifier: request.teamIdentifier,
541
+ appVersion: release.appVersion,
542
+ buildNumber: release.buildNumber,
543
+ platformVersion: release.bleamPlatformVersion,
544
+ platformFingerprint: release.platformFingerprint,
545
+ executable: executable,
546
+ architecture: "arm64",
547
+ appPath: appURL.path
548
+ )
549
+ }
550
+
551
+ private func validateArchiveSymlinks(in appURL: URL) throws {
552
+ let root = appURL.resolvingSymlinksInPath().standardizedFileURL
553
+ let rootValues = try appURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
554
+ var enumerationFailed = false
555
+ guard rootValues.isDirectory == true,
556
+ rootValues.isSymbolicLink != true,
557
+ let enumerator = FileManager.default.enumerator(
558
+ at: appURL,
559
+ includingPropertiesForKeys: [.isSymbolicLinkKey],
560
+ errorHandler: { _, _ in enumerationFailed = true; return false }
561
+ )
562
+ else { throw VerificationError.unsafeArchivePath }
563
+ for case let url as URL in enumerator {
564
+ guard (try url.resourceValues(forKeys: [.isSymbolicLinkKey])).isSymbolicLink == true else { continue }
565
+ let target = url.resolvingSymlinksInPath().standardizedFileURL
566
+ guard
567
+ target == root || target.path.hasPrefix(root.path + "/"),
568
+ FileManager.default.fileExists(atPath: target.path)
569
+ else {
570
+ throw VerificationError.unsafeArchivePath
571
+ }
572
+ }
573
+ guard !enumerationFailed else { throw VerificationError.unsafeArchivePath }
574
+ }
575
+
576
+ private func validateBundle(at appURL: URL, expected: VerifiedReceipt) throws {
577
+ try validateArchiveSymlinks(in: appURL)
578
+ guard
579
+ let info = NSDictionary(contentsOf: appURL.appendingPathComponent("Contents/Info.plist")),
580
+ info["CFBundleIdentifier"] as? String == expected.bundleIdentifier,
581
+ info["CFBundleShortVersionString"] as? String == expected.appVersion,
582
+ info["CFBundleVersion"] as? String == expected.buildNumber,
583
+ info["BleamRuntimeVersion"] as? String == expected.platformVersion,
584
+ info["BleamPlatformFingerprint"] as? String == expected.platformFingerprint,
585
+ info["CFBundleExecutable"] as? String == expected.executable
586
+ else { throw VerificationError.invalidIdentity }
274
587
 
275
588
  var staticCode: SecStaticCode?
276
589
  guard
@@ -281,22 +594,43 @@ private final class PlatformHelper: NSObject, PlatformHelperProtocol {
281
594
  var signingInfo: CFDictionary?
282
595
  guard
283
596
  SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &signingInfo) == errSecSuccess,
284
- (signingInfo as? [String: Any])?[kSecCodeInfoTeamIdentifier as String] as? String == request.teamIdentifier
597
+ (signingInfo as? [String: Any])?[kSecCodeInfoTeamIdentifier as String] as? String == expected.teamIdentifier
285
598
  else { throw VerificationError.invalidSignature }
286
599
 
287
600
  let architectures = try run(
288
601
  "/usr/bin/lipo",
289
- ["-archs", appURL.appendingPathComponent("Contents/MacOS/\(executable)").path]
602
+ ["-archs", appURL.appendingPathComponent("Contents/MacOS/\(expected.executable)").path]
290
603
  ).trimmingCharacters(in: .whitespacesAndNewlines)
291
- guard architectures == "arm64" else { throw VerificationError.invalidArchitecture }
604
+ guard architectures == expected.architecture else { throw VerificationError.invalidArchitecture }
605
+ }
292
606
 
293
- let required = release.target.minimumMacOSVersion.split(separator: ".").compactMap { Int($0) }
294
- let current = ProcessInfo.processInfo.operatingSystemVersion
295
- let installed = [current.majorVersion, current.minorVersion, current.patchVersion]
296
- guard installed.lexicographicallyPrecedes(required) == false else {
297
- throw VerificationError.unsupportedOS
607
+ private func validateInstalledBundle(at appURL: URL) throws {
608
+ guard
609
+ let info = NSDictionary(contentsOf: appURL.appendingPathComponent("Contents/Info.plist")),
610
+ info["CFBundleIdentifier"] as? String == bundleIdentifier
611
+ else { throw VerificationError.invalidIdentity }
612
+
613
+ var staticCode: SecStaticCode?
614
+ guard
615
+ SecStaticCodeCreateWithPath(appURL as CFURL, [], &staticCode) == errSecSuccess,
616
+ let staticCode,
617
+ SecStaticCodeCheckValidity(staticCode, SecCSFlags(rawValue: kSecCSStrictValidate), nil) == errSecSuccess
618
+ else { throw VerificationError.invalidSignature }
619
+ var signingInfo: CFDictionary?
620
+ guard
621
+ SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &signingInfo) == errSecSuccess,
622
+ (signingInfo as? [String: Any])?[kSecCodeInfoTeamIdentifier as String] as? String == teamIdentifier
623
+ else { throw VerificationError.invalidSignature }
624
+ }
625
+
626
+ private func isValidBundle(at appURL: URL, expected: VerifiedReceipt) -> Bool {
627
+ guard FileManager.default.fileExists(atPath: appURL.path) else { return false }
628
+ do {
629
+ try validateBundle(at: appURL, expected: expected)
630
+ return true
631
+ } catch {
632
+ return false
298
633
  }
299
- return appURL
300
634
  }
301
635
 
302
636
  private func run(_ executable: String, _ arguments: [String]) throws -> String {
@@ -314,22 +648,79 @@ private final class PlatformHelper: NSObject, PlatformHelperProtocol {
314
648
  }
315
649
  }
316
650
 
651
+ private final class PlatformHelperConnection: NSObject, PlatformHelperProtocol {
652
+ private let helper: PlatformHelper
653
+ private let processIdentifier: pid_t
654
+
655
+ init(helper: PlatformHelper, processIdentifier: pid_t) {
656
+ self.helper = helper
657
+ self.processIdentifier = processIdentifier
658
+ }
659
+
660
+ func health(reply: @escaping (Data?) -> Void) {
661
+ helper.health(reply: reply)
662
+ }
663
+
664
+ func downloadPlatformUpdate(requestData: Data, reply: @escaping (Data?) -> Void) {
665
+ helper.downloadPlatformUpdate(requestData: requestData, reply: reply)
666
+ }
667
+
668
+ func applyPlatformUpdate(requestData: Data, reply: @escaping (Bool) -> Void) {
669
+ helper.applyPlatformUpdate(
670
+ requestData: requestData,
671
+ clientProcessIdentifier: processIdentifier,
672
+ reply: reply
673
+ )
674
+ }
675
+ }
676
+
317
677
  private enum VerificationError: Error {
318
678
  case invalidArtifact, unsafeArchivePath, invalidArchiveLayout, invalidIdentity, invalidSignature
319
- case invalidArchitecture, unsupportedOS, commandFailed
679
+ case invalidArchitecture, unsupportedOS, commandFailed, invalidTransaction, appDidNotTerminate
320
680
  }
321
681
 
322
682
  private final class PlatformHelperDelegate: NSObject, NSXPCListenerDelegate {
683
+ private let helper: PlatformHelper
684
+ private let codeSigningRequirement: String
685
+
686
+ init(helper: PlatformHelper, bundleIdentifier: String, teamIdentifier: String) {
687
+ self.helper = helper
688
+ self.codeSigningRequirement = "anchor apple generic and identifier \"\(bundleIdentifier)\" and certificate leaf[subject.OU] = \"\(teamIdentifier)\""
689
+ }
690
+
323
691
  func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
692
+ guard
693
+ connection.processIdentifier > 0,
694
+ connection.effectiveUserIdentifier == getuid()
695
+ else { return false }
696
+ connection.setCodeSigningRequirement(codeSigningRequirement)
324
697
  connection.exportedInterface = NSXPCInterface(with: PlatformHelperProtocol.self)
325
- connection.exportedObject = PlatformHelper()
698
+ connection.exportedObject = PlatformHelperConnection(
699
+ helper: helper,
700
+ processIdentifier: connection.processIdentifier
701
+ )
326
702
  connection.resume()
327
703
  return true
328
704
  }
329
705
  }
330
706
 
331
- private let delegate = PlatformHelperDelegate()
332
- private let listener = NSXPCListener.service()
707
+ let environment = ProcessInfo.processInfo.environment
708
+ guard
709
+ let serviceName = environment["XPC_SERVICE_NAME"],
710
+ let bundleIdentifier = environment["BLEAM_BUNDLE_IDENTIFIER"],
711
+ bundleIdentifier.range(of: "^[A-Za-z0-9.-]+$", options: .regularExpression) != nil,
712
+ let teamIdentifier = environment["BLEAM_TEAM_IDENTIFIER"],
713
+ teamIdentifier.range(of: "^[A-Z0-9]{10}$", options: .regularExpression) != nil
714
+ else {
715
+ fatalError("Bleam platform helper requires launchd service metadata")
716
+ }
717
+ private let helper = PlatformHelper(bundleIdentifier: bundleIdentifier, teamIdentifier: teamIdentifier)
718
+ private let delegate = PlatformHelperDelegate(
719
+ helper: helper,
720
+ bundleIdentifier: bundleIdentifier,
721
+ teamIdentifier: teamIdentifier
722
+ )
723
+ private let listener = NSXPCListener(machServiceName: serviceName)
333
724
  listener.delegate = delegate
334
725
  listener.resume()
335
726
  RunLoop.main.run()