@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,274 @@
1
+ import Darwin
2
+ import Foundation
3
+ import RecordingsUpdateProtocol
4
+ import Security
5
+
6
+ struct BrokerPolicy: Codable, Sendable {
7
+ let schemaVersion: Int
8
+ let signingTeamIdentifier: String
9
+ let allowedClientIdentifiers: [String]
10
+ let applicationIdentifier: String
11
+ let initialKeyEpoch: UInt64
12
+ let allowedKeyEpochs: [UInt64]
13
+ let lifecycle: String
14
+ let rootMaintenanceSupported: Bool
15
+ let keyRotationSupported: Bool
16
+
17
+ enum CodingKeys: String, CodingKey {
18
+ case schemaVersion = "schema_version"
19
+ case signingTeamIdentifier = "signing_team_identifier"
20
+ case allowedClientIdentifiers = "allowed_client_identifiers"
21
+ case applicationIdentifier = "application_identifier"
22
+ case initialKeyEpoch = "initial_key_epoch"
23
+ case allowedKeyEpochs = "allowed_key_epochs"
24
+ case lifecycle
25
+ case rootMaintenanceSupported = "root_maintenance_supported"
26
+ case keyRotationSupported = "key_rotation_supported"
27
+ }
28
+
29
+ func validate() throws {
30
+ guard schemaVersion == RecordingsUpdateConstants.protocolVersion else {
31
+ throw BrokerSecurityError.invalidPolicy("unsupported schema")
32
+ }
33
+ guard signingTeamIdentifier.range(of: #"^[A-Z0-9]{10}$"#, options: .regularExpression) != nil else {
34
+ throw BrokerSecurityError.invalidPolicy("invalid team identifier")
35
+ }
36
+ guard !allowedClientIdentifiers.isEmpty, allowedClientIdentifiers.count <= 16 else {
37
+ throw BrokerSecurityError.invalidPolicy("invalid client identifier list")
38
+ }
39
+ guard initialKeyEpoch > 0, allowedKeyEpochs == [initialKeyEpoch]
40
+ else {
41
+ throw BrokerSecurityError.invalidPolicy("invalid key-epoch authorization")
42
+ }
43
+ guard lifecycle == RecordingsUpdateConstants.lifecycle,
44
+ rootMaintenanceSupported == RecordingsUpdateConstants.rootMaintenanceSupported,
45
+ keyRotationSupported == RecordingsUpdateConstants.keyRotationSupported
46
+ else {
47
+ throw BrokerSecurityError.invalidPolicy("unsupported updater lifecycle")
48
+ }
49
+ for identifier in allowedClientIdentifiers + [applicationIdentifier] {
50
+ guard identifier.range(of: #"^[A-Za-z0-9][A-Za-z0-9.-]{1,254}$"#, options: .regularExpression) != nil else {
51
+ throw BrokerSecurityError.invalidPolicy("invalid bundle identifier")
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ struct AuthenticatedPeer: Sendable {
58
+ let effectiveUserID: uid_t
59
+ let processIdentifier: pid_t
60
+ let signingIdentifier: String
61
+ let signingTeamIdentifier: String
62
+ }
63
+
64
+ enum RootTrustStore {
65
+ static func readPolicy() throws -> BrokerPolicy {
66
+ let data = try readRootOwnedRegularFile(
67
+ at: RecordingsUpdateConstants.policyPath,
68
+ maximumBytes: 64 * 1024,
69
+ expectedModeMask: 0o022
70
+ )
71
+ let policy: BrokerPolicy
72
+ do {
73
+ let decoder = JSONDecoder()
74
+ decoder.keyDecodingStrategy = .useDefaultKeys
75
+ policy = try decoder.decode(BrokerPolicy.self, from: data)
76
+ } catch {
77
+ throw BrokerSecurityError.invalidPolicy("malformed JSON")
78
+ }
79
+ try policy.validate()
80
+ return policy
81
+ }
82
+
83
+ static func readEnvelopePublicKey(epoch: UInt64) throws -> Data {
84
+ guard epoch > 0 else { throw BrokerSecurityError.invalidTrustFile }
85
+ let data = try readRootOwnedRegularFile(
86
+ at: RecordingsUpdateConstants.envelopePublicKeyDirectory + "/\(epoch).raw",
87
+ maximumBytes: 32,
88
+ expectedModeMask: 0o022
89
+ )
90
+ guard data.count == 32 else { throw BrokerSecurityError.invalidTrustFile }
91
+ return data
92
+ }
93
+
94
+ static func readBootstrapMarker() throws -> Data {
95
+ try readRootOwnedRegularFile(
96
+ at: RecordingsUpdateConstants.bootstrapMarkerPath,
97
+ maximumBytes: 1024 * 1024,
98
+ expectedModeMask: 0o022
99
+ )
100
+ }
101
+
102
+ private static func readRootOwnedRegularFile(
103
+ at path: String,
104
+ maximumBytes: Int,
105
+ expectedModeMask: mode_t
106
+ ) throws -> Data {
107
+ let parentDirectory = URL(fileURLWithPath: path).deletingLastPathComponent().path
108
+ guard DarwinACLValidator.rootOwnedDirectoryAncestryHasNoExtendedACL(
109
+ to: parentDirectory
110
+ ) else {
111
+ throw BrokerSecurityError.invalidTrustFile
112
+ }
113
+ let descriptor = Darwin.open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW)
114
+ guard descriptor >= 0 else { throw BrokerSecurityError.invalidTrustFile }
115
+ defer { Darwin.close(descriptor) }
116
+
117
+ var metadata = stat()
118
+ guard fstat(descriptor, &metadata) == 0,
119
+ (metadata.st_mode & S_IFMT) == S_IFREG,
120
+ metadata.st_uid == 0,
121
+ (metadata.st_mode & expectedModeMask) == 0,
122
+ DarwinACLValidator.descriptorHasNoExtendedACL(descriptor),
123
+ metadata.st_size > 0,
124
+ metadata.st_size <= maximumBytes
125
+ else {
126
+ throw BrokerSecurityError.invalidTrustFile
127
+ }
128
+
129
+ var result = Data()
130
+ result.reserveCapacity(Int(metadata.st_size))
131
+ var offset: off_t = 0
132
+ var buffer = [UInt8](repeating: 0, count: min(16 * 1024, maximumBytes + 1))
133
+ while offset < metadata.st_size {
134
+ let requested = min(buffer.count, Int(metadata.st_size - offset))
135
+ let count = buffer.withUnsafeMutableBytes {
136
+ pread(descriptor, $0.baseAddress, requested, offset)
137
+ }
138
+ guard count > 0 else { throw BrokerSecurityError.invalidTrustFile }
139
+ result.append(contentsOf: buffer.prefix(count))
140
+ offset += off_t(count)
141
+ }
142
+ var trailingByte: UInt8 = 0
143
+ guard pread(descriptor, &trailingByte, 1, offset) == 0,
144
+ DarwinACLValidator.descriptorHasNoExtendedACL(descriptor)
145
+ else {
146
+ throw BrokerSecurityError.invalidTrustFile
147
+ }
148
+ return result
149
+ }
150
+ }
151
+
152
+ struct PeerIdentityPolicy {
153
+ let policy: BrokerPolicy
154
+
155
+ func authenticate(_ connection: NSXPCConnection) throws -> AuthenticatedPeer {
156
+ var token = connection.auditToken
157
+ let tokenData = withUnsafeBytes(of: &token) { Data($0) }
158
+ let attributes = [kSecGuestAttributeAudit as String: tokenData] as CFDictionary
159
+ var guest: SecCode?
160
+ let guestStatus = SecCodeCopyGuestWithAttributes(nil, attributes, [], &guest)
161
+ guard guestStatus == errSecSuccess, let guest else {
162
+ throw BrokerSecurityError.peerCodeUnavailable(guestStatus)
163
+ }
164
+
165
+ let identifiers = policy.allowedClientIdentifiers
166
+ .map { "identifier \(Self.requirementQuoted($0))" }
167
+ .joined(separator: " or ")
168
+ let requirementText = """
169
+ anchor apple generic and
170
+ certificate leaf[field.1.2.840.113635.100.6.1.13] exists and
171
+ certificate 1[field.1.2.840.113635.100.6.2.6] exists and
172
+ certificate leaf[subject.OU] = \(Self.requirementQuoted(policy.signingTeamIdentifier)) and
173
+ (\(identifiers))
174
+ """
175
+ var requirement: SecRequirement?
176
+ let requirementStatus = SecRequirementCreateWithString(
177
+ requirementText as CFString,
178
+ [],
179
+ &requirement
180
+ )
181
+ guard requirementStatus == errSecSuccess, let requirement else {
182
+ throw BrokerSecurityError.invalidPolicy("could not compile peer requirement")
183
+ }
184
+ let validationStatus = SecCodeCheckValidity(
185
+ guest,
186
+ SecCSFlags(rawValue: kSecCSStrictValidate | kSecCSCheckNestedCode),
187
+ requirement
188
+ )
189
+ guard validationStatus == errSecSuccess else {
190
+ throw BrokerSecurityError.peerRejected(validationStatus)
191
+ }
192
+
193
+ var signingInformation: CFDictionary?
194
+ let informationStatus = SecCodeCopySigningInformation(
195
+ guest,
196
+ SecCSFlags(rawValue: kSecCSSigningInformation | kSecCSRequirementInformation),
197
+ &signingInformation
198
+ )
199
+ guard informationStatus == errSecSuccess,
200
+ let information = signingInformation as? [String: Any],
201
+ let identifier = information[kSecCodeInfoIdentifier as String] as? String,
202
+ let team = information[kSecCodeInfoTeamIdentifier as String] as? String,
203
+ identifiersContain(identifier),
204
+ team == policy.signingTeamIdentifier,
205
+ Self.hasHardenedRuntime(information),
206
+ Self.hasNoDangerousDebugOrLoaderEntitlements(information)
207
+ else {
208
+ throw BrokerSecurityError.peerMetadataRejected
209
+ }
210
+
211
+ return AuthenticatedPeer(
212
+ effectiveUserID: audit_token_to_euid(token),
213
+ processIdentifier: audit_token_to_pid(token),
214
+ signingIdentifier: identifier,
215
+ signingTeamIdentifier: team
216
+ )
217
+ }
218
+
219
+ private func identifiersContain(_ identifier: String) -> Bool {
220
+ policy.allowedClientIdentifiers.contains(identifier)
221
+ }
222
+
223
+ private static func requirementQuoted(_ value: String) -> String {
224
+ "\"" + value.replacingOccurrences(of: "\\", with: "\\\\")
225
+ .replacingOccurrences(of: "\"", with: "\\\"") + "\""
226
+ }
227
+
228
+ private static func hasHardenedRuntime(_ information: [String: Any]) -> Bool {
229
+ guard let flags = information[kSecCodeInfoFlags as String] as? NSNumber else {
230
+ return false
231
+ }
232
+ return flags.uint32Value & UInt32(kSecCodeSignatureRuntime) != 0
233
+ }
234
+
235
+ private static func hasNoDangerousDebugOrLoaderEntitlements(
236
+ _ information: [String: Any]
237
+ ) -> Bool {
238
+ let entitlements: [String: Any]
239
+ if let values = information[kSecCodeInfoEntitlementsDict as String] as? [String: Any] {
240
+ entitlements = values
241
+ } else if information[kSecCodeInfoEntitlementsDict as String] == nil,
242
+ information[kSecCodeInfoEntitlements as String] == nil {
243
+ entitlements = [:]
244
+ } else {
245
+ // A present but non-dictionary entitlement blob is not safely
246
+ // inspectable here, so dynamic-peer admission fails closed.
247
+ return false
248
+ }
249
+ let forbidden = [
250
+ "com.apple.security.get-task-allow",
251
+ "com.apple.security.cs.disable-library-validation",
252
+ "com.apple.security.cs.allow-dyld-environment-variables",
253
+ ]
254
+ return forbidden.allSatisfy { entitlements[$0] == nil }
255
+ }
256
+ }
257
+
258
+ enum BrokerSecurityError: Error, CustomStringConvertible {
259
+ case invalidPolicy(String)
260
+ case invalidTrustFile
261
+ case peerCodeUnavailable(OSStatus)
262
+ case peerRejected(OSStatus)
263
+ case peerMetadataRejected
264
+
265
+ var description: String {
266
+ switch self {
267
+ case let .invalidPolicy(reason): "Invalid root-owned broker policy: \(reason)"
268
+ case .invalidTrustFile: "A root-owned broker trust file is missing or unsafe"
269
+ case let .peerCodeUnavailable(status): "Could not resolve the XPC peer code identity (\(status))"
270
+ case let .peerRejected(status): "The XPC peer code identity was rejected (\(status))"
271
+ case .peerMetadataRejected: "The XPC peer signing metadata was rejected"
272
+ }
273
+ }
274
+ }
@@ -0,0 +1,81 @@
1
+ import Darwin
2
+ import Foundation
3
+ import RecordingsUpdateProtocol
4
+ import RecordingsVerifierLauncher
5
+
6
+ struct ArtifactVerifierRunner {
7
+ func materialize(stagedUpdate: StagedUpdate) throws -> String {
8
+ var verifierUserID: uid_t = 0
9
+ var verifierGroupID: gid_t = 0
10
+ let lookupStatus = RecordingsUpdateConstants.artifactVerifierAccount.withCString {
11
+ recordings_lookup_verifier_account($0, &verifierUserID, &verifierGroupID)
12
+ }
13
+ guard lookupStatus == 0, verifierUserID != 0, verifierGroupID != 0 else {
14
+ throw VerifierRunnerError.invalidVerifierAccount
15
+ }
16
+ let outputPath = stagedUpdate.directory + "/verifier-output"
17
+ guard mkdir(outputPath, 0o700) == 0,
18
+ chown(outputPath, verifierUserID, verifierGroupID) == 0
19
+ else {
20
+ throw VerifierRunnerError.couldNotCreateOutput
21
+ }
22
+ let archive = Darwin.open(stagedUpdate.archive.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW)
23
+ guard archive >= 0,
24
+ DarwinACLValidator.descriptorHasNoExtendedACL(archive)
25
+ else {
26
+ if archive >= 0 { Darwin.close(archive) }
27
+ throw VerifierRunnerError.invalidStagedArchive
28
+ }
29
+ defer { Darwin.close(archive) }
30
+ let output = Darwin.open(outputPath, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)
31
+ guard output >= 0,
32
+ DarwinACLValidator.descriptorHasNoExtendedACL(output)
33
+ else {
34
+ if output >= 0 { Darwin.close(output) }
35
+ throw VerifierRunnerError.couldNotCreateOutput
36
+ }
37
+ defer { Darwin.close(output) }
38
+
39
+ let status = stagedUpdate.archive.sha256.withCString {
40
+ recordings_run_artifact_verifier(
41
+ archive,
42
+ output,
43
+ verifierUserID,
44
+ verifierGroupID,
45
+ $0
46
+ )
47
+ }
48
+ guard status == 0 else { throw VerifierRunnerError.verifierFailed(status) }
49
+ var outputMetadata = stat()
50
+ guard fchown(output, 0, 0) == 0,
51
+ fchmod(output, 0o700) == 0,
52
+ fstat(output, &outputMetadata) == 0,
53
+ (outputMetadata.st_mode & S_IFMT) == S_IFDIR,
54
+ outputMetadata.st_uid == 0,
55
+ (outputMetadata.st_mode & 0o077) == 0,
56
+ DarwinACLValidator.descriptorHasNoExtendedACL(output),
57
+ fsync(output) == 0
58
+ else {
59
+ throw VerifierRunnerError.couldNotSealOutput
60
+ }
61
+ return outputPath
62
+ }
63
+ }
64
+
65
+ enum VerifierRunnerError: Error, CustomStringConvertible {
66
+ case invalidVerifierAccount
67
+ case couldNotCreateOutput
68
+ case invalidStagedArchive
69
+ case verifierFailed(Int32)
70
+ case couldNotSealOutput
71
+
72
+ var description: String {
73
+ switch self {
74
+ case .invalidVerifierAccount: "The no-login artifact-verifier account is missing or unsafe"
75
+ case .couldNotCreateOutput: "Could not create protected verifier output"
76
+ case .invalidStagedArchive: "The protected staged package is unavailable"
77
+ case let .verifierFailed(status): "The sandboxed artifact verifier failed (\(status))"
78
+ case .couldNotSealOutput: "Could not seal verifier output for root-side validation"
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,259 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsUpdateBroker
4
+
5
+ struct ActivationRecoveryPolicyTests {
6
+ private let old = String(repeating: "a", count: 64)
7
+ private let candidate = String(repeating: "b", count: 64)
8
+
9
+ @Test("swap recovery spans rename, fsync, and phase-journal failure boundaries")
10
+ func swapFailureInjectionBoundaries() {
11
+ let beforeRename = snapshot(live: old, candidate: candidate)
12
+ #expect(commit(beforeRename) == .exchangeCandidateAndLive)
13
+
14
+ // renameatx_np(RENAME_SWAP) succeeded, but either directory fsync failed.
15
+ let afterRenameBeforeFsync = snapshot(live: candidate, candidate: old)
16
+ #expect(commit(afterRenameBeforeFsync) == .retainPrevious)
17
+
18
+ // Both directory fsyncs succeeded, but writing `swapped` failed. The exact
19
+ // namespace shape is the same and recovery retains the old tree once.
20
+ let afterFsyncBeforeSwappedJournal = snapshot(live: candidate, candidate: old)
21
+ #expect(commit(afterFsyncBeforeSwappedJournal) == .retainPrevious)
22
+
23
+ // Retaining the old app succeeded, but its fsync or `previous-retained`
24
+ // journal write failed. Recovery recognizes the durable terminal shape.
25
+ let afterRetainBeforeJournal = snapshot(live: candidate, previous: old)
26
+ #expect(commit(afterRetainBeforeJournal) == .ready)
27
+ }
28
+
29
+ @Test("rollback recovery restores the exact old digest at every swap boundary")
30
+ func rollbackFailureInjectionBoundaries() {
31
+ #expect(rollback(snapshot(live: candidate, candidate: old)) == .exchangeCandidateAndLive)
32
+ #expect(rollback(snapshot(live: candidate, previous: old)) == .exchangePreviousAndLive)
33
+
34
+ // The rollback exchange succeeded, but fsync failed before the candidate
35
+ // could be moved from the previous slot to its failed-candidate slot.
36
+ #expect(rollback(snapshot(live: old, previous: candidate)) == .retainFailedFromPrevious)
37
+
38
+ // Either rollback path is terminal only when the exact old digest is live
39
+ // and the exact candidate survives in exactly one protected slot.
40
+ #expect(rollback(snapshot(live: old, candidate: candidate)) == .ready)
41
+ #expect(rollback(snapshot(live: old, failed: candidate)) == .ready)
42
+ #expect(
43
+ rollback(snapshot(live: old, candidate: candidate, failed: candidate)) == .invalid
44
+ )
45
+ #expect(
46
+ rollback(snapshot(live: old, candidate: candidate, failed: old)) == .invalid
47
+ )
48
+ }
49
+
50
+ @Test("first install is exclusive and crash recoverable")
51
+ func firstInstallFailureInjectionBoundaries() {
52
+ #expect(
53
+ ActivationRecoveryPolicy.commitAction(
54
+ snapshot: snapshot(candidate: candidate),
55
+ previousDigest: nil,
56
+ candidateDigest: candidate
57
+ ) == .installCandidateExclusively
58
+ )
59
+ #expect(
60
+ ActivationRecoveryPolicy.commitAction(
61
+ snapshot: snapshot(live: candidate),
62
+ previousDigest: nil,
63
+ candidateDigest: candidate
64
+ ) == .ready
65
+ )
66
+ #expect(
67
+ ActivationRecoveryPolicy.commitAction(
68
+ snapshot: snapshot(live: old, candidate: candidate),
69
+ previousDigest: nil,
70
+ candidateDigest: candidate
71
+ ) == .invalid
72
+ )
73
+ }
74
+
75
+ @Test("prepared newer transaction is discarded over an older aborted barrier")
76
+ func preparedOverOlderAbortedBarrier() {
77
+ #expect(
78
+ ActivationRecoveryPolicy.preparedBarrierAction(
79
+ currentPhase: .aborted,
80
+ currentSequence: 7,
81
+ journalSequence: 8,
82
+ exactBinding: false,
83
+ sameCohort: true
84
+ ) == .discard
85
+ )
86
+ #expect(
87
+ ActivationRecoveryPolicy.preparedBarrierAction(
88
+ currentPhase: .aborted,
89
+ currentSequence: 7,
90
+ journalSequence: 8,
91
+ exactBinding: false,
92
+ sameCohort: false
93
+ ) == .invalid
94
+ )
95
+ }
96
+
97
+ @Test("rollback-started plus seen closes abort before prune across a second crash")
98
+ func rolledBackSeenBarrierSecondCrash() {
99
+ let afterRolledBackJournalBeforeAbort =
100
+ ActivationRecoveryPolicy.rolledBackBarrierAction(
101
+ currentPhase: .seen,
102
+ currentSequence: 8,
103
+ journalSequence: 8,
104
+ exactBinding: true,
105
+ sameCohort: true
106
+ )
107
+ #expect(afterRolledBackJournalBeforeAbort == .finalizeAbortThenPrune)
108
+
109
+ // If the broker crashes after persisting `rolled-back` but before the abort
110
+ // state write, startup obtains the same action and preserves the journal.
111
+ let secondStartup = ActivationRecoveryPolicy.rolledBackBarrierAction(
112
+ currentPhase: .seen,
113
+ currentSequence: 8,
114
+ journalSequence: 8,
115
+ exactBinding: true,
116
+ sameCohort: true
117
+ )
118
+ #expect(secondStartup == .finalizeAbortThenPrune)
119
+
120
+ let afterAbort = ActivationRecoveryPolicy.rolledBackBarrierAction(
121
+ currentPhase: .aborted,
122
+ currentSequence: 8,
123
+ journalSequence: 8,
124
+ exactBinding: true,
125
+ sameCohort: true
126
+ )
127
+ #expect(afterAbort == .prune)
128
+ }
129
+
130
+ @Test("bootstrap journal fsync before seen is safely discardable without state mutation")
131
+ func bootstrapAfterJournalFsyncBeforeSeen() {
132
+ #expect(
133
+ bootstrap(phase: .prepared, current: nil, exact: false, candidateMatches: false)
134
+ == .discardBeforeSeen
135
+ )
136
+ #expect(
137
+ bootstrap(phase: .aborted, current: nil, exact: false, candidateMatches: false)
138
+ == .prune
139
+ )
140
+ }
141
+
142
+ @Test("bootstrap seen state commits from its exact durable journal")
143
+ func bootstrapAfterSeenFsyncBeforeCommit() {
144
+ #expect(bootstrap(phase: .prepared, current: .seen) == .finalizeCommit)
145
+ }
146
+
147
+ @Test("a visible seen rename remains recoverable when its directory fsync failed")
148
+ func bootstrapStateRenameBeforeDirectoryFsync() {
149
+ #expect(bootstrap(phase: .prepared, current: .seen) == .finalizeCommit)
150
+ // If the rename is lost rather than visible after restart, no monotonic state
151
+ // exists and the pre-seen journal can be closed without committing anything.
152
+ #expect(
153
+ bootstrap(phase: .prepared, current: nil, exact: false, candidateMatches: false)
154
+ == .discardBeforeSeen
155
+ )
156
+ }
157
+
158
+ @Test("bootstrap recovery is idempotent across a second crash")
159
+ func bootstrapSecondCrashDuringRecovery() {
160
+ #expect(bootstrap(phase: .commitPending, current: .seen) == .finalizeCommit)
161
+ #expect(bootstrap(phase: .commitPending, current: .committed) == .validateCommitted)
162
+ #expect(bootstrap(phase: .committed, current: .committed) == .prune)
163
+ }
164
+
165
+ @Test("bootstrap recovery does not depend on replaying an expired envelope")
166
+ func bootstrapExpiredEnvelopeAfterCrash() {
167
+ // Recovery uses the root-owned journal, exact highest-seen binding, and live
168
+ // candidate digest; envelope wall-clock validity is intentionally not an input.
169
+ #expect(bootstrap(phase: .prepared, current: .seen) == .finalizeCommit)
170
+ }
171
+
172
+ @Test("bootstrap recovery rejects mismatched or non-absent prior state")
173
+ func bootstrapMismatchedJournal() {
174
+ #expect(bootstrap(phase: .prepared, current: .seen, exact: false) == .invalid)
175
+ #expect(
176
+ bootstrap(phase: .prepared, current: .seen, priorWasAbsent: false) == .invalid
177
+ )
178
+ #expect(
179
+ bootstrap(phase: .prepared, current: .seen, candidateMatches: false) == .invalid
180
+ )
181
+ }
182
+
183
+ @Test("schema-v1 activation journals without a discriminator remain activation-only")
184
+ func legacyActivationJournalSchemaMigration() throws {
185
+ let legacyJSON = Data(#"""
186
+ {
187
+ "schema_version": 1,
188
+ "transaction_id": "00000000-0000-0000-0000-000000000001",
189
+ "phase": "prepared",
190
+ "release_id": "00000000-0000-0000-0000-000000000002",
191
+ "release_sequence": 2,
192
+ "key_epoch": 1,
193
+ "envelope_payload_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
194
+ "artifact_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
195
+ "manifest_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
196
+ "cohort_package_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
197
+ "candidate_tree_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
198
+ "candidate_application_mode": 493,
199
+ "candidate_executable_modes": {
200
+ "Contents/MacOS/Recordings": 493,
201
+ "Contents/Helpers/recordings": 493,
202
+ "Contents/Helpers/recordings-update-client": 493
203
+ },
204
+ "candidate_application_path": "/Library/Application Support/Hasna/Recordings/Updates/transaction-00000000-0000-0000-0000-000000000001/candidate/Recordings.app",
205
+ "application_path": "/Applications/Recordings.app"
206
+ }
207
+ """#.utf8)
208
+ let journal = try JSONDecoder().decode(BrokerInstallJournal.self, from: legacyJSON)
209
+ #expect(journal.operation == nil)
210
+ #expect(journal.resolvedOperation == .applicationActivation)
211
+ #expect(journal.expectedPurpose == "update")
212
+ }
213
+
214
+ private func commit(_ value: ApplicationNamespaceSnapshot) -> CommitRecoveryAction {
215
+ ActivationRecoveryPolicy.commitAction(
216
+ snapshot: value,
217
+ previousDigest: old,
218
+ candidateDigest: candidate
219
+ )
220
+ }
221
+
222
+ private func rollback(_ value: ApplicationNamespaceSnapshot) -> RollbackRecoveryAction {
223
+ ActivationRecoveryPolicy.rollbackAction(
224
+ snapshot: value,
225
+ previousDigest: old,
226
+ candidateDigest: candidate
227
+ )
228
+ }
229
+
230
+ private func bootstrap(
231
+ phase: BootstrapJournalPhase,
232
+ current: MonotonicBarrierPhase?,
233
+ exact: Bool = true,
234
+ priorWasAbsent: Bool = true,
235
+ candidateMatches: Bool = true
236
+ ) -> BootstrapRecoveryAction {
237
+ BootstrapRecoveryPolicy.action(
238
+ journalPhase: phase,
239
+ currentPhase: current,
240
+ exactBinding: exact,
241
+ priorMonotonicStateWasAbsent: priorWasAbsent,
242
+ candidateMatches: candidateMatches
243
+ )
244
+ }
245
+
246
+ private func snapshot(
247
+ live: String? = nil,
248
+ candidate: String? = nil,
249
+ previous: String? = nil,
250
+ failed: String? = nil
251
+ ) -> ApplicationNamespaceSnapshot {
252
+ ApplicationNamespaceSnapshot(
253
+ live: live,
254
+ candidateSlot: candidate,
255
+ previousSlot: previous,
256
+ failedSlot: failed
257
+ )
258
+ }
259
+ }
@@ -0,0 +1,21 @@
1
+ import Testing
2
+ @testable import RecordingsUpdateBroker
3
+
4
+ struct CanonicalReleaseOrderTests {
5
+ @Test("canonical material uses unsigned UTF-8 byte ordering")
6
+ func unsignedUTF8Ordering() {
7
+ let astral = "\u{10000}"
8
+ let privateUse = "\u{e000}"
9
+ let decomposed = "e\u{301}"
10
+ let precomposed = "\u{e9}"
11
+ let values = [astral, precomposed, privateUse, "z", decomposed, "a"]
12
+ let expected = ["a", decomposed, "z", precomposed, privateUse, astral]
13
+
14
+ #expect(CanonicalReleaseOrder.sorted(values) == expected)
15
+ // Swift String equality is normalization-aware. Canonical release
16
+ // ordering intentionally compares the preserved UTF-8 bytes instead.
17
+ #expect(decomposed == precomposed)
18
+ #expect(CanonicalReleaseOrder.unsignedUTF8Precedes(decomposed, precomposed))
19
+ #expect(!CanonicalReleaseOrder.unsignedUTF8Precedes(precomposed, decomposed))
20
+ }
21
+ }