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