@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.
- package/README.md +350 -35
- package/bun.lock +3 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
- package/dist/cli/index.js +1728 -320
- package/dist/cli/macos-permissions.d.ts +13 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -0
- package/dist/cli/options.d.ts +12 -0
- package/dist/cli/options.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +2 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/db/remote-storage.d.ts +5 -1
- package/dist/db/remote-storage.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +628 -147
- package/dist/lib/bun-runtime.d.ts +11 -0
- package/dist/lib/bun-runtime.d.ts.map +1 -0
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +10 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/install-maintenance.d.ts +24 -0
- package/dist/lib/install-maintenance.d.ts.map +1 -0
- package/dist/lib/machine.d.ts +2 -0
- package/dist/lib/machine.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts +2 -1
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/recording-create-identity.d.ts +13 -0
- package/dist/lib/recording-create-identity.d.ts.map +1 -0
- package/dist/lib/release-install-policy.d.ts +31 -0
- package/dist/lib/release-install-policy.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +977 -194
- package/dist/sdk/index.js +5 -1
- package/dist/sdk/v1.generated.d.ts +4 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud-config.d.ts +9 -0
- package/dist/server/cloud-config.d.ts.map +1 -0
- package/dist/server/cloud-readiness.d.ts +10 -0
- package/dist/server/cloud-readiness.d.ts.map +1 -0
- package/dist/server/cloud.d.ts +9 -18
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +1654 -304
- package/dist/server/migrate-command.d.ts +11 -0
- package/dist/server/migrate-command.d.ts.map +1 -0
- package/dist/server/openapi.d.ts +22 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts +8 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts +6 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage.js +515 -80
- package/dist/store.d.ts +3 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +7 -4
- package/packaging/macos/Empty.entitlements +5 -0
- package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
- package/packaging/macos/Verifier.entitlements +10 -0
- package/packaging/macos/artifact-verifier.sb +33 -0
- package/packaging/macos/build_release_pkg.sh +872 -0
- package/packaging/macos/managed_bootstrap.sh +623 -0
- package/packaging/macos/pkgutil_fingerprint.awk +37 -0
- package/packaging/macos/release_lifecycle.ts +463 -0
- package/packaging/macos/scripts/postinstall +565 -0
- package/packaging/macos/scripts/preinstall +297 -0
- package/scripts/build_companion_cli.sh +337 -0
- package/scripts/build_native_fs_guard.sh +59 -0
- package/scripts/generate-sdk.ts +19 -1
- package/scripts/install_macos_app.sh +1922 -77
- package/scripts/macos_artifact.ts +5316 -0
- package/scripts/migrate.ts +38 -10
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +1167 -0
- package/scripts/native_fs_guard.ts +158 -0
- package/scripts/release-guard.ts +20 -1
- package/scripts/resolve_tailscale_cli.sh +389 -0
- package/scripts/smoke_macos_app.sh +573 -0
- package/src/native/Recordings/App/ContentView.swift +83 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
- package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
- package/src/native/Recordings/App/RecordingsApp.swift +367 -44
- package/src/native/Recordings/App/RecordingsListView.swift +130 -0
- package/src/native/Recordings/App/RecordingsStore.swift +206 -0
- package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
- package/src/native/Recordings/App/SidebarView.swift +212 -0
- package/src/native/Recordings/App/Theme.swift +87 -0
- package/src/native/Recordings/Package.resolved +5 -5
- package/src/native/Recordings/Package.swift +62 -2
- package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
- package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
- package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
- package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
- package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
- package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
- package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
- package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
- package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
- package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
- package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
- package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
- package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
- package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
- package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
- package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
- package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
- package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
- package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
- package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
- package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
- package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
- package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
- package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
- package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
- package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
- package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
- package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
- package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
- package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
- package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
- package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
- package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
- package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
- package/src/native/Recordings/build.sh +1990 -29
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import Darwin
|
|
2
|
+
import CryptoKit
|
|
3
|
+
import Foundation
|
|
4
|
+
import RecordingsUpdateProtocol
|
|
5
|
+
import RecordingsVerifierLauncher
|
|
6
|
+
|
|
7
|
+
@main
|
|
8
|
+
enum RecordingsUpdateBrokerMain {
|
|
9
|
+
static func main() {
|
|
10
|
+
guard geteuid() == 0 else { Darwin.exit(78) }
|
|
11
|
+
do {
|
|
12
|
+
let policy = try RootTrustStore.readPolicy()
|
|
13
|
+
// Recovery runs to a terminal exact-digest state before the Mach service
|
|
14
|
+
// accepts any peer. Ambiguous journals keep launchd fail-closed.
|
|
15
|
+
try BrokerStartupRecovery.recoverInterruptedTransactions()
|
|
16
|
+
let delegate = BrokerListenerDelegate(policy: policy)
|
|
17
|
+
withExtendedLifetime(delegate) {
|
|
18
|
+
let listener = NSXPCListener(machServiceName: RecordingsUpdateConstants.machServiceName)
|
|
19
|
+
listener.delegate = delegate
|
|
20
|
+
listener.resume()
|
|
21
|
+
RunLoop.current.run()
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
Darwin.exit(78)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
final class BrokerListenerDelegate: NSObject, NSXPCListenerDelegate {
|
|
30
|
+
private let policy: BrokerPolicy
|
|
31
|
+
private let peerPolicy: PeerIdentityPolicy
|
|
32
|
+
|
|
33
|
+
init(policy: BrokerPolicy) {
|
|
34
|
+
self.policy = policy
|
|
35
|
+
peerPolicy = PeerIdentityPolicy(policy: policy)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
|
|
39
|
+
do {
|
|
40
|
+
let peer = try peerPolicy.authenticate(connection)
|
|
41
|
+
let session = UpdateBrokerSession(peer: peer, policy: policy)
|
|
42
|
+
connection.exportedInterface = makeRecordingsUpdateXPCInterface()
|
|
43
|
+
connection.exportedObject = session
|
|
44
|
+
connection.invalidationHandler = { _ = session }
|
|
45
|
+
connection.resume()
|
|
46
|
+
return true
|
|
47
|
+
} catch {
|
|
48
|
+
connection.invalidate()
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
final class UpdateBrokerSession: NSObject, RecordingsUpdateXPCProtocol {
|
|
55
|
+
private let peer: AuthenticatedPeer
|
|
56
|
+
private let policy: BrokerPolicy
|
|
57
|
+
private let queue = DispatchQueue(label: "com.hasna.recordings.updater.transaction")
|
|
58
|
+
|
|
59
|
+
init(peer: AuthenticatedPeer, policy: BrokerPolicy) {
|
|
60
|
+
self.peer = peer
|
|
61
|
+
self.policy = policy
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func install(
|
|
65
|
+
archive: FileHandle,
|
|
66
|
+
manifest: FileHandle,
|
|
67
|
+
envelope: FileHandle,
|
|
68
|
+
withReply reply: @escaping (NSDictionary) -> Void
|
|
69
|
+
) {
|
|
70
|
+
queue.async { [peer, policy] in
|
|
71
|
+
do {
|
|
72
|
+
reply(try Self.performInstall(
|
|
73
|
+
archive: archive,
|
|
74
|
+
manifest: manifest,
|
|
75
|
+
envelope: envelope,
|
|
76
|
+
peer: peer,
|
|
77
|
+
policy: policy
|
|
78
|
+
))
|
|
79
|
+
} catch {
|
|
80
|
+
if Self.requiresRecoveryRestart(after: error) {
|
|
81
|
+
// Never accept another release after an activation/journal
|
|
82
|
+
// durability failure. launchd restarts the broker, whose startup
|
|
83
|
+
// gate must reconcile exact journal and highest-seen state first.
|
|
84
|
+
Darwin.exit(75)
|
|
85
|
+
}
|
|
86
|
+
reply(Self.failureReply(for: error))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func queryStatus(withReply reply: @escaping (NSDictionary) -> Void) {
|
|
92
|
+
queue.async { [policy] in
|
|
93
|
+
do {
|
|
94
|
+
let state = try MonotonicReleaseStateStore().currentState()
|
|
95
|
+
let committed = state?.phase == "committed"
|
|
96
|
+
let statusMessage: String
|
|
97
|
+
if state == nil {
|
|
98
|
+
statusMessage = "No release committed"
|
|
99
|
+
} else if committed {
|
|
100
|
+
statusMessage = "Release committed"
|
|
101
|
+
} else if state?.phase == "aborted" {
|
|
102
|
+
statusMessage = "Release rolled back; a newer release is required"
|
|
103
|
+
} else {
|
|
104
|
+
statusMessage = "Release seen; retry or recovery required"
|
|
105
|
+
}
|
|
106
|
+
reply([
|
|
107
|
+
RecordingsUpdateReplyKey.success: true,
|
|
108
|
+
RecordingsUpdateReplyKey.code: "ok",
|
|
109
|
+
RecordingsUpdateReplyKey.message: statusMessage,
|
|
110
|
+
RecordingsUpdateReplyKey.releaseID: state?.releaseID ?? "none",
|
|
111
|
+
RecordingsUpdateReplyKey.installedVersion: committed ? (state?.version ?? "none") : "none",
|
|
112
|
+
RecordingsUpdateReplyKey.lifecycle: policy.lifecycle,
|
|
113
|
+
RecordingsUpdateReplyKey.rootMaintenanceSupported: policy.rootMaintenanceSupported,
|
|
114
|
+
RecordingsUpdateReplyKey.keyRotationSupported: policy.keyRotationSupported,
|
|
115
|
+
RecordingsUpdateReplyKey.keyEpoch: state?.keyEpoch ?? policy.initialKeyEpoch,
|
|
116
|
+
])
|
|
117
|
+
} catch {
|
|
118
|
+
reply(Self.failureReply(for: error))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private static func performInstall(
|
|
124
|
+
archive: FileHandle,
|
|
125
|
+
manifest: FileHandle,
|
|
126
|
+
envelope: FileHandle,
|
|
127
|
+
peer: AuthenticatedPeer,
|
|
128
|
+
policy: BrokerPolicy
|
|
129
|
+
) throws -> NSDictionary {
|
|
130
|
+
// Peer signing is admission control, not update authorization. A process running
|
|
131
|
+
// as the login UID can invoke the signed client; only this independently verified,
|
|
132
|
+
// monotonic signed envelope can authorize mutation.
|
|
133
|
+
guard policy.allowedClientIdentifiers.contains(peer.signingIdentifier),
|
|
134
|
+
peer.signingTeamIdentifier == policy.signingTeamIdentifier
|
|
135
|
+
else {
|
|
136
|
+
throw BrokerOperationError.unauthorizedPeer
|
|
137
|
+
}
|
|
138
|
+
let rootState = try RootUpdateState()
|
|
139
|
+
return try rootState.withExclusiveTransactionLock {
|
|
140
|
+
try rootState.ensureTransactionQuota()
|
|
141
|
+
let staged = try ArtifactIngestor(state: rootState).stage(
|
|
142
|
+
archive: archive,
|
|
143
|
+
manifest: manifest,
|
|
144
|
+
envelope: envelope
|
|
145
|
+
)
|
|
146
|
+
do {
|
|
147
|
+
let result = try performStagedInstall(staged: staged, policy: policy)
|
|
148
|
+
try cleanupIfTerminalOrUnjournaled(staged: staged, rootState: rootState)
|
|
149
|
+
return result
|
|
150
|
+
} catch {
|
|
151
|
+
if Self.requiresRecoveryRestart(after: error) {
|
|
152
|
+
// A state rename whose directory fsync failed is not durable proof
|
|
153
|
+
// of commit or abort even when an immediate reread sees the new
|
|
154
|
+
// phase. Preserve the exact journal for startup reconciliation.
|
|
155
|
+
throw error
|
|
156
|
+
}
|
|
157
|
+
do {
|
|
158
|
+
try cleanupIfTerminalOrUnjournaled(staged: staged, rootState: rootState)
|
|
159
|
+
} catch {
|
|
160
|
+
throw error
|
|
161
|
+
}
|
|
162
|
+
throw error
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private static func performStagedInstall(
|
|
168
|
+
staged: StagedUpdate,
|
|
169
|
+
policy: BrokerPolicy
|
|
170
|
+
) throws -> NSDictionary {
|
|
171
|
+
guard let envelopeData = staged.envelope.data,
|
|
172
|
+
let signedEnvelope = try? JSONDecoder().decode(SignedReleaseEnvelope.self, from: envelopeData)
|
|
173
|
+
else {
|
|
174
|
+
throw BrokerOperationError.invalidEnvelope
|
|
175
|
+
}
|
|
176
|
+
// The envelope may request only the key pinned by the immutable installed
|
|
177
|
+
// cohort. Never select a root trust file from an unverified payload field.
|
|
178
|
+
let publicKey = try RootTrustStore.readEnvelopePublicKey(epoch: policy.initialKeyEpoch)
|
|
179
|
+
let payload: ReleaseEnvelopePayload
|
|
180
|
+
do {
|
|
181
|
+
payload = try signedEnvelope.verify(publicKeyData: publicKey)
|
|
182
|
+
} catch let validationError as ReleaseEnvelopeValidationError {
|
|
183
|
+
if case .brokerTooOld = validationError {
|
|
184
|
+
throw BrokerOperationError.unsupportedLifecycle
|
|
185
|
+
}
|
|
186
|
+
throw BrokerOperationError.signatureRejected
|
|
187
|
+
} catch {
|
|
188
|
+
throw BrokerOperationError.signatureRejected
|
|
189
|
+
}
|
|
190
|
+
let hostProductVersion = try HostOSProductVersionReader.read()
|
|
191
|
+
try HostOSVersionPolicy.validate(
|
|
192
|
+
candidateMinimumOSVersion: payload.minimumOSVersion,
|
|
193
|
+
hostProductVersion: hostProductVersion
|
|
194
|
+
)
|
|
195
|
+
guard payload.keyEpoch == policy.initialKeyEpoch else {
|
|
196
|
+
throw MonotonicStateError.immutableCohortChange
|
|
197
|
+
}
|
|
198
|
+
guard payload.signingTeamIdentifier == policy.signingTeamIdentifier,
|
|
199
|
+
payload.artifactSHA256 == staged.archive.sha256,
|
|
200
|
+
payload.artifactByteCount == UInt64(staged.archive.byteCount),
|
|
201
|
+
payload.manifestSHA256 == staged.manifest.sha256,
|
|
202
|
+
payload.manifestByteCount == UInt64(staged.manifest.byteCount)
|
|
203
|
+
else {
|
|
204
|
+
throw BrokerOperationError.artifactMismatch
|
|
205
|
+
}
|
|
206
|
+
do {
|
|
207
|
+
try ReleaseCodeValidator.validateProtectedComponents(payload: payload)
|
|
208
|
+
} catch {
|
|
209
|
+
guard payload.purpose == "bootstrap" else {
|
|
210
|
+
throw BrokerOperationError.unsupportedLifecycle
|
|
211
|
+
}
|
|
212
|
+
throw error
|
|
213
|
+
}
|
|
214
|
+
let marker = try RootTrustStore.readBootstrapMarker()
|
|
215
|
+
let markerDigest = SHA256.hash(data: marker)
|
|
216
|
+
.map { String(format: "%02x", $0) }
|
|
217
|
+
.joined()
|
|
218
|
+
guard markerDigest == payload.bootstrapMarkerSHA256 else {
|
|
219
|
+
if payload.purpose == "update" {
|
|
220
|
+
throw BrokerOperationError.unsupportedLifecycle
|
|
221
|
+
}
|
|
222
|
+
throw BrokerOperationError.artifactMismatch
|
|
223
|
+
}
|
|
224
|
+
if payload.purpose == "bootstrap" {
|
|
225
|
+
_ = try ReleaseCodeValidator.validateCandidate(
|
|
226
|
+
at: RecordingsUpdateConstants.applicationPath,
|
|
227
|
+
payload: payload,
|
|
228
|
+
policy: policy
|
|
229
|
+
)
|
|
230
|
+
let stateStore = try MonotonicReleaseStateStore()
|
|
231
|
+
var requiresJournalFinalization = false
|
|
232
|
+
let result = try stateStore.perform(
|
|
233
|
+
payload: payload,
|
|
234
|
+
policy: policy,
|
|
235
|
+
prepare: { decision in
|
|
236
|
+
switch decision {
|
|
237
|
+
case .advance:
|
|
238
|
+
try prepareBootstrapCommit(staged: staged, payload: payload)
|
|
239
|
+
requiresJournalFinalization = true
|
|
240
|
+
case .resumeSeen:
|
|
241
|
+
_ = try requirePreparedBootstrapCommit(staged: staged, payload: payload)
|
|
242
|
+
requiresJournalFinalization = true
|
|
243
|
+
case .alreadyCommitted:
|
|
244
|
+
break
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
) { decision in
|
|
248
|
+
switch decision {
|
|
249
|
+
case .advance, .resumeSeen, .alreadyCommitted:
|
|
250
|
+
if requiresJournalFinalization {
|
|
251
|
+
var journal = try requirePreparedBootstrapCommit(
|
|
252
|
+
staged: staged,
|
|
253
|
+
payload: payload
|
|
254
|
+
)
|
|
255
|
+
journal.phase = "bootstrap-commit-pending"
|
|
256
|
+
try DurableInstallJournal(transactionDirectory: staged.directory).write(journal)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return updateSuccessReply(
|
|
260
|
+
transactionID: staged.transactionID.uuidString.lowercased(),
|
|
261
|
+
releaseID: payload.releaseID,
|
|
262
|
+
installedVersion: payload.version
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
if requiresJournalFinalization {
|
|
266
|
+
try finalizeBootstrapCommit(staged: staged, payload: payload, stateStore: stateStore)
|
|
267
|
+
}
|
|
268
|
+
return result
|
|
269
|
+
}
|
|
270
|
+
let verifierOutput = try ArtifactVerifierRunner().materialize(stagedUpdate: staged)
|
|
271
|
+
let candidateRoot = staged.directory + "/candidate"
|
|
272
|
+
guard mkdir(candidateRoot, 0o700) == 0 else {
|
|
273
|
+
throw BrokerOperationError.candidateRejected
|
|
274
|
+
}
|
|
275
|
+
let candidateApplication = candidateRoot + "/Recordings.app"
|
|
276
|
+
let verifierIDs = try verifierAccountIDs()
|
|
277
|
+
try CanonicalTreeCopier.copyApplication(
|
|
278
|
+
from: verifierOutput,
|
|
279
|
+
to: candidateApplication,
|
|
280
|
+
verifierUserID: verifierIDs.user
|
|
281
|
+
)
|
|
282
|
+
_ = try ReleaseCodeValidator.validateCandidate(
|
|
283
|
+
at: candidateApplication,
|
|
284
|
+
payload: payload,
|
|
285
|
+
policy: policy
|
|
286
|
+
)
|
|
287
|
+
let activator = AtomicActivator()
|
|
288
|
+
return try MonotonicReleaseStateStore().perform(
|
|
289
|
+
payload: payload,
|
|
290
|
+
policy: policy,
|
|
291
|
+
prepare: { decision in
|
|
292
|
+
switch decision {
|
|
293
|
+
case .advance:
|
|
294
|
+
try activator.prepareActivation(
|
|
295
|
+
candidatePath: candidateApplication,
|
|
296
|
+
stagedUpdate: staged,
|
|
297
|
+
payload: payload,
|
|
298
|
+
excludingAuthenticatedClientPID: peer.processIdentifier
|
|
299
|
+
)
|
|
300
|
+
case .resumeSeen:
|
|
301
|
+
try activator.prepareActivation(
|
|
302
|
+
candidatePath: candidateApplication,
|
|
303
|
+
stagedUpdate: staged,
|
|
304
|
+
payload: payload,
|
|
305
|
+
excludingAuthenticatedClientPID: peer.processIdentifier
|
|
306
|
+
)
|
|
307
|
+
case .alreadyCommitted:
|
|
308
|
+
break
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
) { decision in
|
|
312
|
+
switch decision {
|
|
313
|
+
case .alreadyCommitted:
|
|
314
|
+
_ = try ReleaseCodeValidator.validateCandidate(
|
|
315
|
+
at: RecordingsUpdateConstants.applicationPath,
|
|
316
|
+
payload: payload,
|
|
317
|
+
policy: policy
|
|
318
|
+
)
|
|
319
|
+
case .advance:
|
|
320
|
+
try activator.activatePrepared(
|
|
321
|
+
stagedUpdate: staged,
|
|
322
|
+
payload: payload,
|
|
323
|
+
excludingAuthenticatedClientPID: peer.processIdentifier
|
|
324
|
+
)
|
|
325
|
+
case .resumeSeen:
|
|
326
|
+
try activator.activatePrepared(
|
|
327
|
+
stagedUpdate: staged,
|
|
328
|
+
payload: payload,
|
|
329
|
+
excludingAuthenticatedClientPID: peer.processIdentifier
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
return updateSuccessReply(
|
|
333
|
+
transactionID: staged.transactionID.uuidString.lowercased(),
|
|
334
|
+
releaseID: payload.releaseID,
|
|
335
|
+
installedVersion: payload.version
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private static func cleanupIfTerminalOrUnjournaled(
|
|
341
|
+
staged: StagedUpdate,
|
|
342
|
+
rootState: RootUpdateState
|
|
343
|
+
) throws {
|
|
344
|
+
let journal = try DurableInstallJournal(transactionDirectory: staged.directory).read()
|
|
345
|
+
if let journal, journal.resolvedOperation == .bootstrapCommit {
|
|
346
|
+
guard journal.phase == "bootstrap-committed" else { return }
|
|
347
|
+
let state: PersistedReleaseState?
|
|
348
|
+
do {
|
|
349
|
+
state = try MonotonicReleaseStateStore().currentState()
|
|
350
|
+
} catch {
|
|
351
|
+
throw IngestError.couldNotCleanup
|
|
352
|
+
}
|
|
353
|
+
let candidateTreeSHA256: String
|
|
354
|
+
do {
|
|
355
|
+
candidateTreeSHA256 = try CanonicalTree.digest(at: journal.applicationPath)
|
|
356
|
+
} catch {
|
|
357
|
+
throw IngestError.couldNotCleanup
|
|
358
|
+
}
|
|
359
|
+
guard let state,
|
|
360
|
+
state.purpose == journal.expectedPurpose,
|
|
361
|
+
state.phase == "committed",
|
|
362
|
+
state.keyEpoch == journal.keyEpoch,
|
|
363
|
+
state.releaseSequence == journal.releaseSequence,
|
|
364
|
+
state.releaseID == journal.releaseID,
|
|
365
|
+
state.cohortPackageSHA256 == journal.cohortPackageSHA256,
|
|
366
|
+
state.envelopePayloadSHA256 == journal.envelopePayloadSHA256
|
|
367
|
+
else {
|
|
368
|
+
throw IngestError.couldNotCleanup
|
|
369
|
+
}
|
|
370
|
+
guard candidateTreeSHA256 == journal.candidateTreeSHA256 else {
|
|
371
|
+
throw IngestError.couldNotCleanup
|
|
372
|
+
}
|
|
373
|
+
try rootState.removeTransactionDirectory(id: staged.transactionID)
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
if let journal, journal.phase == "committed" {
|
|
377
|
+
let state: PersistedReleaseState?
|
|
378
|
+
do {
|
|
379
|
+
state = try MonotonicReleaseStateStore().currentState()
|
|
380
|
+
} catch {
|
|
381
|
+
throw IngestError.couldNotCleanup
|
|
382
|
+
}
|
|
383
|
+
guard let state,
|
|
384
|
+
state.phase == "committed",
|
|
385
|
+
state.keyEpoch == journal.keyEpoch,
|
|
386
|
+
state.releaseSequence == journal.releaseSequence,
|
|
387
|
+
state.releaseID == journal.releaseID,
|
|
388
|
+
state.cohortPackageSHA256 == journal.cohortPackageSHA256,
|
|
389
|
+
state.envelopePayloadSHA256 == journal.envelopePayloadSHA256
|
|
390
|
+
else {
|
|
391
|
+
// A committed physical journal with only highest-seen state is
|
|
392
|
+
// still required by startup recovery and must not be pruned.
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
} else if let journal {
|
|
396
|
+
if journal.phase != "rolled-back" { return }
|
|
397
|
+
let state: PersistedReleaseState?
|
|
398
|
+
do {
|
|
399
|
+
state = try MonotonicReleaseStateStore().currentState()
|
|
400
|
+
} catch {
|
|
401
|
+
throw IngestError.couldNotCleanup
|
|
402
|
+
}
|
|
403
|
+
if let state,
|
|
404
|
+
state.phase == "seen",
|
|
405
|
+
state.keyEpoch == journal.keyEpoch,
|
|
406
|
+
state.releaseSequence == journal.releaseSequence,
|
|
407
|
+
state.releaseID == journal.releaseID,
|
|
408
|
+
state.cohortPackageSHA256 == journal.cohortPackageSHA256,
|
|
409
|
+
state.envelopePayloadSHA256 == journal.envelopePayloadSHA256 {
|
|
410
|
+
// Recovery needs this exact rollback proof to close the seen barrier
|
|
411
|
+
// as aborted before the protected transaction can be pruned.
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
try rootState.removeTransactionDirectory(id: staged.transactionID)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private static func prepareBootstrapCommit(
|
|
419
|
+
staged: StagedUpdate,
|
|
420
|
+
payload: ReleaseEnvelopePayload
|
|
421
|
+
) throws {
|
|
422
|
+
let durableJournal = DurableInstallJournal(transactionDirectory: staged.directory)
|
|
423
|
+
if let _ = try durableJournal.read() {
|
|
424
|
+
throw InstallJournalError.invalidJournal
|
|
425
|
+
}
|
|
426
|
+
let binding = try bootstrapApplicationBinding(expectedTreeSHA256: payload.candidateTreeSHA256)
|
|
427
|
+
let journal = BrokerInstallJournal(
|
|
428
|
+
schemaVersion: RecordingsUpdateConstants.protocolVersion,
|
|
429
|
+
transactionID: staged.transactionID.uuidString.lowercased(),
|
|
430
|
+
phase: "bootstrap-prepared",
|
|
431
|
+
releaseID: payload.releaseID,
|
|
432
|
+
releaseSequence: payload.releaseSequence,
|
|
433
|
+
keyEpoch: payload.keyEpoch,
|
|
434
|
+
envelopePayloadSHA256: try envelopeDigest(payload),
|
|
435
|
+
artifactSHA256: staged.archive.sha256,
|
|
436
|
+
manifestSHA256: staged.manifest.sha256,
|
|
437
|
+
cohortPackageSHA256: payload.packageSHA256,
|
|
438
|
+
candidateTreeSHA256: binding.treeSHA256,
|
|
439
|
+
minimumOSVersion: payload.minimumOSVersion,
|
|
440
|
+
candidateApplicationMode: binding.applicationMode,
|
|
441
|
+
candidateExecutableModes: binding.executableModes,
|
|
442
|
+
previousTreeSHA256: nil,
|
|
443
|
+
previousApplicationMode: nil,
|
|
444
|
+
previousExecutableModes: nil,
|
|
445
|
+
previousApplicationPath: nil,
|
|
446
|
+
candidateApplicationPath: RecordingsUpdateConstants.applicationPath,
|
|
447
|
+
applicationPath: RecordingsUpdateConstants.applicationPath,
|
|
448
|
+
operation: .bootstrapCommit,
|
|
449
|
+
transactionDirectory: staged.directory,
|
|
450
|
+
bootstrapPriorMonotonicState: "absent"
|
|
451
|
+
)
|
|
452
|
+
try durableJournal.write(journal)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private static func requirePreparedBootstrapCommit(
|
|
456
|
+
staged: StagedUpdate,
|
|
457
|
+
payload: ReleaseEnvelopePayload
|
|
458
|
+
) throws -> BrokerInstallJournal {
|
|
459
|
+
let durableJournal = DurableInstallJournal(transactionDirectory: staged.directory)
|
|
460
|
+
let expectedEnvelopeDigest = try envelopeDigest(payload)
|
|
461
|
+
guard let journal = try durableJournal.read(),
|
|
462
|
+
journal.resolvedOperation == .bootstrapCommit,
|
|
463
|
+
journal.phase == "bootstrap-prepared" ||
|
|
464
|
+
journal.phase == "bootstrap-commit-pending",
|
|
465
|
+
journal.transactionID == staged.transactionID.uuidString.lowercased(),
|
|
466
|
+
journal.transactionDirectory == staged.directory,
|
|
467
|
+
journal.bootstrapPriorMonotonicState == "absent",
|
|
468
|
+
journal.expectedPurpose == payload.purpose,
|
|
469
|
+
journal.releaseID == payload.releaseID,
|
|
470
|
+
journal.releaseSequence == payload.releaseSequence,
|
|
471
|
+
journal.keyEpoch == payload.keyEpoch,
|
|
472
|
+
journal.artifactSHA256 == staged.archive.sha256,
|
|
473
|
+
journal.manifestSHA256 == staged.manifest.sha256,
|
|
474
|
+
journal.cohortPackageSHA256 == payload.packageSHA256,
|
|
475
|
+
journal.candidateTreeSHA256 == payload.candidateTreeSHA256,
|
|
476
|
+
journal.minimumOSVersion == payload.minimumOSVersion,
|
|
477
|
+
journal.envelopePayloadSHA256 == expectedEnvelopeDigest
|
|
478
|
+
else {
|
|
479
|
+
throw InstallJournalError.invalidJournal
|
|
480
|
+
}
|
|
481
|
+
let binding = try bootstrapApplicationBinding(expectedTreeSHA256: payload.candidateTreeSHA256)
|
|
482
|
+
guard journal.candidateApplicationMode == binding.applicationMode,
|
|
483
|
+
journal.candidateExecutableModes == binding.executableModes
|
|
484
|
+
else {
|
|
485
|
+
throw InstallJournalError.invalidJournal
|
|
486
|
+
}
|
|
487
|
+
return journal
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private static func finalizeBootstrapCommit(
|
|
491
|
+
staged: StagedUpdate,
|
|
492
|
+
payload: ReleaseEnvelopePayload,
|
|
493
|
+
stateStore: MonotonicReleaseStateStore
|
|
494
|
+
) throws {
|
|
495
|
+
var journal = try requirePreparedBootstrapCommit(staged: staged, payload: payload)
|
|
496
|
+
guard let current = try stateStore.currentState(),
|
|
497
|
+
current.purpose == journal.expectedPurpose,
|
|
498
|
+
current.phase == "committed",
|
|
499
|
+
current.keyEpoch == journal.keyEpoch,
|
|
500
|
+
current.releaseSequence == journal.releaseSequence,
|
|
501
|
+
current.releaseID == journal.releaseID,
|
|
502
|
+
current.cohortPackageSHA256 == journal.cohortPackageSHA256,
|
|
503
|
+
current.envelopePayloadSHA256 == journal.envelopePayloadSHA256
|
|
504
|
+
else {
|
|
505
|
+
throw MonotonicStateError.recoveryStateMismatch
|
|
506
|
+
}
|
|
507
|
+
journal.phase = "bootstrap-committed"
|
|
508
|
+
try DurableInstallJournal(transactionDirectory: staged.directory).write(journal)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private static func bootstrapApplicationBinding(
|
|
512
|
+
expectedTreeSHA256: String
|
|
513
|
+
) throws -> (treeSHA256: String, applicationMode: UInt16, executableModes: [String: UInt16]) {
|
|
514
|
+
let applicationPath = RecordingsUpdateConstants.applicationPath
|
|
515
|
+
let before = try CanonicalTree.digest(at: applicationPath)
|
|
516
|
+
guard before == expectedTreeSHA256 else { throw InstallJournalError.invalidJournal }
|
|
517
|
+
let applicationMode = try bootstrapMode(
|
|
518
|
+
at: applicationPath,
|
|
519
|
+
expectedType: mode_t(S_IFDIR),
|
|
520
|
+
requireExecutable: true
|
|
521
|
+
)
|
|
522
|
+
let relativeExecutables = [
|
|
523
|
+
"Contents/MacOS/Recordings",
|
|
524
|
+
"Contents/Helpers/recordings",
|
|
525
|
+
"Contents/Helpers/recordings-update-client",
|
|
526
|
+
]
|
|
527
|
+
var executableModes: [String: UInt16] = [:]
|
|
528
|
+
for relativePath in relativeExecutables {
|
|
529
|
+
executableModes[relativePath] = try bootstrapMode(
|
|
530
|
+
at: applicationPath + "/" + relativePath,
|
|
531
|
+
expectedType: mode_t(S_IFREG),
|
|
532
|
+
requireExecutable: true
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
let after = try CanonicalTree.digest(at: applicationPath)
|
|
536
|
+
guard before == after else { throw InstallJournalError.invalidJournal }
|
|
537
|
+
return (after, applicationMode, executableModes)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private static func bootstrapMode(
|
|
541
|
+
at path: String,
|
|
542
|
+
expectedType: mode_t,
|
|
543
|
+
requireExecutable: Bool
|
|
544
|
+
) throws -> UInt16 {
|
|
545
|
+
var metadata = stat()
|
|
546
|
+
guard lstat(path, &metadata) == 0,
|
|
547
|
+
(metadata.st_mode & S_IFMT) == expectedType,
|
|
548
|
+
metadata.st_uid == 0,
|
|
549
|
+
(metadata.st_mode & 0o022) == 0,
|
|
550
|
+
(!requireExecutable || (metadata.st_mode & 0o500) == 0o500),
|
|
551
|
+
DarwinACLValidator.pathHasNoExtendedACL(path, directory: expectedType == S_IFDIR)
|
|
552
|
+
else {
|
|
553
|
+
throw InstallJournalError.invalidJournal
|
|
554
|
+
}
|
|
555
|
+
return UInt16(metadata.st_mode & 0o777)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private static func envelopeDigest(_ payload: ReleaseEnvelopePayload) throws -> String {
|
|
559
|
+
try SHA256.hash(data: payload.canonicalData())
|
|
560
|
+
.map { String(format: "%02x", $0) }
|
|
561
|
+
.joined()
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private static func verifierAccountIDs() throws -> (user: uid_t, group: gid_t) {
|
|
565
|
+
var user: uid_t = 0
|
|
566
|
+
var group: gid_t = 0
|
|
567
|
+
let status = RecordingsUpdateConstants.artifactVerifierAccount.withCString {
|
|
568
|
+
recordings_lookup_verifier_account($0, &user, &group)
|
|
569
|
+
}
|
|
570
|
+
guard status == 0 else { throw BrokerOperationError.candidateRejected }
|
|
571
|
+
return (user, group)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private static func failureReply(for error: Error) -> NSDictionary {
|
|
575
|
+
switch error {
|
|
576
|
+
case is BrokerSecurityError, BrokerOperationError.unauthorizedPeer:
|
|
577
|
+
updateFailureReply(.unauthorizedPeer, message: "The update request is unauthorized")
|
|
578
|
+
case is ReleaseEnvelopeValidationError, BrokerOperationError.invalidEnvelope:
|
|
579
|
+
updateFailureReply(.invalidEnvelope, message: "The signed release envelope is invalid")
|
|
580
|
+
case BrokerOperationError.signatureRejected:
|
|
581
|
+
updateFailureReply(.signatureRejected, message: "The signed release envelope was rejected")
|
|
582
|
+
case MonotonicStateError.immutableCohortChange, BrokerOperationError.unsupportedLifecycle:
|
|
583
|
+
updateFailureReply(
|
|
584
|
+
.unsupportedLifecycle,
|
|
585
|
+
message: "This installation uses an immutable updater cohort. Only app-artifact updates are supported. Root updater or key changes require managed reprovisioning; no files were changed."
|
|
586
|
+
)
|
|
587
|
+
case is MonotonicStateError, BrokerOperationError.invalidBootstrapState:
|
|
588
|
+
updateFailureReply(.rollbackRejected, message: "The release would violate monotonic update policy")
|
|
589
|
+
case is CodeValidationError:
|
|
590
|
+
updateFailureReply(.codeIdentityRejected, message: "A signed release component was rejected")
|
|
591
|
+
case is IngestError:
|
|
592
|
+
updateFailureReply(.invalidDescriptor, message: "A retained update input was rejected")
|
|
593
|
+
case is HostOSVersionPolicyError, is HostOSProductVersionQueryError,
|
|
594
|
+
is VerifierRunnerError, is CanonicalCopyError, BrokerOperationError.candidateRejected:
|
|
595
|
+
updateFailureReply(.candidateRejected, message: "The isolated release candidate was rejected")
|
|
596
|
+
case is AtomicActivationError, is InstallJournalError:
|
|
597
|
+
updateFailureReply(.activationFailed, message: "The transactional application activation failed")
|
|
598
|
+
default:
|
|
599
|
+
updateFailureReply(.internalFailure, message: "The update broker failed closed")
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private static func requiresRecoveryRestart(after error: Error) -> Bool {
|
|
604
|
+
if error is AtomicActivationError || error is InstallJournalError {
|
|
605
|
+
return true
|
|
606
|
+
}
|
|
607
|
+
if let ingestError = error as? IngestError, case .couldNotCleanup = ingestError {
|
|
608
|
+
return true
|
|
609
|
+
}
|
|
610
|
+
guard let stateError = error as? MonotonicStateError else { return false }
|
|
611
|
+
if case .couldNotPersist = stateError { return true }
|
|
612
|
+
return false
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
enum BrokerOperationError: Error {
|
|
617
|
+
case unauthorizedPeer
|
|
618
|
+
case invalidEnvelope
|
|
619
|
+
case signatureRejected
|
|
620
|
+
case artifactMismatch
|
|
621
|
+
case candidateRejected
|
|
622
|
+
case invalidBootstrapState
|
|
623
|
+
case unsupportedLifecycle
|
|
624
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import Darwin
|
|
2
|
+
import Foundation
|
|
3
|
+
import RecordingsVerifierLauncher
|
|
4
|
+
|
|
5
|
+
enum CanonicalTreeCopier {
|
|
6
|
+
static func copyApplication(
|
|
7
|
+
from verifierRoot: String,
|
|
8
|
+
to rootCandidatePath: String,
|
|
9
|
+
verifierUserID: uid_t
|
|
10
|
+
) throws {
|
|
11
|
+
let source = Darwin.open(verifierRoot, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)
|
|
12
|
+
var sourceMetadata = stat()
|
|
13
|
+
guard source >= 0,
|
|
14
|
+
fstat(source, &sourceMetadata) == 0,
|
|
15
|
+
(sourceMetadata.st_mode & S_IFMT) == S_IFDIR,
|
|
16
|
+
sourceMetadata.st_uid == 0,
|
|
17
|
+
(sourceMetadata.st_mode & 0o077) == 0,
|
|
18
|
+
DarwinACLValidator.descriptorHasNoExtendedACL(source)
|
|
19
|
+
else {
|
|
20
|
+
if source >= 0 { Darwin.close(source) }
|
|
21
|
+
throw CanonicalCopyError.unsafeSourceEntry
|
|
22
|
+
}
|
|
23
|
+
defer { Darwin.close(source) }
|
|
24
|
+
let destinationRoot = URL(fileURLWithPath: rootCandidatePath).deletingLastPathComponent().path
|
|
25
|
+
guard URL(fileURLWithPath: rootCandidatePath).lastPathComponent == "Recordings.app" else {
|
|
26
|
+
throw CanonicalCopyError.nonCanonicalRoot
|
|
27
|
+
}
|
|
28
|
+
let destination = Darwin.open(
|
|
29
|
+
destinationRoot,
|
|
30
|
+
O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW
|
|
31
|
+
)
|
|
32
|
+
var destinationMetadata = stat()
|
|
33
|
+
guard destination >= 0,
|
|
34
|
+
fstat(destination, &destinationMetadata) == 0,
|
|
35
|
+
(destinationMetadata.st_mode & S_IFMT) == S_IFDIR,
|
|
36
|
+
destinationMetadata.st_uid == 0,
|
|
37
|
+
(destinationMetadata.st_mode & 0o077) == 0,
|
|
38
|
+
DarwinACLValidator.descriptorHasNoExtendedACL(destination)
|
|
39
|
+
else {
|
|
40
|
+
if destination >= 0 { Darwin.close(destination) }
|
|
41
|
+
throw CanonicalCopyError.couldNotCreateDestination
|
|
42
|
+
}
|
|
43
|
+
defer { Darwin.close(destination) }
|
|
44
|
+
let status = recordings_copy_canonical_application_tree(source, destination, verifierUserID)
|
|
45
|
+
guard status == 0 else { throw CanonicalCopyError.copyRejected(status) }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
enum CanonicalCopyError: Error, CustomStringConvertible {
|
|
50
|
+
case nonCanonicalRoot
|
|
51
|
+
case unsafeSourceEntry
|
|
52
|
+
case couldNotCreateDestination
|
|
53
|
+
case copyRejected(Int32)
|
|
54
|
+
|
|
55
|
+
var description: String {
|
|
56
|
+
switch self {
|
|
57
|
+
case .nonCanonicalRoot: "The verifier output does not contain the canonical application root"
|
|
58
|
+
case .unsafeSourceEntry: "The verifier output directory is unsafe"
|
|
59
|
+
case .couldNotCreateDestination: "The root-owned candidate directory is unsafe"
|
|
60
|
+
case let .copyRejected(status): "Descriptor-relative candidate copying was rejected (\(status))"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|