278 lines
12 KiB
Swift
278 lines
12 KiB
Swift
import CodexBarCore
|
|
import Foundation
|
|
|
|
private struct CodexPreparedImportedAccount {
|
|
let account: ManagedCodexAccount
|
|
let homeURL: URL
|
|
}
|
|
|
|
struct CodexDisplacedLivePreservationExecutionResult: Equatable {
|
|
let displacedLiveDisposition: CodexAccountPromotionResult.DisplacedLiveDisposition
|
|
}
|
|
|
|
@MainActor
|
|
struct CodexDisplacedLivePreservationExecutor {
|
|
private let store: any ManagedCodexAccountStoring
|
|
private let homeFactory: any ManagedCodexHomeProducing
|
|
private let fileManager: FileManager
|
|
|
|
init(
|
|
store: any ManagedCodexAccountStoring,
|
|
homeFactory: any ManagedCodexHomeProducing,
|
|
fileManager: FileManager = .default)
|
|
{
|
|
self.store = store
|
|
self.homeFactory = homeFactory
|
|
self.fileManager = fileManager
|
|
}
|
|
|
|
func execute(
|
|
plan: CodexDisplacedLivePreservationPlan,
|
|
context: PreparedPromotionContext) throws
|
|
-> CodexDisplacedLivePreservationExecutionResult
|
|
{
|
|
/*
|
|
Safety contract:
|
|
- This executor never swaps live auth. The caller must do that only after success.
|
|
- Import cleanup is best-effort and leaves no orphaned managed home on failure.
|
|
- Refresh/repair may copy auth before store commit, matching current behavior.
|
|
*/
|
|
switch plan {
|
|
case .none:
|
|
return CodexDisplacedLivePreservationExecutionResult(displacedLiveDisposition: .none)
|
|
|
|
case let .reject(reason):
|
|
throw self.error(for: reason)
|
|
|
|
case .importNew:
|
|
let importedAccount = try self.importDisplacedLiveAccount(from: context)
|
|
return try self.commitImportedAccount(importedAccount)
|
|
|
|
case let .refreshExisting(destination, _),
|
|
let .repairExisting(destination, _):
|
|
guard destination.persisted.id != context.target.persisted.id else {
|
|
throw CodexAccountPromotionError.managedStoreCommitFailed
|
|
}
|
|
|
|
let refreshed = try self.refreshExistingManagedAccount(destination, from: context)
|
|
return CodexDisplacedLivePreservationExecutionResult(
|
|
displacedLiveDisposition: .alreadyManaged(managedAccountID: refreshed.id))
|
|
}
|
|
}
|
|
|
|
private func error(for reason: CodexDisplacedLivePreservationRejectReason) -> CodexAccountPromotionError {
|
|
switch reason {
|
|
case .liveUnreadable:
|
|
.liveAccountUnreadable
|
|
case .liveAPIKeyOnlyUnsupported:
|
|
.liveAccountAPIKeyOnlyUnsupported
|
|
case .liveIdentityMissingForPreservation:
|
|
.liveAccountMissingIdentityForPreservation
|
|
case .conflictingReadableManagedHome:
|
|
.displacedLiveManagedAccountConflict
|
|
}
|
|
}
|
|
|
|
private func importDisplacedLiveAccount(
|
|
from context: PreparedPromotionContext) throws
|
|
-> CodexPreparedImportedAccount
|
|
{
|
|
guard case let .readable(liveAuthMaterial) = context.live.homeState else {
|
|
throw CodexAccountPromotionError.displacedLiveImportFailed
|
|
}
|
|
|
|
let importedHomeURL = self.homeFactory.makeHomeURL()
|
|
let importedAccountID = Self.accountID(for: importedHomeURL)
|
|
|
|
do {
|
|
try self.fileManager.createDirectory(at: importedHomeURL, withIntermediateDirectories: true)
|
|
try self.writeManagedAuthData(liveAuthMaterial.rawData, to: importedHomeURL)
|
|
|
|
guard let liveAuthIdentity = context.live.authIdentity,
|
|
let email = liveAuthIdentity.email,
|
|
liveAuthIdentity.identity != .unresolved
|
|
else {
|
|
throw CodexAccountPromotionError.liveAccountMissingIdentityForPreservation
|
|
}
|
|
|
|
let now = Date().timeIntervalSince1970
|
|
return CodexPreparedImportedAccount(
|
|
account: ManagedCodexAccount(
|
|
id: importedAccountID,
|
|
email: email,
|
|
providerAccountID: liveAuthIdentity.providerAccountID,
|
|
workspaceLabel: liveAuthIdentity.workspaceLabel,
|
|
workspaceAccountID: liveAuthIdentity.workspaceAccountID,
|
|
authFingerprint: CodexAuthFingerprint.fingerprint(data: liveAuthMaterial.rawData),
|
|
managedHomePath: importedHomeURL.path,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
lastAuthenticatedAt: now),
|
|
homeURL: importedHomeURL)
|
|
} catch let error as CodexAccountPromotionError {
|
|
try? self.removeManagedHomeIfSafe(importedHomeURL)
|
|
throw error
|
|
} catch {
|
|
try? self.removeManagedHomeIfSafe(importedHomeURL)
|
|
throw CodexAccountPromotionError.displacedLiveImportFailed
|
|
}
|
|
}
|
|
|
|
private func commitImportedAccount(_ importedAccount: CodexPreparedImportedAccount) throws
|
|
-> CodexDisplacedLivePreservationExecutionResult
|
|
{
|
|
do {
|
|
let latestManagedAccounts = try self.store.loadAccounts()
|
|
try self.store.storeAccounts(ManagedCodexAccountSet(
|
|
version: latestManagedAccounts.version,
|
|
accounts: latestManagedAccounts.accounts + [importedAccount.account]))
|
|
return try self.resolveImportedAccountAfterCommit(importedAccount)
|
|
} catch let error as CodexAccountPromotionError {
|
|
try? self.removeManagedHomeIfSafe(importedAccount.homeURL)
|
|
throw error
|
|
} catch {
|
|
try? self.removeManagedHomeIfSafe(importedAccount.homeURL)
|
|
throw CodexAccountPromotionError.managedStoreCommitFailed
|
|
}
|
|
}
|
|
|
|
private func resolveImportedAccountAfterCommit(_ importedAccount: CodexPreparedImportedAccount) throws
|
|
-> CodexDisplacedLivePreservationExecutionResult
|
|
{
|
|
let persistedManagedAccounts = try self.store.loadAccounts()
|
|
if persistedManagedAccounts.account(id: importedAccount.account.id) != nil {
|
|
return CodexDisplacedLivePreservationExecutionResult(
|
|
displacedLiveDisposition: .imported(managedAccountID: importedAccount.account.id))
|
|
}
|
|
|
|
guard let existingManagedAccount = self.repairDestination(
|
|
in: persistedManagedAccounts,
|
|
for: importedAccount.account)
|
|
else {
|
|
throw CodexAccountPromotionError.managedStoreCommitFailed
|
|
}
|
|
|
|
let repairedManagedAccount = ManagedCodexAccount(
|
|
id: existingManagedAccount.id,
|
|
email: importedAccount.account.email,
|
|
providerAccountID: importedAccount.account.providerAccountID,
|
|
workspaceLabel: importedAccount.account.workspaceLabel,
|
|
workspaceAccountID: importedAccount.account.workspaceAccountID,
|
|
authFingerprint: importedAccount.account.authFingerprint,
|
|
managedHomePath: importedAccount.homeURL.path,
|
|
createdAt: existingManagedAccount.createdAt,
|
|
updatedAt: importedAccount.account.updatedAt,
|
|
lastAuthenticatedAt: importedAccount.account.lastAuthenticatedAt)
|
|
try self.store.storeAccounts(ManagedCodexAccountSet(
|
|
version: persistedManagedAccounts.version,
|
|
accounts: persistedManagedAccounts.accounts.map { account in
|
|
guard account.id == existingManagedAccount.id else { return account }
|
|
return repairedManagedAccount
|
|
}))
|
|
if existingManagedAccount.managedHomePath != importedAccount.homeURL.path {
|
|
try? self.removeManagedHomeIfSafe(
|
|
URL(fileURLWithPath: existingManagedAccount.managedHomePath, isDirectory: true))
|
|
}
|
|
|
|
return CodexDisplacedLivePreservationExecutionResult(
|
|
displacedLiveDisposition: .alreadyManaged(managedAccountID: existingManagedAccount.id))
|
|
}
|
|
|
|
private func repairDestination(
|
|
in persistedManagedAccounts: ManagedCodexAccountSet,
|
|
for importedAccount: ManagedCodexAccount) -> ManagedCodexAccount?
|
|
{
|
|
if let providerAccountID = importedAccount.providerAccountID {
|
|
return persistedManagedAccounts.account(
|
|
email: importedAccount.email,
|
|
providerAccountID: providerAccountID)
|
|
}
|
|
|
|
let normalizedEmail = importedAccount.email
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
return persistedManagedAccounts.accounts.first {
|
|
$0.email == normalizedEmail && $0.providerAccountID == nil
|
|
}
|
|
}
|
|
|
|
private func refreshExistingManagedAccount(
|
|
_ destination: PreparedStoredManagedAccount,
|
|
from context: PreparedPromotionContext) throws
|
|
-> ManagedCodexAccount
|
|
{
|
|
guard case let .readable(liveAuthMaterial) = context.live.homeState else {
|
|
throw CodexAccountPromotionError.managedStoreCommitFailed
|
|
}
|
|
guard let liveAuthIdentity = context.live.authIdentity else {
|
|
throw CodexAccountPromotionError.liveAccountMissingIdentityForPreservation
|
|
}
|
|
|
|
do {
|
|
let latestManagedAccounts = try self.store.loadAccounts()
|
|
guard let persistedManagedAccount = latestManagedAccounts.account(id: destination.persisted.id) else {
|
|
throw CodexAccountPromotionError.managedStoreCommitFailed
|
|
}
|
|
|
|
let email = liveAuthIdentity.email
|
|
?? (liveAuthIdentity.providerAccountID != nil ? persistedManagedAccount.email : nil)
|
|
guard let email, liveAuthIdentity.identity != .unresolved else {
|
|
throw CodexAccountPromotionError.liveAccountMissingIdentityForPreservation
|
|
}
|
|
|
|
let now = Date().timeIntervalSince1970
|
|
let refreshedManagedAccount = ManagedCodexAccount(
|
|
id: persistedManagedAccount.id,
|
|
email: email,
|
|
providerAccountID: liveAuthIdentity.providerAccountID ?? persistedManagedAccount.providerAccountID,
|
|
workspaceLabel: liveAuthIdentity.workspaceLabel ?? persistedManagedAccount.workspaceLabel,
|
|
workspaceAccountID: liveAuthIdentity.workspaceAccountID ?? persistedManagedAccount.workspaceAccountID,
|
|
authFingerprint: CodexAuthFingerprint.fingerprint(data: liveAuthMaterial.rawData),
|
|
managedHomePath: persistedManagedAccount.managedHomePath,
|
|
createdAt: persistedManagedAccount.createdAt,
|
|
updatedAt: now,
|
|
lastAuthenticatedAt: now)
|
|
|
|
let refreshedHomeURL = URL(fileURLWithPath: persistedManagedAccount.managedHomePath, isDirectory: true)
|
|
do {
|
|
try self.homeFactory.validateManagedHomeForDeletion(refreshedHomeURL)
|
|
} catch {
|
|
throw CodexAccountPromotionError.displacedLiveImportFailed
|
|
}
|
|
|
|
try self.fileManager.createDirectory(at: refreshedHomeURL, withIntermediateDirectories: true)
|
|
try self.writeManagedAuthData(liveAuthMaterial.rawData, to: refreshedHomeURL)
|
|
try self.store.storeAccounts(ManagedCodexAccountSet(
|
|
version: latestManagedAccounts.version,
|
|
accounts: latestManagedAccounts.accounts.map { account in
|
|
guard account.id == persistedManagedAccount.id else { return account }
|
|
return refreshedManagedAccount
|
|
}))
|
|
return refreshedManagedAccount
|
|
} catch let error as CodexAccountPromotionError {
|
|
throw error
|
|
} catch {
|
|
throw CodexAccountPromotionError.managedStoreCommitFailed
|
|
}
|
|
}
|
|
|
|
private func writeManagedAuthData(_ data: Data, to homeURL: URL) throws {
|
|
let authFileURL = CodexAccountPromotionService.authFileURL(for: homeURL)
|
|
try data.write(to: authFileURL, options: .atomic)
|
|
try self.fileManager.setAttributes(
|
|
[.posixPermissions: NSNumber(value: Int16(0o600))],
|
|
ofItemAtPath: authFileURL.path)
|
|
}
|
|
|
|
private func removeManagedHomeIfSafe(_ homeURL: URL) throws {
|
|
try self.homeFactory.validateManagedHomeForDeletion(homeURL)
|
|
if self.fileManager.fileExists(atPath: homeURL.path) {
|
|
try self.fileManager.removeItem(at: homeURL)
|
|
}
|
|
}
|
|
|
|
private static func accountID(for homeURL: URL) -> UUID {
|
|
UUID(uuidString: homeURL.lastPathComponent) ?? UUID()
|
|
}
|
|
}
|