@hasna/recordings 0.2.10 → 0.2.13

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.
Files changed (167) hide show
  1. package/README.md +350 -35
  2. package/bun.lock +3 -0
  3. package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
  4. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
  5. package/dist/cli/index.js +1728 -320
  6. package/dist/cli/macos-permissions.d.ts +13 -0
  7. package/dist/cli/macos-permissions.d.ts.map +1 -0
  8. package/dist/cli/options.d.ts +12 -0
  9. package/dist/cli/options.d.ts.map +1 -1
  10. package/dist/db/pg-migrations.d.ts.map +1 -1
  11. package/dist/db/recordings.d.ts +2 -1
  12. package/dist/db/recordings.d.ts.map +1 -1
  13. package/dist/db/remote-storage.d.ts +5 -1
  14. package/dist/db/remote-storage.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +628 -147
  18. package/dist/lib/bun-runtime.d.ts +11 -0
  19. package/dist/lib/bun-runtime.d.ts.map +1 -0
  20. package/dist/lib/config.d.ts +8 -1
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/enhancer.d.ts +10 -2
  23. package/dist/lib/enhancer.d.ts.map +1 -1
  24. package/dist/lib/install-maintenance.d.ts +24 -0
  25. package/dist/lib/install-maintenance.d.ts.map +1 -0
  26. package/dist/lib/machine.d.ts +2 -0
  27. package/dist/lib/machine.d.ts.map +1 -0
  28. package/dist/lib/recorder.d.ts +2 -1
  29. package/dist/lib/recorder.d.ts.map +1 -1
  30. package/dist/lib/recording-create-identity.d.ts +13 -0
  31. package/dist/lib/recording-create-identity.d.ts.map +1 -0
  32. package/dist/lib/release-install-policy.d.ts +31 -0
  33. package/dist/lib/release-install-policy.d.ts.map +1 -0
  34. package/dist/lib/transcriber.d.ts.map +1 -1
  35. package/dist/mcp/index.d.ts.map +1 -1
  36. package/dist/mcp/index.js +977 -194
  37. package/dist/sdk/index.js +5 -1
  38. package/dist/sdk/v1.generated.d.ts +4 -0
  39. package/dist/sdk/v1.generated.d.ts.map +1 -1
  40. package/dist/server/cloud-config.d.ts +9 -0
  41. package/dist/server/cloud-config.d.ts.map +1 -0
  42. package/dist/server/cloud-readiness.d.ts +10 -0
  43. package/dist/server/cloud-readiness.d.ts.map +1 -0
  44. package/dist/server/cloud.d.ts +9 -18
  45. package/dist/server/cloud.d.ts.map +1 -1
  46. package/dist/server/index.js +1654 -304
  47. package/dist/server/migrate-command.d.ts +11 -0
  48. package/dist/server/migrate-command.d.ts.map +1 -0
  49. package/dist/server/openapi.d.ts +22 -0
  50. package/dist/server/openapi.d.ts.map +1 -1
  51. package/dist/server/repo.d.ts +8 -1
  52. package/dist/server/repo.d.ts.map +1 -1
  53. package/dist/server/serve.d.ts +6 -1
  54. package/dist/server/serve.d.ts.map +1 -1
  55. package/dist/server/v1.d.ts.map +1 -1
  56. package/dist/storage.js +515 -80
  57. package/dist/store.d.ts +3 -1
  58. package/dist/store.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +10 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/version.d.ts +1 -1
  62. package/package.json +7 -4
  63. package/packaging/macos/Empty.entitlements +5 -0
  64. package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
  65. package/packaging/macos/Verifier.entitlements +10 -0
  66. package/packaging/macos/artifact-verifier.sb +33 -0
  67. package/packaging/macos/build_release_pkg.sh +872 -0
  68. package/packaging/macos/managed_bootstrap.sh +623 -0
  69. package/packaging/macos/pkgutil_fingerprint.awk +37 -0
  70. package/packaging/macos/release_lifecycle.ts +463 -0
  71. package/packaging/macos/scripts/postinstall +565 -0
  72. package/packaging/macos/scripts/preinstall +297 -0
  73. package/scripts/build_companion_cli.sh +337 -0
  74. package/scripts/build_native_fs_guard.sh +59 -0
  75. package/scripts/generate-sdk.ts +19 -1
  76. package/scripts/install_macos_app.sh +1922 -77
  77. package/scripts/macos_artifact.ts +5316 -0
  78. package/scripts/migrate.ts +38 -10
  79. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  80. package/scripts/native/recordings_fs_guard.c +1167 -0
  81. package/scripts/native_fs_guard.ts +158 -0
  82. package/scripts/release-guard.ts +20 -1
  83. package/scripts/resolve_tailscale_cli.sh +389 -0
  84. package/scripts/smoke_macos_app.sh +573 -0
  85. package/src/native/Recordings/App/ContentView.swift +83 -0
  86. package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
  87. package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
  88. package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
  89. package/src/native/Recordings/App/RecordingsApp.swift +367 -44
  90. package/src/native/Recordings/App/RecordingsListView.swift +130 -0
  91. package/src/native/Recordings/App/RecordingsStore.swift +206 -0
  92. package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
  93. package/src/native/Recordings/App/SidebarView.swift +212 -0
  94. package/src/native/Recordings/App/Theme.swift +87 -0
  95. package/src/native/Recordings/Package.resolved +5 -5
  96. package/src/native/Recordings/Package.swift +62 -2
  97. package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
  98. package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
  99. package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
  101. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
  102. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
  103. package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
  104. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
  105. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
  106. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
  107. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
  108. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
  109. package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
  110. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
  111. package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
  112. package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
  113. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
  114. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
  115. package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
  116. package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
  117. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
  118. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
  119. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
  120. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
  121. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
  122. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
  123. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
  124. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
  125. package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
  126. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
  127. package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
  128. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
  129. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
  130. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
  131. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
  132. package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
  133. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
  134. package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
  135. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
  136. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
  137. package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
  138. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
  139. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
  140. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
  141. package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
  142. package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
  143. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
  144. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
  145. package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
  146. package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
  147. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
  148. package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
  149. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
  150. package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
  151. package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
  152. package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
  153. package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
  154. package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
  155. package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
  156. package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
  157. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
  158. package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
  159. package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
  160. package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
  161. package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
  162. package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
  163. package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
  164. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
  165. package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
  166. package/src/native/Recordings/build.sh +1990 -29
  167. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
@@ -0,0 +1,456 @@
1
+ import CryptoKit
2
+ import Darwin
3
+ import Foundation
4
+ import RecordingsUpdateProtocol
5
+
6
+ struct PersistedReleaseState: Codable, Sendable {
7
+ let schemaVersion: Int
8
+ let purpose: String
9
+ let phase: String
10
+ let keyEpoch: UInt64
11
+ let releaseSequence: UInt64
12
+ let releaseID: String
13
+ let version: String
14
+ let build: String
15
+ let cohortPackageSHA256: String
16
+ let envelopePayloadSHA256: String
17
+ let updatedAtUTC: String
18
+
19
+ enum CodingKeys: String, CodingKey {
20
+ case schemaVersion = "schema_version"
21
+ case purpose
22
+ case phase
23
+ case keyEpoch = "key_epoch"
24
+ case releaseSequence = "release_sequence"
25
+ case releaseID = "release_id"
26
+ case version
27
+ case build
28
+ case cohortPackageSHA256 = "cohort_package_sha256"
29
+ case envelopePayloadSHA256 = "envelope_payload_sha256"
30
+ case updatedAtUTC = "updated_at_utc"
31
+ }
32
+ }
33
+
34
+ enum ReleaseStateDecision: Sendable {
35
+ case advance
36
+ case resumeSeen
37
+ case alreadyCommitted
38
+ }
39
+
40
+ final class MonotonicReleaseStateStore {
41
+ private let directoryDescriptor: Int32
42
+
43
+ init(directory: String = RecordingsUpdateConstants.monotonicStateDirectory) throws {
44
+ guard geteuid() == 0 else { throw MonotonicStateError.notRoot }
45
+ guard DarwinACLValidator.rootOwnedDirectoryAncestryHasNoExtendedACL(to: directory) else {
46
+ throw MonotonicStateError.unsafeDirectory
47
+ }
48
+ var pathMetadata = stat()
49
+ guard lstat(directory, &pathMetadata) == 0,
50
+ (pathMetadata.st_mode & S_IFMT) == S_IFDIR,
51
+ pathMetadata.st_uid == 0,
52
+ (pathMetadata.st_mode & 0o077) == 0
53
+ else {
54
+ throw MonotonicStateError.unsafeDirectory
55
+ }
56
+ directoryDescriptor = Darwin.open(directory, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)
57
+ guard directoryDescriptor >= 0 else { throw MonotonicStateError.unsafeDirectory }
58
+ var openedMetadata = stat()
59
+ guard fstat(directoryDescriptor, &openedMetadata) == 0,
60
+ openedMetadata.st_dev == pathMetadata.st_dev,
61
+ openedMetadata.st_ino == pathMetadata.st_ino,
62
+ DarwinACLValidator.descriptorHasNoExtendedACL(directoryDescriptor)
63
+ else {
64
+ Darwin.close(directoryDescriptor)
65
+ throw MonotonicStateError.unsafeDirectory
66
+ }
67
+ }
68
+
69
+ deinit { Darwin.close(directoryDescriptor) }
70
+
71
+ /// Holds a process-independent root-owned lock across assessment, activation, and
72
+ /// durable sequence advancement. Exact replays are idempotent; every conflicting
73
+ /// equal or lower sequence and every root-cohort/key change fails closed.
74
+ func perform<T>(
75
+ payload: ReleaseEnvelopePayload,
76
+ policy: BrokerPolicy,
77
+ prepare: (ReleaseStateDecision) throws -> Void = { _ in },
78
+ operation: (ReleaseStateDecision) throws -> T
79
+ ) throws -> T {
80
+ let lock = openat(
81
+ directoryDescriptor,
82
+ "release-state.lock",
83
+ O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW,
84
+ 0o600
85
+ )
86
+ guard lock >= 0 else { throw MonotonicStateError.lockUnavailable }
87
+ defer { Darwin.close(lock) }
88
+ var lockMetadata = stat()
89
+ guard fstat(lock, &lockMetadata) == 0,
90
+ (lockMetadata.st_mode & S_IFMT) == S_IFREG,
91
+ lockMetadata.st_uid == 0,
92
+ (lockMetadata.st_mode & 0o077) == 0,
93
+ DarwinACLValidator.descriptorHasNoExtendedACL(lock),
94
+ flock(lock, LOCK_EX) == 0
95
+ else {
96
+ throw MonotonicStateError.lockUnavailable
97
+ }
98
+ defer { flock(lock, LOCK_UN) }
99
+
100
+ let payloadDigest = try SHA256.hash(data: payload.canonicalData())
101
+ .map { String(format: "%02x", $0) }
102
+ .joined()
103
+ let current = try readCurrentState()
104
+ let decision = try assess(payload: payload, digest: payloadDigest, current: current, policy: policy)
105
+ // Preparation must durably bind the exact transaction before `seen` is
106
+ // persisted. Recovery can then finish or roll back without replaying an
107
+ // envelope that may have expired while the broker was unavailable.
108
+ try prepare(decision)
109
+ if case .advance = decision {
110
+ try writeState(payload: payload, payloadDigest: payloadDigest, phase: "seen")
111
+ }
112
+ let result: T
113
+ do {
114
+ result = try operation(decision)
115
+ } catch {
116
+ if Self.mayDurablyAbortSeenBarrier(after: error) {
117
+ switch decision {
118
+ case .advance, .resumeSeen:
119
+ try writeState(payload: payload, payloadDigest: payloadDigest, phase: "aborted")
120
+ case .alreadyCommitted:
121
+ break
122
+ }
123
+ }
124
+ throw error
125
+ }
126
+ if case .alreadyCommitted = decision {
127
+ return result
128
+ }
129
+ try writeState(payload: payload, payloadDigest: payloadDigest, phase: "committed")
130
+ return result
131
+ }
132
+
133
+ func currentState() throws -> PersistedReleaseState? {
134
+ let lock = openat(
135
+ directoryDescriptor,
136
+ "release-state.lock",
137
+ O_RDONLY | O_CREAT | O_CLOEXEC | O_NOFOLLOW,
138
+ 0o600
139
+ )
140
+ guard lock >= 0 else { throw MonotonicStateError.lockUnavailable }
141
+ defer { Darwin.close(lock) }
142
+ var lockMetadata = stat()
143
+ guard fstat(lock, &lockMetadata) == 0,
144
+ (lockMetadata.st_mode & S_IFMT) == S_IFREG,
145
+ lockMetadata.st_uid == 0,
146
+ (lockMetadata.st_mode & 0o077) == 0,
147
+ DarwinACLValidator.descriptorHasNoExtendedACL(lock),
148
+ flock(lock, LOCK_SH) == 0
149
+ else {
150
+ throw MonotonicStateError.lockUnavailable
151
+ }
152
+ defer { flock(lock, LOCK_UN) }
153
+ return try readCurrentState()
154
+ }
155
+
156
+ /// Startup recovery may finalize only the exact release whose durable `seen`
157
+ /// state and install journal both bind the application tree now installed.
158
+ /// This never lowers or replaces the highest-seen key epoch or sequence.
159
+ func finalizeRecoveredCommit(journal: BrokerInstallJournal) throws {
160
+ let lock = openat(
161
+ directoryDescriptor,
162
+ "release-state.lock",
163
+ O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW,
164
+ 0o600
165
+ )
166
+ guard lock >= 0 else { throw MonotonicStateError.lockUnavailable }
167
+ defer { Darwin.close(lock) }
168
+ var lockMetadata = stat()
169
+ guard fstat(lock, &lockMetadata) == 0,
170
+ (lockMetadata.st_mode & S_IFMT) == S_IFREG,
171
+ lockMetadata.st_uid == 0,
172
+ (lockMetadata.st_mode & 0o077) == 0,
173
+ DarwinACLValidator.descriptorHasNoExtendedACL(lock),
174
+ flock(lock, LOCK_EX) == 0
175
+ else {
176
+ throw MonotonicStateError.lockUnavailable
177
+ }
178
+ defer { flock(lock, LOCK_UN) }
179
+ guard let current = try readCurrentState(),
180
+ current.purpose == journal.expectedPurpose,
181
+ current.keyEpoch == journal.keyEpoch,
182
+ current.releaseSequence == journal.releaseSequence,
183
+ current.releaseID == journal.releaseID,
184
+ current.cohortPackageSHA256 == journal.cohortPackageSHA256,
185
+ current.envelopePayloadSHA256 == journal.envelopePayloadSHA256,
186
+ current.phase == "seen" || current.phase == "committed"
187
+ else {
188
+ throw MonotonicStateError.recoveryStateMismatch
189
+ }
190
+ if current.phase == "committed" { return }
191
+ try writePersistedState(PersistedReleaseState(
192
+ schemaVersion: current.schemaVersion,
193
+ purpose: current.purpose,
194
+ phase: "committed",
195
+ keyEpoch: current.keyEpoch,
196
+ releaseSequence: current.releaseSequence,
197
+ releaseID: current.releaseID,
198
+ version: current.version,
199
+ build: current.build,
200
+ cohortPackageSHA256: current.cohortPackageSHA256,
201
+ envelopePayloadSHA256: current.envelopePayloadSHA256,
202
+ updatedAtUTC: Self.timestamp()
203
+ ))
204
+ }
205
+
206
+ /// Recovery may close an exact `seen` barrier only after the activation journal
207
+ /// proves the previous tree is live and the candidate is retained in one protected
208
+ /// transaction slot. The aborted sequence remains monotonic and blocks replay.
209
+ func finalizeRecoveredAbort(journal: BrokerInstallJournal) throws {
210
+ let lock = openat(
211
+ directoryDescriptor,
212
+ "release-state.lock",
213
+ O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW,
214
+ 0o600
215
+ )
216
+ guard lock >= 0 else { throw MonotonicStateError.lockUnavailable }
217
+ defer { Darwin.close(lock) }
218
+ var lockMetadata = stat()
219
+ guard fstat(lock, &lockMetadata) == 0,
220
+ (lockMetadata.st_mode & S_IFMT) == S_IFREG,
221
+ lockMetadata.st_uid == 0,
222
+ (lockMetadata.st_mode & 0o077) == 0,
223
+ DarwinACLValidator.descriptorHasNoExtendedACL(lock),
224
+ flock(lock, LOCK_EX) == 0
225
+ else {
226
+ throw MonotonicStateError.lockUnavailable
227
+ }
228
+ defer { flock(lock, LOCK_UN) }
229
+ guard let current = try readCurrentState(),
230
+ current.purpose == journal.expectedPurpose,
231
+ current.keyEpoch == journal.keyEpoch,
232
+ current.releaseSequence == journal.releaseSequence,
233
+ current.releaseID == journal.releaseID,
234
+ current.cohortPackageSHA256 == journal.cohortPackageSHA256,
235
+ current.envelopePayloadSHA256 == journal.envelopePayloadSHA256,
236
+ current.phase == "seen" || current.phase == "aborted"
237
+ else {
238
+ throw MonotonicStateError.recoveryStateMismatch
239
+ }
240
+ if current.phase == "aborted" { return }
241
+ try writePersistedState(PersistedReleaseState(
242
+ schemaVersion: current.schemaVersion,
243
+ purpose: current.purpose,
244
+ phase: "aborted",
245
+ keyEpoch: current.keyEpoch,
246
+ releaseSequence: current.releaseSequence,
247
+ releaseID: current.releaseID,
248
+ version: current.version,
249
+ build: current.build,
250
+ cohortPackageSHA256: current.cohortPackageSHA256,
251
+ envelopePayloadSHA256: current.envelopePayloadSHA256,
252
+ updatedAtUTC: Self.timestamp()
253
+ ))
254
+ }
255
+
256
+ private func assess(
257
+ payload: ReleaseEnvelopePayload,
258
+ digest: String,
259
+ current: PersistedReleaseState?,
260
+ policy: BrokerPolicy
261
+ ) throws -> ReleaseStateDecision {
262
+ let candidate = MonotonicReleaseCandidate(
263
+ purpose: payload.purpose,
264
+ keyEpoch: payload.keyEpoch,
265
+ releaseSequence: payload.releaseSequence,
266
+ releaseID: payload.releaseID,
267
+ cohortPackageSHA256: payload.packageSHA256,
268
+ envelopePayloadSHA256: digest
269
+ )
270
+ let currentSnapshot: MonotonicReleaseStateSnapshot?
271
+ if let current {
272
+ guard let phase = MonotonicReleasePhase(rawValue: current.phase) else {
273
+ throw MonotonicStateError.invalidState
274
+ }
275
+ currentSnapshot = MonotonicReleaseStateSnapshot(
276
+ purpose: current.purpose,
277
+ phase: phase,
278
+ keyEpoch: current.keyEpoch,
279
+ releaseSequence: current.releaseSequence,
280
+ releaseID: current.releaseID,
281
+ cohortPackageSHA256: current.cohortPackageSHA256,
282
+ envelopePayloadSHA256: current.envelopePayloadSHA256
283
+ )
284
+ } else {
285
+ currentSnapshot = nil
286
+ }
287
+
288
+ do {
289
+ switch try MonotonicReleasePolicy.assess(
290
+ candidate: candidate,
291
+ current: currentSnapshot,
292
+ initialKeyEpoch: policy.initialKeyEpoch,
293
+ allowedKeyEpochs: policy.allowedKeyEpochs
294
+ ) {
295
+ case .advance: return .advance
296
+ case .resumeSeen: return .resumeSeen
297
+ case .alreadyCommitted: return .alreadyCommitted
298
+ }
299
+ } catch let error as MonotonicReleasePolicyError {
300
+ switch error {
301
+ case .immutableCohortChange: throw MonotonicStateError.immutableCohortChange
302
+ case .bootstrapRequired: throw MonotonicStateError.bootstrapRequired
303
+ case .bootstrapAlreadyInitialized: throw MonotonicStateError.bootstrapAlreadyInitialized
304
+ case .sequenceRollbackOrConflict: throw MonotonicStateError.sequenceRollbackOrConflict
305
+ case .pendingSeenRecoveryRequired: throw MonotonicStateError.pendingSeenRecoveryRequired
306
+ }
307
+ }
308
+ }
309
+
310
+ private func readCurrentState() throws -> PersistedReleaseState? {
311
+ let descriptor = openat(directoryDescriptor, "release-state.json", O_RDONLY | O_CLOEXEC | O_NOFOLLOW)
312
+ if descriptor < 0, errno == ENOENT { return nil }
313
+ guard descriptor >= 0 else { throw MonotonicStateError.invalidState }
314
+ defer { Darwin.close(descriptor) }
315
+ var metadata = stat()
316
+ guard fstat(descriptor, &metadata) == 0,
317
+ (metadata.st_mode & S_IFMT) == S_IFREG,
318
+ metadata.st_uid == 0,
319
+ (metadata.st_mode & 0o077) == 0,
320
+ DarwinACLValidator.descriptorHasNoExtendedACL(descriptor),
321
+ metadata.st_size > 0,
322
+ metadata.st_size <= 64 * 1024
323
+ else {
324
+ throw MonotonicStateError.invalidState
325
+ }
326
+ var data = Data(count: Int(metadata.st_size))
327
+ var offset: off_t = 0
328
+ try data.withUnsafeMutableBytes { bytes in
329
+ guard let base = bytes.baseAddress else { return }
330
+ while offset < metadata.st_size {
331
+ let count = pread(descriptor, base.advanced(by: Int(offset)), Int(metadata.st_size - offset), offset)
332
+ if count < 0, errno == EINTR { continue }
333
+ guard count > 0 else { throw MonotonicStateError.invalidState }
334
+ offset += off_t(count)
335
+ }
336
+ }
337
+ guard DarwinACLValidator.descriptorHasNoExtendedACL(descriptor),
338
+ let state = try? JSONDecoder().decode(PersistedReleaseState.self, from: data),
339
+ state.schemaVersion == RecordingsUpdateConstants.protocolVersion,
340
+ (state.purpose == "bootstrap" || state.purpose == "update"),
341
+ (state.phase == "seen" || state.phase == "aborted" || state.phase == "committed"),
342
+ state.keyEpoch > 0,
343
+ state.releaseSequence > 0,
344
+ UUID(uuidString: state.releaseID) != nil,
345
+ state.cohortPackageSHA256.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil,
346
+ state.envelopePayloadSHA256.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil
347
+ else {
348
+ throw MonotonicStateError.invalidState
349
+ }
350
+ return state
351
+ }
352
+
353
+ private func writeState(payload: ReleaseEnvelopePayload, payloadDigest: String, phase: String) throws {
354
+ let state = PersistedReleaseState(
355
+ schemaVersion: RecordingsUpdateConstants.protocolVersion,
356
+ purpose: payload.purpose,
357
+ phase: phase,
358
+ keyEpoch: payload.keyEpoch,
359
+ releaseSequence: payload.releaseSequence,
360
+ releaseID: payload.releaseID,
361
+ version: payload.version,
362
+ build: payload.build,
363
+ cohortPackageSHA256: payload.packageSHA256,
364
+ envelopePayloadSHA256: payloadDigest,
365
+ updatedAtUTC: Self.timestamp()
366
+ )
367
+ try writePersistedState(state)
368
+ }
369
+
370
+ private func writePersistedState(_ state: PersistedReleaseState) throws {
371
+ let encoder = JSONEncoder()
372
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
373
+ let data = try encoder.encode(state) + Data([0x0a])
374
+ let temporary = ".release-state.\(UUID().uuidString.lowercased()).tmp"
375
+ let descriptor = openat(
376
+ directoryDescriptor,
377
+ temporary,
378
+ O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW,
379
+ 0o600
380
+ )
381
+ guard descriptor >= 0 else { throw MonotonicStateError.couldNotPersist }
382
+ var removeTemporary = true
383
+ defer {
384
+ Darwin.close(descriptor)
385
+ if removeTemporary { unlinkat(directoryDescriptor, temporary, 0) }
386
+ }
387
+ guard DarwinACLValidator.descriptorHasNoExtendedACL(descriptor) else {
388
+ throw MonotonicStateError.couldNotPersist
389
+ }
390
+ try writeAll(data, descriptor: descriptor)
391
+ guard fsync(descriptor) == 0,
392
+ renameat(directoryDescriptor, temporary, directoryDescriptor, "release-state.json") == 0,
393
+ fsync(directoryDescriptor) == 0
394
+ else {
395
+ throw MonotonicStateError.couldNotPersist
396
+ }
397
+ removeTemporary = false
398
+ }
399
+
400
+ private static func timestamp() -> String {
401
+ let formatter = ISO8601DateFormatter()
402
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
403
+ return formatter.string(from: Date())
404
+ }
405
+
406
+ private static func mayDurablyAbortSeenBarrier(after error: Error) -> Bool {
407
+ guard let activationError = error as? AtomicActivationError else { return false }
408
+ if case .recoveryRequired = activationError { return false }
409
+ return true
410
+ }
411
+
412
+ private func writeAll(_ data: Data, descriptor: Int32) throws {
413
+ try data.withUnsafeBytes { bytes in
414
+ guard let base = bytes.baseAddress else { return }
415
+ var offset = 0
416
+ while offset < bytes.count {
417
+ let count = Darwin.write(descriptor, base.advanced(by: offset), bytes.count - offset)
418
+ if count < 0, errno == EINTR { continue }
419
+ guard count > 0 else { throw MonotonicStateError.couldNotPersist }
420
+ offset += count
421
+ }
422
+ }
423
+ }
424
+ }
425
+
426
+ enum MonotonicStateError: Error, CustomStringConvertible {
427
+ case notRoot
428
+ case unsafeDirectory
429
+ case lockUnavailable
430
+ case invalidState
431
+ case immutableCohortChange
432
+ case bootstrapRequired
433
+ case bootstrapAlreadyInitialized
434
+ case sequenceRollbackOrConflict
435
+ case pendingSeenRecoveryRequired
436
+ case recoveryStateMismatch
437
+ case couldNotPersist
438
+
439
+ var description: String {
440
+ switch self {
441
+ case .notRoot: "The update broker is not running as root"
442
+ case .unsafeDirectory: "The monotonic update-state directory is missing or unsafe"
443
+ case .lockUnavailable: "The monotonic update-state lock is unavailable"
444
+ case .invalidState: "The monotonic update state is malformed or unsafe"
445
+ case .immutableCohortChange:
446
+ "The immutable updater cohort does not support root-component or key-epoch changes"
447
+ case .bootstrapRequired: "The monotonic release state must be initialized by a bootstrap release"
448
+ case .bootstrapAlreadyInitialized: "Bootstrap release state is already initialized"
449
+ case .sequenceRollbackOrConflict: "The release sequence is stale or conflicts with committed state"
450
+ case .pendingSeenRecoveryRequired:
451
+ "The highest-seen release must be recovered or replayed exactly before another release can advance"
452
+ case .recoveryStateMismatch: "Interrupted-install recovery does not match the highest-seen release state"
453
+ case .couldNotPersist: "Could not durably persist monotonic update state"
454
+ }
455
+ }
456
+ }