@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,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
|
+
}
|