Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift
Peter Steinberger 374007083a fix(mac): adopt CLI identity during state migration (#121313)
* fix(identity): one canonical device-identity contract across app and CLI

* fix(ci): satisfy native identity checks

* chore: drop changelog edit (release-generation owns CHANGELOG.md)
2026-08-09 20:47:21 -07:00

1262 lines
56 KiB
Swift

import CryptoKit
import Darwin
import Foundation
import SQLite3
import Testing
@testable import OpenClawKit
private final class DeviceIdentityMigrationFixture {
let root: URL
let destination: URL
let databaseURL: URL
init(destinationName: String = "destination", databasePath: String = "openclaw.sqlite") {
self.root = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
self.destination = self.root.appendingPathComponent(destinationName, isDirectory: true)
self.databaseURL = self.destination.appendingPathComponent(databasePath, isDirectory: false)
}
deinit {
try? FileManager.default.removeItem(at: self.root)
}
func url(_ path: String, isDirectory: Bool = true) -> URL {
self.root.appendingPathComponent(path, isDirectory: isDirectory)
}
func source(
_ name: String = "legacy",
profile: GatewayDeviceIdentityProfile = .primary,
contents: String? = nil) throws -> DeviceIdentityPaths.LegacyIdentitySource
{
try DeviceIdentityStoreTests.writeLegacyIdentity(
stateDirURL: self.url(name),
profile: profile,
contents: contents ?? DeviceIdentityStoreTests.nodePEMIdentityJSON())
}
func claimURL(
for source: DeviceIdentityPaths.LegacyIdentitySource,
suffix: String = ".native-importing") -> URL
{
URL(fileURLWithPath: source.identityURL.path + suffix, isDirectory: false)
}
func load(
profile: GatewayDeviceIdentityProfile = .primary,
sources: [DeviceIdentityPaths.LegacyIdentitySource] = [],
beforeLegacyClaim: ((DeviceIdentityPaths.LegacyIdentitySource) throws -> Void)? = nil,
afterLegacyCommit: (() throws -> Void)? = nil) throws -> DeviceIdentity
{
try DeviceIdentitySQLiteStore.loadOrCreate(
databaseURL: self.databaseURL,
destinationStateDirURL: self.destination,
profile: profile,
legacySources: sources,
beforeLegacyClaim: beforeLegacyClaim,
afterLegacyCommit: afterLegacyCommit)
}
}
private func deviceAuthEntry(
_ deviceID: String,
role: String = "node",
owner: String? = nil,
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
{
DeviceAuthStore.loadToken(
deviceId: deviceID,
role: role,
gatewayID: owner,
profile: profile)
}
@Suite(.serialized)
struct DeviceIdentityStoreTests {
@Test func `process state root configures once before identity use`() {
var state = DeviceIdentityStateRootState()
let work = URL(fileURLWithPath: "/Users/test/.openclaw-work", isDirectory: true)
let other = URL(fileURLWithPath: "/Users/test/.openclaw-other", isDirectory: true)
let configuredWork = state.configure(work)
let reconfiguredWork = state.configure(work)
let configuredOther = state.configure(other)
let resolvedWork = state.resolve()
let configuredWorkAfterUse = state.configure(work)
let configuredOtherAfterUse = state.configure(other)
#expect(configuredWork)
#expect(reconfiguredWork)
#expect(!configuredOther)
#expect(resolvedWork == work)
#expect(configuredWorkAfterUse)
#expect(!configuredOtherAfterUse)
var usedDefault = DeviceIdentityStateRootState()
let resolvedDefault = usedDefault.resolve()
let configuredDefaultAfterUse = usedDefault.configure(work)
#expect(resolvedDefault == nil)
#expect(!configuredDefaultAfterUse)
}
@Test
func `task scoped state directories isolate concurrent identity stores`() async throws {
let fixture = DeviceIdentityMigrationFixture()
let root = fixture.root
let stateDirectories = [
root.appendingPathComponent("a", isDirectory: true),
root.appendingPathComponent("b", isDirectory: true),
]
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let observed = await withTaskGroup(of: String.self) { group in
for stateDirectory in stateDirectories {
group.addTask {
await DeviceIdentityStore.withStateDirectory(stateDirectory) {
let identity = DeviceIdentityStore.loadOrCreate()
await Task.yield()
#expect(DeviceIdentityStore.loadOrCreate().deviceId == identity.deviceId)
return identity.deviceId
}
}
}
return await group.reduce(into: Set<String>()) { result, deviceId in
result.insert(deviceId)
}
}
#expect(observed.count == stateDirectories.count)
for stateDirectory in stateDirectories {
#expect(FileManager.default.fileExists(
atPath: stateDirectory.appendingPathComponent("state/openclaw.sqlite").path))
}
}
@Test(.stateDirectoryIsolated)
func `device auth store reports failed durable writes`() throws {
let fixture = DeviceIdentityMigrationFixture()
let tempDir = fixture.root
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let blocker = tempDir.appendingPathComponent("not-a-directory", isDirectory: false)
try Data().write(to: blocker)
// Repoint the pinned state dir at a plain file to force write failures;
// the isolation trait restores the env var after the test.
setenv("OPENCLAW_STATE_DIR", blocker.path, 1)
let compatibleEntry: DeviceAuthEntry = DeviceAuthStore.storeToken(
deviceId: "unwritable-device",
role: "node",
token: "must-not-be-acknowledged")
let stored = DeviceAuthStore.storeTokenResult(
deviceId: "unwritable-device",
role: "node",
token: "must-not-be-acknowledged")
let publicWritePersisted = DeviceAuthStore.storeTokenPersisted(
deviceId: "unwritable-device",
role: "node",
token: "also-must-not-be-acknowledged")
let durableIdentity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary)
#expect(compatibleEntry.token == "must-not-be-acknowledged")
#expect(!stored.persisted)
#expect(!publicWritePersisted)
#expect(durableIdentity == nil)
#expect(deviceAuthEntry("unwritable-device") == nil)
}
@Test
func `device auth entry round-trips epoch milliseconds beyond Int32`() throws {
let epochMilliseconds: Int64 = 1_800_000_000_000
let entry = DeviceAuthEntry(
token: "device-token",
role: "node",
scopes: [],
updatedAtMs: epochMilliseconds)
let data = try JSONEncoder().encode(entry)
let decoded = try JSONDecoder().decode(DeviceAuthEntry.self, from: data)
#expect(decoded.updatedAtMs == epochMilliseconds)
}
@Test
func `durable identity creation verifies persisted key material`() throws {
let fixture = DeviceIdentityMigrationFixture()
let identity = try fixture.load()
let reloaded = try fixture.load()
#expect(reloaded.deviceId == identity.deviceId)
#expect(reloaded.publicKey == identity.publicKey)
#expect(reloaded.privateKey == identity.privateKey)
}
@Test(.stateDirectoryIsolated)
func `device auth tokens are isolated by gateway owner`() {
let deviceID = "test-device"
_ = DeviceAuthStore.storeToken(deviceId: deviceID, role: "node", token: "legacy-token")
#expect(deviceAuthEntry(deviceID, owner: "gateway-a") == nil)
_ = DeviceAuthStore.storeToken(
deviceId: deviceID,
role: "node",
token: "gateway-a-token",
gatewayID: "gateway-a")
_ = DeviceAuthStore.storeToken(
deviceId: deviceID,
role: "node",
token: "gateway-b-token",
gatewayID: "gateway-b")
#expect(deviceAuthEntry(deviceID)?.token == "legacy-token")
#expect(deviceAuthEntry(deviceID, owner: "gateway-a")?.token == "gateway-a-token")
#expect(deviceAuthEntry(deviceID, owner: "gateway-b")?.token == "gateway-b-token")
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node", gatewayID: "gateway-b")
#expect(deviceAuthEntry(deviceID, owner: "gateway-a")?.token == "gateway-a-token")
#expect(deviceAuthEntry(deviceID, owner: "gateway-b") == nil)
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node")
#expect(deviceAuthEntry(deviceID) == nil)
#expect(deviceAuthEntry(deviceID, owner: "gateway-a") == nil)
}
@Test(.stateDirectoryIsolated)
func `device auth owners preserve exact unicode bytes`() throws {
let deviceID = "exact-owner-device"
let composedOwner = "gateway-\u{00E9}"
let decomposedOwner = "gateway-e\u{0301}"
let nextLineOwner = "\u{0085}gateway"
#expect(composedOwner == decomposedOwner)
#expect(!DeviceAuthStore.storeTokenPersisted(
deviceId: deviceID,
role: "node",
token: "must-not-become-unscoped",
gatewayID: ""))
#expect(deviceAuthEntry(deviceID) == nil)
for (owner, token) in [
(composedOwner, "composed-token"),
(decomposedOwner, "decomposed-token"),
(nextLineOwner, "next-line-token"),
] {
#expect(DeviceAuthStore.storeTokenPersisted(
deviceId: deviceID,
role: "node",
token: token,
gatewayID: owner))
}
#expect(deviceAuthEntry(deviceID, owner: composedOwner)?.token == "composed-token")
#expect(deviceAuthEntry(deviceID, owner: decomposedOwner)?.token == "decomposed-token")
#expect(deviceAuthEntry(deviceID, owner: nextLineOwner)?.token == "next-line-token")
let stateDirPath = try #require(getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) })
let stateDirURL = URL(fileURLWithPath: stateDirPath, isDirectory: true)
#expect(try Self.scalarInt(
stateDirURL.appendingPathComponent("state/openclaw.sqlite"),
"SELECT COUNT(*) FROM device_auth_tokens WHERE device_id = '\(deviceID)'") == 3)
#expect(!FileManager.default.fileExists(
atPath: stateDirURL.appendingPathComponent("identity/device-auth.json").path))
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node", gatewayID: decomposedOwner)
#expect(deviceAuthEntry(deviceID, owner: composedOwner)?.token == "composed-token")
#expect(deviceAuthEntry(deviceID, owner: decomposedOwner) == nil)
}
@Test(.stateDirectoryIsolated)
func `legacy raw owner keys migrate without canonical aliasing`() throws {
let deviceID = "legacy-exact-owner-device"
let composedOwner = "gateway-\u{00E9}"
let decomposedOwner = "gateway-e\u{0301}"
let stateDirPath = try #require(getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) })
let identityURL = URL(fileURLWithPath: stateDirPath, isDirectory: true)
.appendingPathComponent("identity", isDirectory: true)
let authURL = identityURL.appendingPathComponent("device-auth.json", isDirectory: false)
try FileManager.default.createDirectory(at: identityURL, withIntermediateDirectories: true)
let legacy: [String: Any] = [
"version": 1,
"deviceId": deviceID,
"tokens": [
"\(composedOwner)\u{1F}node": [
"token": "legacy-composed-token",
"role": "node",
"scopes": [],
"updatedAtMs": 1,
"gatewayID": composedOwner,
],
],
]
try JSONSerialization.data(withJSONObject: legacy).write(to: authURL, options: [.atomic])
#expect(deviceAuthEntry(deviceID, owner: composedOwner)?.token == "legacy-composed-token")
#expect(DeviceAuthStore.storeTokenPersisted(
deviceId: deviceID,
role: "node",
token: "new-decomposed-token",
gatewayID: decomposedOwner))
#expect(deviceAuthEntry(deviceID, owner: composedOwner)?.token == "legacy-composed-token")
#expect(deviceAuthEntry(deviceID, owner: decomposedOwner)?.token == "new-decomposed-token")
}
@Test(.stateDirectoryIsolated)
func `legacy device auth migration claims only the proven role`() {
let deviceID = "legacy-device"
_ = DeviceAuthStore.storeToken(
deviceId: deviceID,
role: "node",
token: "legacy-node-token")
_ = DeviceAuthStore.storeToken(
deviceId: deviceID,
role: "operator",
token: "legacy-operator-token")
#expect(DeviceAuthStore.migrateUnscopedToken(
deviceId: deviceID,
role: "node",
toGatewayID: "trusted-gateway"))
#expect(deviceAuthEntry(deviceID) == nil)
#expect(deviceAuthEntry(deviceID, owner: "trusted-gateway")?.token == "legacy-node-token")
#expect(deviceAuthEntry(deviceID, role: "operator", owner: "trusted-gateway") == nil)
#expect(deviceAuthEntry(deviceID, role: "operator")?.token == "legacy-operator-token")
#expect(deviceAuthEntry(deviceID, owner: "other-gateway") == nil)
#expect(!DeviceAuthStore.migrateUnscopedToken(
deviceId: deviceID,
role: "node",
toGatewayID: "other-gateway"))
_ = DeviceAuthStore.storeToken(
deviceId: deviceID,
role: "node",
token: "ambiguous-legacy-token")
#expect(DeviceAuthStore.discardUnscopedTokens(deviceId: deviceID) == 2)
#expect(deviceAuthEntry(deviceID) == nil)
#expect(deviceAuthEntry(deviceID, owner: "trusted-gateway")?.token == "legacy-node-token")
}
@Test
func `state directory override wins over shared app group storage`() {
let fixture = DeviceIdentityMigrationFixture()
let overrideURL = fixture.url("override")
let legacyURL = fixture.url("legacy")
let sharedURL = fixture.url("shared")
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: overrideURL,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
temporaryDirectory: fixture.root)
#expect(selected == overrideURL)
#expect(!FileManager.default.fileExists(atPath: sharedURL.path))
}
@Test
func `shared app group storage wins over legacy app support storage`() throws {
let fixture = DeviceIdentityMigrationFixture()
let legacyURL = fixture.url("legacy")
let sharedURL = fixture.url("shared")
let legacyIdentityURL = legacyURL.appendingPathComponent("identity", isDirectory: true)
let legacyDeviceURL = legacyIdentityURL.appendingPathComponent("device.json", isDirectory: false)
let sharedIdentityURL = sharedURL.appendingPathComponent("identity", isDirectory: true)
let sharedDeviceURL = sharedIdentityURL.appendingPathComponent("device.json", isDirectory: false)
try FileManager.default.createDirectory(at: legacyIdentityURL, withIntermediateDirectories: true)
try "legacy-device\n".write(to: legacyDeviceURL, atomically: true, encoding: .utf8)
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: nil,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
temporaryDirectory: fixture.root)
#expect(selected == sharedURL)
#expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path))
}
@Test
func `legacy app support storage wins when app group storage is not available`() {
let fixture = DeviceIdentityMigrationFixture()
let legacyURL = fixture.url("legacy")
let sharedURL = fixture.url("shared")
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: nil,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
appGroupStateDirAvailable: false,
temporaryDirectory: fixture.root)
#expect(selected == legacyURL)
}
@Test
func `task scoped state directory never probes machine legacy roots`() async {
let fixture = DeviceIdentityMigrationFixture()
let scopedURL = fixture.url("scoped")
await DeviceIdentityStore.withStateDirectory(scopedURL) {
let sources = DeviceIdentityPaths.legacyIdentitySources(profile: .primary)
#expect(sources.map(\.stateDirURL) == [scopedURL.standardizedFileURL])
}
}
@Test(.stateDirectoryIsolated)
func `secondary profiles use separate identity and auth rows`() throws {
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
let nodeIdentity = DeviceIdentityStore.loadOrCreate(profile: .node)
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "primary-token")
_ = DeviceAuthStore.storeToken(
deviceId: nodeIdentity.deviceId,
role: "node",
token: "node-token",
profile: .node)
_ = DeviceAuthStore.storeToken(
deviceId: shareIdentity.deviceId,
role: "node",
token: "share-token",
profile: .shareExtension)
// getenv, not ProcessInfo: the trait pins OPENCLAW_STATE_DIR via setenv and
// ProcessInfo.environment can serve a stale snapshot on Darwin.
let stateDirPath = try #require(getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) })
let stateDir = URL(fileURLWithPath: stateDirPath, isDirectory: true)
let identityDir = stateDir.appendingPathComponent("identity", isDirectory: true)
#expect(primaryIdentity.deviceId != nodeIdentity.deviceId)
#expect(primaryIdentity.deviceId != shareIdentity.deviceId)
#expect(try Self.scalarInt(
stateDir.appendingPathComponent("state/openclaw.sqlite"),
"SELECT COUNT(*) FROM device_identities") == 3)
#expect(try Self.scalarInt(
stateDir.appendingPathComponent("state/openclaw.sqlite"),
"SELECT COUNT(*) FROM device_auth_tokens") == 3)
#expect(!FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path))
#expect(!FileManager.default
.fileExists(atPath: identityDir.appendingPathComponent("node-device-auth.json").path))
#expect(!FileManager.default
.fileExists(atPath: identityDir.appendingPathComponent("share-device-auth.json").path))
#expect(deviceAuthEntry(primaryIdentity.deviceId)?.token == "primary-token")
#expect(deviceAuthEntry(nodeIdentity.deviceId, profile: .node)?.token == "node-token")
#expect(
deviceAuthEntry(shareIdentity.deviceId, profile: .shareExtension)?.token ==
"share-token")
DeviceAuthStore.clearAll(profile: .shareExtension)
#expect(deviceAuthEntry(primaryIdentity.deviceId)?.token == "primary-token")
#expect(deviceAuthEntry(nodeIdentity.deviceId, profile: .node)?.token == "node-token")
#expect(deviceAuthEntry(shareIdentity.deviceId, profile: .shareExtension) == nil)
}
@Test
func `fresh database creates only canonical identity schema and leaves user version zero`() throws {
let fixture = DeviceIdentityMigrationFixture()
_ = try fixture.load()
#expect(try Self.scalarInt(fixture.databaseURL, "PRAGMA user_version") == 0)
#expect(try Self.scalarText(
fixture.databaseURL,
"""
SELECT group_concat(type || ':' || name, ',')
FROM (SELECT type, name FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name)
""") == "index:idx_device_identities_device,table:device_identities")
let directoryMode = try #require(
FileManager.default.attributesOfItem(atPath: fixture.destination.path)[.posixPermissions] as? NSNumber)
let databaseMode = try #require(
FileManager.default.attributesOfItem(atPath: fixture.databaseURL.path)[.posixPermissions] as? NSNumber)
#expect(directoryMode.intValue & 0o777 == 0o700)
#expect(databaseMode.intValue & 0o777 == 0o600)
let coordinatorURLs = DeviceIdentitySQLiteStore.resolveDeviceIdentityCoordinatorURLs(
databaseURL: fixture.databaseURL,
destinationStateDirURL: fixture.destination,
temporaryDirectory: FileManager.default.temporaryDirectory,
uid: getuid())
#expect(coordinatorURLs.count == 2)
for coordinatorURL in coordinatorURLs {
let coordinatorDirectoryMode = try #require(
FileManager.default.attributesOfItem(
atPath: coordinatorURL.deletingLastPathComponent().path)[.posixPermissions] as? NSNumber)
let coordinatorFileMode = try #require(
FileManager.default.attributesOfItem(atPath: coordinatorURL.path)[.posixPermissions] as? NSNumber)
#expect(coordinatorDirectoryMode.intValue & 0o777 == 0o700)
#expect(coordinatorFileMode.intValue & 0o777 == 0o600)
}
}
@Test
func `Node PEM fixture repairs a stale device id and remains signing compatible`() throws {
let fixture = DeviceIdentityMigrationFixture()
let source = try fixture.source(
"Application Support/OpenClaw",
contents: Self.nodePEMIdentityJSON(deviceId: "stale-device-id"))
let identity = try fixture.load(sources: [source])
#expect(identity.deviceId == Self.fixtureDeviceID)
#expect(identity.publicKey == Self.fixturePublicKeyRaw)
#expect(identity.privateKey == Self.fixturePrivateKeyRaw)
#expect(identity.createdAtMs == 1_800_000_000_000)
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(try Self.scalarText(
fixture.databaseURL,
"SELECT public_key_pem FROM device_identities WHERE identity_key = 'primary'") == Self.fixturePublicKeyPEM)
#expect(try Self.scalarText(
fixture.databaseURL,
"SELECT private_key_pem FROM device_identities WHERE identity_key = 'primary'") == Self
.fixturePrivateKeyPEM)
#expect(DeviceIdentityStore.publicKeyBase64Url(identity) == "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg")
let signature = try #require(DeviceIdentityStore.signPayload("hello", identity: identity))
let publicKey = try Curve25519.Signing.PublicKey(
rawRepresentation: #require(Data(base64Encoded: identity.publicKey)))
#expect(try publicKey.isValidSignature(
#require(Self.base64UrlDecode(signature)),
for: Data("hello".utf8)))
}
@Test
func `reads a canonical Node SQLite row without rewriting it`() throws {
let fixture = DeviceIdentityMigrationFixture()
try Self.seedCanonicalSchema(fixture.databaseURL, nodeOwned: true)
try Self.execute(fixture.databaseURL, """
INSERT INTO device_identities (
identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
) VALUES (
'node', '\(Self.fixtureDeviceID)', '\(Self.sql(Self.fixturePublicKeyPEM))',
'\(Self.sql(Self.fixturePrivateKeyPEM))', 1800000000000, 1800000000123
)
""")
let identity = try fixture.load(profile: .node)
#expect(identity.deviceId == Self.fixtureDeviceID)
#expect(identity.createdAtMs == 1_800_000_000_000)
#expect(try Self.scalarInt(
fixture.databaseURL,
"SELECT updated_at_ms FROM device_identities WHERE identity_key = 'node'") == 1_800_000_000_123)
}
@Test
func `adopts a Node row and creates primary while the global version zero migration is pending`() throws {
let fixture = DeviceIdentityMigrationFixture()
try Self.seedCanonicalSchema(fixture.databaseURL)
try Self.execute(fixture.databaseURL, """
CREATE TABLE plugin_state_entries (entry_key TEXT NOT NULL PRIMARY KEY) STRICT;
INSERT INTO device_identities (
identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
) VALUES (
'node', '\(Self.fixtureDeviceID)', '\(Self.sql(Self.fixturePublicKeyPEM))',
'\(Self.sql(Self.fixturePrivateKeyPEM))', 1800000000000, 1800000000123
)
""")
let nodeIdentity = try fixture.load(profile: .node)
let primaryIdentity = try fixture.load()
#expect(nodeIdentity.deviceId == Self.fixtureDeviceID)
#expect(nodeIdentity.createdAtMs == 1_800_000_000_000)
#expect(primaryIdentity.deviceId != nodeIdentity.deviceId)
#expect(try Self.scalarInt(fixture.databaseURL, "PRAGMA user_version") == 0)
#expect(try Self.scalarInt(
fixture.databaseURL,
"SELECT updated_at_ms FROM device_identities WHERE identity_key = 'node'") == 1_800_000_000_123)
#expect(try Self.scalarInt(fixture.databaseURL, "SELECT COUNT(*) FROM device_identities") == 2)
}
@Test
func `gateway identity failure names corrupt row and state directory without rotating it`() async throws {
let fixture = DeviceIdentityMigrationFixture(databasePath: "state/openclaw.sqlite")
try Self.seedCanonicalSchema(fixture.databaseURL)
try Self.execute(fixture.databaseURL, """
INSERT INTO device_identities (
identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
) VALUES (
'primary', '\(Self.fixtureDeviceID)', '\(Self.sql(Self.fixturePublicKeyPEM))',
'not-a-private-key', 1800000000000, 1800000000123
)
""")
await DeviceIdentityStore.withStateDirectory(fixture.destination) {
do {
_ = try GatewayChannelActor.loadDeviceIdentityForConnect(
includeDeviceIdentity: true,
profile: .primary)
Issue.record("corrupt identity unexpectedly loaded")
} catch {
let message = error.localizedDescription
#expect(message.contains("Could not access the persisted device identity"))
#expect(message.contains("row \"primary\""))
#expect(message.contains(fixture.destination.path))
#expect(message.contains("invalid key material"))
}
}
#expect(try Self.scalarText(
fixture.databaseURL,
"SELECT private_key_pem FROM device_identities WHERE identity_key = 'primary'") ==
"not-a-private-key")
}
@Test
func `same key migration preserves the authoritative SQLite timestamp`() throws {
let fixture = DeviceIdentityMigrationFixture()
let source = try fixture.source()
let identity = try fixture.load(
sources: [source],
beforeLegacyClaim: { _ in
try Self.seedCanonicalSchema(fixture.databaseURL, nodeOwned: true)
try Self.execute(fixture.databaseURL, """
INSERT INTO device_identities (
identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
) VALUES (
'primary', '\(Self.fixtureDeviceID)', '\(Self.sql(Self.fixturePublicKeyPEM))',
'\(Self.sql(Self.fixturePrivateKeyPEM))', 1700000000000, 1700000000123
)
""")
})
#expect(identity.deviceId == Self.fixtureDeviceID)
#expect(identity.createdAtMs == 1_700_000_000_000)
#expect(try Self.scalarInt(
fixture.databaseURL,
"SELECT updated_at_ms FROM device_identities WHERE identity_key = 'primary'") == 1_700_000_000_123)
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
}
@Test(arguments: [GatewayDeviceIdentityProfile.primary, .node])
func `canonical SQLite identity ignores a recreated retired file`(
profile: GatewayDeviceIdentityProfile) throws
{
let fixture = DeviceIdentityMigrationFixture()
let canonical = try fixture.load(profile: profile)
let createdAt = try Self.scalarInt(
fixture.databaseURL,
"SELECT created_at_ms FROM device_identities WHERE identity_key = '\(profile.rawValue)'")
let updatedAt = try Self.scalarInt(
fixture.databaseURL,
"SELECT updated_at_ms FROM device_identities WHERE identity_key = '\(profile.rawValue)'")
let source = try fixture.source(profile: profile)
let legacyBytes = try Data(contentsOf: source.identityURL)
let loaded = try fixture.load(
profile: profile,
sources: [source],
beforeLegacyClaim: { _ in Issue.record("canonical identity inspected a retired source") })
#expect(loaded == canonical)
#expect(canonical.deviceId != Self.fixtureDeviceID)
#expect(try Self.scalarInt(
fixture.databaseURL,
"SELECT created_at_ms FROM device_identities WHERE identity_key = '\(profile.rawValue)'") == createdAt)
#expect(try Self.scalarInt(
fixture.databaseURL,
"SELECT updated_at_ms FROM device_identities WHERE identity_key = '\(profile.rawValue)'") == updatedAt)
#expect(try Data(contentsOf: source.identityURL) == legacyBytes)
#expect(!FileManager.default.fileExists(atPath: fixture.claimURL(for: source).path))
}
@Test
func `strict legacy validation preserves invalid source and creates no row`() throws {
let fixture = DeviceIdentityMigrationFixture()
let invalidJSON = try Self.nodePEMIdentityJSON(privateKeyPem: "not-a-private-key")
let source = try fixture.source("shared", contents: invalidJSON)
#expect(throws: NSError.self) {
try fixture.load(sources: [source])
}
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(!FileManager.default.fileExists(atPath: fixture.databaseURL.path))
}
@Test
func `pending Doctor claim blocks identity generation`() throws {
let fixture = DeviceIdentityMigrationFixture()
let source = try fixture.source()
let claimURL = fixture.claimURL(for: source, suffix: ".doctor-importing")
try FileManager.default.moveItem(at: source.identityURL, to: claimURL)
#expect(throws: NSError.self) {
try fixture.load(sources: [source])
}
#expect(FileManager.default.fileExists(atPath: claimURL.path))
#expect(!FileManager.default.fileExists(atPath: fixture.databaseURL.path))
}
@Test
func `Doctor rename winning native claim race blocks identity generation`() throws {
let fixture = DeviceIdentityMigrationFixture()
let source = try fixture.source()
let claimURL = fixture.claimURL(for: source, suffix: ".doctor-importing")
#expect(throws: NSError.self) {
try fixture.load(
sources: [source],
beforeLegacyClaim: { _ in
try FileManager.default.moveItem(at: source.identityURL, to: claimURL)
})
}
#expect(FileManager.default.fileExists(atPath: claimURL.path))
#expect(!FileManager.default.fileExists(atPath: fixture.databaseURL.path))
}
@Test
func `interrupted native claim resumes without rotating identity`() throws {
let fixture = DeviceIdentityMigrationFixture()
let source = try fixture.source()
let claimURL = fixture.claimURL(for: source)
try FileManager.default.moveItem(at: source.identityURL, to: claimURL)
let identity = try fixture.load(sources: [source])
#expect(identity.deviceId == Self.fixtureDeviceID)
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(!FileManager.default.fileExists(atPath: claimURL.path))
}
@Test
func `matching recreated source parks stale native claim`() throws {
let fixture = DeviceIdentityMigrationFixture()
let source = try fixture.source(
contents: Self.nodePEMIdentityJSON(deviceId: "recreated-source"))
let claimURL = fixture.claimURL(for: source)
try Self.nodePEMIdentityJSON(deviceId: "interrupted-claim")
.write(to: claimURL, atomically: true, encoding: .utf8)
let identity = try fixture.load(sources: [source])
#expect(identity.deviceId == Self.fixtureDeviceID)
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(!FileManager.default.fileExists(atPath: claimURL.path))
let parkedClaims = try FileManager.default.contentsOfDirectory(
at: source.identityURL.deletingLastPathComponent(),
includingPropertiesForKeys: nil)
.filter { $0.lastPathComponent.hasPrefix("device.json.native-importing.stale-") }
#expect(parkedClaims.count == 1)
}
@Test
func `conflicting recreated source preserves native claim`() throws {
let fixture = DeviceIdentityMigrationFixture()
let source = try fixture.source()
let claimURL = fixture.claimURL(for: source)
try JSONEncoder().encode(DeviceIdentityStore.generateMaterial().identity).write(to: claimURL)
do {
_ = try fixture.load(sources: [source])
Issue.record("Expected conflicting source and claim to throw")
} catch let error as NSError {
#expect(error.localizedDescription ==
"Legacy device identity source and interrupted native claim both exist")
}
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(FileManager.default.fileExists(atPath: claimURL.path))
}
@Test
func `unparseable native claim is preserved with recreated source`() throws {
let fixture = DeviceIdentityMigrationFixture()
let source = try fixture.source()
let claimURL = fixture.claimURL(for: source)
try Data("not-json".utf8).write(to: claimURL)
do {
_ = try fixture.load(sources: [source])
Issue.record("Expected unparseable claim to throw")
} catch let error as NSError {
#expect(error.localizedDescription ==
"Legacy device identity source and interrupted native claim both exist")
}
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(FileManager.default.fileExists(atPath: claimURL.path))
}
@Test
func `source reappearance preserves both native claim and recreated source`() throws {
let fixture = DeviceIdentityMigrationFixture()
let legacyData = try Self.nodePEMIdentityJSON()
let source = try fixture.source(contents: legacyData)
let claimURL = fixture.claimURL(for: source)
#expect(throws: NSError.self) {
try fixture.load(
sources: [source],
afterLegacyCommit: {
try legacyData.write(to: source.identityURL, atomically: true, encoding: .utf8)
})
}
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(FileManager.default.fileExists(atPath: claimURL.path))
#expect(try Self.scalarText(
fixture.databaseURL,
"SELECT device_id FROM device_identities WHERE identity_key = 'primary'") == Self.fixtureDeviceID)
}
@Test
func `multi source rollback restores every independent native claim`() throws {
let fixture = DeviceIdentityMigrationFixture()
let legacyData = try Self.nodePEMIdentityJSON()
let first = try fixture.source("first", contents: legacyData)
let second = try fixture.source("second", contents: legacyData)
let firstClaimURL = fixture.claimURL(for: first)
let secondClaimURL = fixture.claimURL(for: second)
#expect(throws: NSError.self) {
try fixture.load(
sources: [first, second],
afterLegacyCommit: {
try legacyData.write(to: second.identityURL, atomically: true, encoding: .utf8)
})
}
#expect(FileManager.default.fileExists(atPath: first.identityURL.path))
#expect(!FileManager.default.fileExists(atPath: firstClaimURL.path))
#expect(FileManager.default.fileExists(atPath: second.identityURL.path))
#expect(FileManager.default.fileExists(atPath: secondClaimURL.path))
}
@Test
func `post commit identity change restores native claim before cleanup`() throws {
let fixture = DeviceIdentityMigrationFixture()
let source = try fixture.source()
let claimURL = fixture.claimURL(for: source)
#expect(throws: NSError.self) {
try fixture.load(
sources: [source],
afterLegacyCommit: {
try Self.execute(
fixture.databaseURL,
"UPDATE device_identities SET device_id = 'tampered' WHERE identity_key = 'primary'")
})
}
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(!FileManager.default.fileExists(atPath: claimURL.path))
#expect(try Self.scalarText(
fixture.databaseURL,
"SELECT device_id FROM device_identities WHERE identity_key = 'primary'") == "tampered")
}
@Test
func `legacy claim rejects symbolic traversal and hard linked sources`() throws {
let fixture = DeviceIdentityMigrationFixture()
let root = fixture.root
let realRoot = fixture.url("real")
let real = try Self.writeLegacyIdentity(
stateDirURL: realRoot,
profile: .primary,
contents: Self.nodePEMIdentityJSON())
let symbolicRoot = fixture.url("symbolic")
let symbolic = Self.legacyIdentitySource(stateDirURL: symbolicRoot, profile: .primary)
try FileManager.default.createDirectory(
at: symbolic.identityURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try FileManager.default.createSymbolicLink(at: symbolic.identityURL, withDestinationURL: real.identityURL)
#expect(throws: NSError.self) {
try DeviceIdentitySQLiteStore.loadOrCreate(
databaseURL: root.appendingPathComponent("symbolic.sqlite"),
destinationStateDirURL: root,
profile: .primary,
legacySources: [symbolic])
}
let traversingRoot = fixture.url("traversing")
let traversing = Self.legacyIdentitySource(stateDirURL: traversingRoot, profile: .primary)
try FileManager.default.createDirectory(at: traversingRoot, withIntermediateDirectories: true)
try FileManager.default.createSymbolicLink(
at: traversing.identityURL.deletingLastPathComponent(),
withDestinationURL: real.identityURL.deletingLastPathComponent())
#expect(throws: NSError.self) {
try DeviceIdentitySQLiteStore.loadOrCreate(
databaseURL: root.appendingPathComponent("traversing.sqlite"),
destinationStateDirURL: root,
profile: .primary,
legacySources: [traversing])
}
let hardRoot = fixture.url("hard")
let hard = Self.legacyIdentitySource(stateDirURL: hardRoot, profile: .primary)
try FileManager.default.createDirectory(
at: hard.identityURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try FileManager.default.linkItem(at: real.identityURL, to: hard.identityURL)
#expect(throws: NSError.self) {
try DeviceIdentitySQLiteStore.loadOrCreate(
databaseURL: root.appendingPathComponent("hard.sqlite"),
destinationStateDirURL: root,
profile: .primary,
legacySources: [hard])
}
#expect(FileManager.default.fileExists(atPath: real.identityURL.path))
#expect(FileManager.default.fileExists(atPath: hard.identityURL.path))
}
@Test
func `migration imports auth rows without removing its source`() throws {
let fixture = DeviceIdentityMigrationFixture(
destinationName: "legacy",
databasePath: "state/openclaw.sqlite")
let source = try fixture.source("shared")
let auth = """
{"version":1,"deviceId":"\(Self
.fixtureDeviceID)","tokens":{"node":{"token":"source-token","role":"node","scopes":["write"],"updatedAtMs":100}}}
"""
try auth.write(to: source.authURL, atomically: true, encoding: .utf8)
let destinationAuthURL = fixture.destination.appendingPathComponent("identity/device-auth.json")
_ = try fixture.load(sources: [source])
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == auth)
#expect(!FileManager.default.fileExists(atPath: destinationAuthURL.path))
#expect(try Self.scalarText(
fixture.databaseURL,
"SELECT token FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") ==
"source-token")
}
@Test
func `migration preserves canonical destination auth rows`() throws {
let fixture = DeviceIdentityMigrationFixture(
destinationName: "legacy",
databasePath: "state/openclaw.sqlite")
let source = try fixture.source("shared")
let sourceAuth = """
{"version":1,"deviceId":"\(Self
.fixtureDeviceID)","tokens":{"node":{"token":"source-token","role":"node","scopes":[],"updatedAtMs":100}}}
"""
try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8)
try FileManager.default.createDirectory(
at: fixture.databaseURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try Self.execute(fixture.databaseURL, """
CREATE TABLE device_auth_tokens (
device_id TEXT NOT NULL,
role TEXT NOT NULL,
token TEXT NOT NULL,
scopes_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (device_id, role)
) STRICT;
CREATE INDEX idx_device_auth_tokens_updated
ON device_auth_tokens(updated_at_ms DESC, device_id, role);
INSERT INTO device_auth_tokens (device_id, role, token, scopes_json, updated_at_ms)
VALUES ('\(Self.fixtureDeviceID)', 'node', 'destination-token', '[]', 200);
""")
_ = try fixture.load(sources: [source])
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth)
#expect(try Self.scalarText(
fixture.databaseURL,
"SELECT token FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") ==
"destination-token")
}
@Test
func `migration imports destination legacy auth before source auth`() throws {
let fixture = DeviceIdentityMigrationFixture(databasePath: "state/openclaw.sqlite")
let source = try fixture.source("source")
let sourceAuth = """
{"version":1,"deviceId":"\(Self
.fixtureDeviceID)","tokens":{"node":{"token":"source-token","role":"node","scopes":[],"updatedAtMs":100}}}
"""
try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8)
let destinationAuthURL = fixture.destination.appendingPathComponent("identity/device-auth.json")
try FileManager.default.createDirectory(
at: destinationAuthURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let destinationAuth = sourceAuth.replacingOccurrences(of: "source-token", with: "destination-token")
try destinationAuth.write(to: destinationAuthURL, atomically: true, encoding: .utf8)
_ = try fixture.load(sources: [source])
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth)
#expect(!FileManager.default.fileExists(atPath: destinationAuthURL.path))
#expect(try Self.scalarText(
fixture.databaseURL,
"SELECT token FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") ==
"destination-token")
}
@Test
func `source auth access failure preserves the identity claim for retry`() throws {
let fixture = DeviceIdentityMigrationFixture(databasePath: "state/openclaw.sqlite")
let source = try fixture.source("source")
let sourceAuth = """
{"version":1,"deviceId":"\(Self.fixtureDeviceID)","tokens":{}}
"""
try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: source.authURL.path)
defer {
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: source.authURL.path)
}
#expect(throws: NSError.self) {
try fixture.load(sources: [source])
}
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(FileManager.default.fileExists(atPath: source.authURL.path))
}
@Test
func `migration rejects conflicting source auth stores`() throws {
let fixture = DeviceIdentityMigrationFixture(databasePath: "state/openclaw.sqlite")
let identityJSON = try Self.nodePEMIdentityJSON()
let first = try fixture.source("first", contents: identityJSON)
let second = try fixture.source("second", contents: identityJSON)
let firstAuth = """
{"version":1,"deviceId":"\(Self
.fixtureDeviceID)","tokens":{"node":{"token":"first-token","role":"node","scopes":[],"updatedAtMs":100}}}
"""
let secondAuth = firstAuth.replacingOccurrences(of: "first-token", with: "second-token")
try firstAuth.write(to: first.authURL, atomically: true, encoding: .utf8)
try secondAuth.write(to: second.authURL, atomically: true, encoding: .utf8)
#expect(throws: NSError.self) {
try fixture.load(sources: [first, second])
}
#expect(FileManager.default.fileExists(atPath: first.identityURL.path))
#expect(FileManager.default.fileExists(atPath: second.identityURL.path))
#expect(try String(contentsOf: first.authURL, encoding: .utf8) == firstAuth)
#expect(try String(contentsOf: second.authURL, encoding: .utf8) == secondAuth)
}
@Test
func `migration normalizes imported auth scopes`() throws {
let fixture = DeviceIdentityMigrationFixture(
destinationName: "legacy",
databasePath: "state/openclaw.sqlite")
let source = try fixture.source("shared")
let sourceAuth = """
{"version":1,"deviceId":"\(Self
.fixtureDeviceID)","tokens":{"legacy":{"token":"source-token","role":"node","scopes":["write","read"],"updatedAtMs":100}}}
"""
try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8)
_ = try fixture.load(sources: [source])
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth)
#expect(try Self.scalarText(
fixture.databaseURL,
"SELECT scopes_json FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") ==
"[\"read\",\"write\"]")
}
@Test
func `preserves WAL journal mode`() throws {
let fixture = DeviceIdentityMigrationFixture()
try Self.seedCanonicalSchema(fixture.databaseURL)
#expect(try Self.scalarText(fixture.databaseURL, "PRAGMA journal_mode = WAL")?.lowercased() == "wal")
_ = try fixture.load()
#expect(try Self.scalarText(fixture.databaseURL, "PRAGMA journal_mode")?.lowercased() == "wal")
}
@Test
func `fails closed for nonempty missing wrong and newer schemas`() throws {
let fixture = DeviceIdentityMigrationFixture()
let root = fixture.root
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let missing = root.appendingPathComponent("missing.sqlite")
try Self.execute(missing, "CREATE TABLE unrelated (id INTEGER PRIMARY KEY) STRICT")
#expect(throws: NSError.self) {
try DeviceIdentitySQLiteStore.loadOrCreate(
databaseURL: missing,
destinationStateDirURL: root,
profile: .primary)
}
let wrong = root.appendingPathComponent("wrong.sqlite")
try Self.execute(wrong, """
CREATE TABLE device_identities (
identity_key TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL
) STRICT;
CREATE INDEX idx_device_identities_device ON device_identities(device_id);
""")
#expect(throws: NSError.self) {
try DeviceIdentitySQLiteStore.loadOrCreate(
databaseURL: wrong,
destinationStateDirURL: root,
profile: .primary)
}
let newer = root.appendingPathComponent("newer.sqlite")
try Self.execute(newer, "PRAGMA user_version = 4")
#expect(throws: NSError.self) {
try DeviceIdentitySQLiteStore.loadOrCreate(
databaseURL: newer,
destinationStateDirURL: root,
profile: .primary)
}
}
}
extension DeviceIdentityStoreTests {
fileprivate static func base64UrlDecode(_ value: String) -> Data? {
let normalized = value
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let padded = normalized + String(repeating: "=", count: (4 - normalized.count % 4) % 4)
return Data(base64Encoded: padded)
}
fileprivate static let fixtureDeviceID = "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c"
fileprivate static let fixturePublicKeyRaw = "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="
fileprivate static let fixturePublicKeyData = Data(base64Encoded: Self.fixturePublicKeyRaw)!
fileprivate static let fixturePrivateKeyData = Data((0..<32).map { UInt8($0) })
fileprivate static let fixturePrivateKeyRaw = Self.fixturePrivateKeyData.base64EncodedString()
fileprivate static let fixturePublicKeyPEM = Self.fixturePEM(
label: "PUBLIC KEY",
der: Data([
0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65,
0x70, 0x03, 0x21, 0x00,
]) + Self.fixturePublicKeyData)
fileprivate static let fixturePrivateKeyPEM = Self.fixturePEM(
label: "PRIVATE KEY",
der: Data([
0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2B, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]) + Self.fixturePrivateKeyData)
fileprivate static func fixturePEM(label: String, der: Data) -> String {
let fence = String(repeating: "-", count: 5)
return "\(fence)BEGIN \(label)\(fence)\n\(der.base64EncodedString())\n\(fence)END \(label)\(fence)\n"
}
fileprivate static func nodePEMIdentityJSON(
deviceId: String = Self.fixtureDeviceID,
privateKeyPem: String = Self.fixturePrivateKeyPEM) throws -> String
{
let object: [String: Any] = [
"version": 1,
"deviceId": deviceId,
"publicKeyPem": self.fixturePublicKeyPEM,
"privateKeyPem": privateKeyPem,
"createdAtMs": Int64(1_800_000_000_000),
]
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
guard let json = String(bytes: data, encoding: .utf8) else {
throw DeviceIdentityStore.storageError("Could not encode fixture identity JSON")
}
return json + "\n"
}
fileprivate static func writeLegacyIdentity(
stateDirURL: URL,
profile: GatewayDeviceIdentityProfile,
contents: String) throws -> DeviceIdentityPaths.LegacyIdentitySource
{
let source = self.legacyIdentitySource(stateDirURL: stateDirURL, profile: profile)
try FileManager.default.createDirectory(
at: source.identityURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try contents.write(to: source.identityURL, atomically: true, encoding: .utf8)
return source
}
fileprivate static func legacyIdentitySource(
stateDirURL: URL,
profile: GatewayDeviceIdentityProfile) -> DeviceIdentityPaths.LegacyIdentitySource
{
let stateDirURL = stateDirURL.standardizedFileURL
let identityDirURL = stateDirURL.appendingPathComponent("identity", isDirectory: true)
return DeviceIdentityPaths.LegacyIdentitySource(
stateDirURL: stateDirURL,
identityURL: identityDirURL.appendingPathComponent(profile.identityFileName, isDirectory: false),
authURL: identityDirURL.appendingPathComponent(profile.authFileName, isDirectory: false))
}
fileprivate static func seedCanonicalSchema(_ databaseURL: URL, nodeOwned: Bool = false) throws {
try FileManager.default.createDirectory(
at: databaseURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let metadataSQL = nodeOwned
? """
CREATE TABLE schema_meta (
meta_key TEXT NOT NULL PRIMARY KEY,
role TEXT NOT NULL,
schema_version INTEGER NOT NULL,
agent_id TEXT,
app_version TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
INSERT INTO schema_meta (
meta_key, role, schema_version, agent_id, app_version, created_at, updated_at
) VALUES ('primary', 'global', 4, NULL, NULL, 1800000000000, 1800000000000);
PRAGMA user_version = 4;
"""
: ""
try self.execute(databaseURL, """
CREATE TABLE device_identities (
identity_key TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL,
public_key_pem TEXT NOT NULL,
private_key_pem TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
) STRICT;
CREATE INDEX idx_device_identities_device
ON device_identities(device_id, updated_at_ms DESC);
\(metadataSQL)
""")
}
fileprivate static func execute(_ databaseURL: URL, _ sql: String) throws {
var database: OpaquePointer?
guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else {
throw DeviceIdentityStore.storageError("Could not open test database")
}
defer { sqlite3_close(database) }
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
throw DeviceIdentityStore.storageError(String(cString: sqlite3_errmsg(database)))
}
}
fileprivate static func scalarInt(_ databaseURL: URL, _ sql: String) throws -> Int64 {
try self.scalar(databaseURL, sql) { sqlite3_column_int64($0, 0) }
}
fileprivate static func scalarText(_ databaseURL: URL, _ sql: String) throws -> String? {
try self.scalar(databaseURL, sql) { statement in
sqlite3_column_text(statement, 0).map { String(cString: $0) }
}
}
fileprivate static func scalar<T>(
_ databaseURL: URL,
_ sql: String,
transform: (OpaquePointer) -> T) throws -> T
{
var database: OpaquePointer?
guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else {
throw DeviceIdentityStore.storageError("Could not open test database")
}
defer { sqlite3_close(database) }
var statement: OpaquePointer?
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else {
throw DeviceIdentityStore.storageError(String(cString: sqlite3_errmsg(database)))
}
defer { sqlite3_finalize(statement) }
guard sqlite3_step(statement) == SQLITE_ROW else {
throw DeviceIdentityStore.storageError(String(cString: sqlite3_errmsg(database)))
}
return transform(statement)
}
fileprivate static func sql(_ value: String) -> String {
value.replacingOccurrences(of: "'", with: "''")
}
}