mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-17 16:12:21 -06:00
4bc37cd597
* fix(state): store in-root agent database registry paths relative to the state dir (schema v9) Copied state directories retained stale absolute agent database registry rows. The combined gateway store then merged old and new copies, causing every sessions.list request to fail with SessionCanonicalKeyMigrationRequiredError. Schema v9 stores in-root registry paths relative to the state directory; migration rewrites eligible rows, deletes stale duplicates, and preserves external paths. * fix(voice-call): describe state-relative registry migration * fix(state): preserve registry locator traversal * test(macos): move PortGuardian schema-version boundary to v9 The store opens any state database up to maximumSupportedSchemaVersion, which the schema v9 bump moved from 8 to 9. Shift the supported/newer partition in the boundary test accordingly. * fix(state): re-anchor copied default-layout registry rows instead of deleting them Resolve the ClawSweeper P1 by preserving genuine external default-layout registrations, re-anchoring copied rows when an in-root counterpart exists, and deleting only dual default-layout conflicts.
644 lines
26 KiB
Swift
644 lines
26 KiB
Swift
import Foundation
|
|
import SQLite3
|
|
|
|
public struct OpenClawNativeStateError: Error, LocalizedError, Sendable {
|
|
public let message: String
|
|
|
|
public var errorDescription: String? {
|
|
self.message
|
|
}
|
|
|
|
public init(_ message: String) {
|
|
self.message = message
|
|
}
|
|
}
|
|
|
|
public enum OpenClawNativeStateCanonicalTable: Sendable {
|
|
case deviceAuthTokens
|
|
case deviceIdentities
|
|
case execApprovalsConfig
|
|
case macosPortGuardianRecords
|
|
}
|
|
|
|
public enum OpenClawNativeStateSQLiteStep: Equatable, Sendable {
|
|
case row
|
|
case done
|
|
}
|
|
|
|
public enum OpenClawNativeStateSQLiteValueType: Equatable, Sendable {
|
|
case integer
|
|
case float
|
|
case text
|
|
case blob
|
|
case null
|
|
}
|
|
|
|
/// Synchronous access to the shared native SQLite bootstrap surface.
|
|
/// One recursive connection lock serializes transactions and statement access.
|
|
public final class OpenClawNativeStateSQLite: @unchecked Sendable {
|
|
// Keep aligned with OPENCLAW_STATE_SCHEMA_VERSION. Native clients never upgrade this database.
|
|
private static let maximumSupportedSchemaVersion: Int64 = 9
|
|
private static let defaultBusyTimeoutMilliseconds: Int32 = 5000
|
|
|
|
private struct SchemaObject: Hashable {
|
|
let type: String
|
|
let name: String
|
|
}
|
|
|
|
private struct Column: Equatable {
|
|
let name: String
|
|
let type: String
|
|
let notNull: Bool
|
|
let primaryKeyPosition: Int32
|
|
let hidden: Int32
|
|
}
|
|
|
|
private struct IndexColumn: Equatable {
|
|
let name: String
|
|
let descending: Bool
|
|
}
|
|
|
|
private struct CanonicalTable {
|
|
let name: String
|
|
let indexName: String?
|
|
let createSQL: String
|
|
let columns: [Column]
|
|
let indexColumns: [IndexColumn]
|
|
|
|
var objects: Set<SchemaObject> {
|
|
var objects = [SchemaObject(type: "table", name: self.name)]
|
|
if let indexName {
|
|
objects.append(SchemaObject(type: "index", name: indexName))
|
|
}
|
|
return Set(objects)
|
|
}
|
|
}
|
|
|
|
private static let deviceIdentities = CanonicalTable(
|
|
name: "device_identities",
|
|
indexName: "idx_device_identities_device",
|
|
createSQL: """
|
|
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_device_identities_device
|
|
ON device_identities(device_id, updated_at_ms DESC);
|
|
""",
|
|
columns: [
|
|
Column(name: "identity_key", type: "TEXT", notNull: true, primaryKeyPosition: 1, hidden: 0),
|
|
Column(name: "device_id", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "public_key_pem", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "private_key_pem", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "created_at_ms", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "updated_at_ms", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
],
|
|
indexColumns: [
|
|
IndexColumn(name: "device_id", descending: false),
|
|
IndexColumn(name: "updated_at_ms", descending: true),
|
|
])
|
|
|
|
private static let deviceAuthTokens = CanonicalTable(
|
|
name: "device_auth_tokens",
|
|
indexName: "idx_device_auth_tokens_updated",
|
|
createSQL: """
|
|
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_device_auth_tokens_updated
|
|
ON device_auth_tokens(updated_at_ms DESC, device_id, role);
|
|
""",
|
|
columns: [
|
|
Column(name: "device_id", type: "TEXT", notNull: true, primaryKeyPosition: 1, hidden: 0),
|
|
Column(name: "role", type: "TEXT", notNull: true, primaryKeyPosition: 2, hidden: 0),
|
|
Column(name: "token", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "scopes_json", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "updated_at_ms", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
],
|
|
indexColumns: [
|
|
IndexColumn(name: "updated_at_ms", descending: true),
|
|
IndexColumn(name: "device_id", descending: false),
|
|
IndexColumn(name: "role", descending: false),
|
|
])
|
|
|
|
private static let macosPortGuardianRecords = CanonicalTable(
|
|
name: "macos_port_guardian_records",
|
|
indexName: "idx_macos_port_guardian_records_port",
|
|
createSQL: """
|
|
CREATE TABLE IF NOT EXISTS macos_port_guardian_records (
|
|
pid INTEGER NOT NULL PRIMARY KEY,
|
|
port INTEGER NOT NULL,
|
|
command TEXT NOT NULL,
|
|
mode TEXT NOT NULL,
|
|
timestamp REAL NOT NULL
|
|
) STRICT;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_macos_port_guardian_records_port
|
|
ON macos_port_guardian_records(port, timestamp DESC);
|
|
""",
|
|
columns: [
|
|
Column(name: "pid", type: "INTEGER", notNull: true, primaryKeyPosition: 1, hidden: 0),
|
|
Column(name: "port", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "command", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "mode", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "timestamp", type: "REAL", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
],
|
|
indexColumns: [
|
|
IndexColumn(name: "port", descending: false),
|
|
IndexColumn(name: "timestamp", descending: true),
|
|
])
|
|
|
|
private static let execApprovalsConfig = CanonicalTable(
|
|
name: "exec_approvals_config",
|
|
indexName: nil,
|
|
createSQL: """
|
|
CREATE TABLE IF NOT EXISTS exec_approvals_config (
|
|
config_key TEXT NOT NULL PRIMARY KEY,
|
|
raw_json TEXT NOT NULL,
|
|
socket_path TEXT,
|
|
has_socket_token INTEGER NOT NULL,
|
|
default_security TEXT,
|
|
default_ask TEXT,
|
|
default_ask_fallback TEXT,
|
|
auto_allow_skills INTEGER,
|
|
agent_count INTEGER NOT NULL,
|
|
allowlist_count INTEGER NOT NULL,
|
|
updated_at_ms INTEGER NOT NULL
|
|
) STRICT;
|
|
""",
|
|
columns: [
|
|
Column(name: "config_key", type: "TEXT", notNull: true, primaryKeyPosition: 1, hidden: 0),
|
|
Column(name: "raw_json", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "socket_path", type: "TEXT", notNull: false, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "has_socket_token", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "default_security", type: "TEXT", notNull: false, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "default_ask", type: "TEXT", notNull: false, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "default_ask_fallback", type: "TEXT", notNull: false, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "auto_allow_skills", type: "INTEGER", notNull: false, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "agent_count", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "allowlist_count", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
Column(name: "updated_at_ms", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
|
],
|
|
indexColumns: [])
|
|
|
|
private static let canonicalTables = [
|
|
OpenClawNativeStateSQLite.deviceAuthTokens,
|
|
OpenClawNativeStateSQLite.deviceIdentities,
|
|
OpenClawNativeStateSQLite.execApprovalsConfig,
|
|
OpenClawNativeStateSQLite.macosPortGuardianRecords,
|
|
]
|
|
|
|
private let databaseURL: URL
|
|
fileprivate let database: OpaquePointer
|
|
fileprivate let connectionLock = NSRecursiveLock()
|
|
|
|
public init(
|
|
databaseURL: URL,
|
|
busyTimeoutMilliseconds: Int32 = 5000,
|
|
createIfMissing: Bool = true) throws
|
|
{
|
|
self.databaseURL = databaseURL
|
|
if createIfMissing {
|
|
try Self.secureDirectory(databaseURL.deletingLastPathComponent())
|
|
}
|
|
var database: OpaquePointer?
|
|
let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
|
|
| (createIfMissing ? SQLITE_OPEN_CREATE : 0)
|
|
let result = sqlite3_open_v2(databaseURL.path, &database, flags, nil)
|
|
guard result == SQLITE_OK, let database else {
|
|
let detail = database.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown SQLite error"
|
|
if let database { sqlite3_close(database) }
|
|
throw OpenClawNativeStateError("Could not open native state database: \(detail)")
|
|
}
|
|
var initializationSucceeded = false
|
|
defer {
|
|
if !initializationSucceeded { sqlite3_close(database) }
|
|
}
|
|
self.database = database
|
|
let timeout = busyTimeoutMilliseconds > 0
|
|
? busyTimeoutMilliseconds
|
|
: Self.defaultBusyTimeoutMilliseconds
|
|
guard sqlite3_busy_timeout(database, timeout) == SQLITE_OK else {
|
|
throw self.databaseError(operation: "configure SQLite busy timeout")
|
|
}
|
|
try Self.secureDatabaseFiles(databaseURL)
|
|
initializationSucceeded = true
|
|
}
|
|
|
|
deinit {
|
|
sqlite3_close(self.database)
|
|
try? Self.secureDatabaseFiles(self.databaseURL)
|
|
}
|
|
|
|
public var changes: Int32 {
|
|
self.withConnectionLock { sqlite3_changes(self.database) }
|
|
}
|
|
|
|
public func withImmediateTransaction<T>(_ body: () throws -> T) throws -> T {
|
|
try self.withConnectionLock {
|
|
try self.execute("BEGIN IMMEDIATE")
|
|
var committed = false
|
|
defer {
|
|
if !committed { try? self.execute("ROLLBACK") }
|
|
}
|
|
let value = try body()
|
|
try self.execute("COMMIT")
|
|
committed = true
|
|
try Self.secureDatabaseFiles(self.databaseURL)
|
|
return value
|
|
}
|
|
}
|
|
|
|
/// Creates a requested table only in an owned schema-version-zero bootstrap database.
|
|
/// Node-owned versioned databases must already contain the canonical table.
|
|
public func ensureCanonicalTable(
|
|
_ table: OpenClawNativeStateCanonicalTable,
|
|
allowVersionZeroCreation: Bool = true) throws
|
|
{
|
|
try self.withConnectionLock {
|
|
let descriptor = Self.descriptor(table)
|
|
let userVersion = try self.scalarInt64("PRAGMA user_version")
|
|
guard userVersion <= Self.maximumSupportedSchemaVersion else {
|
|
throw OpenClawNativeStateError(
|
|
"Native state database uses newer schema version \(userVersion); " +
|
|
"this build supports \(Self.maximumSupportedSchemaVersion)")
|
|
}
|
|
|
|
if userVersion == 0 {
|
|
try self.validateVersionZeroOwnership()
|
|
if try !self.schemaObjectExists(type: "table", name: descriptor.name) {
|
|
guard allowVersionZeroCreation else {
|
|
throw OpenClawNativeStateError(
|
|
"Schema version zero database is missing \(descriptor.name)")
|
|
}
|
|
try self.execute(descriptor.createSQL)
|
|
}
|
|
try self.validateVersionZeroOwnership()
|
|
} else {
|
|
try self.validateSharedDatabaseMetadata(userVersion: userVersion)
|
|
guard try self.schemaObjectExists(type: "table", name: descriptor.name) else {
|
|
throw OpenClawNativeStateError(
|
|
"Versioned OpenClaw state database is missing \(descriptor.name)")
|
|
}
|
|
}
|
|
try self.validateCanonicalTable(table)
|
|
}
|
|
}
|
|
|
|
public func validateCanonicalTable(_ table: OpenClawNativeStateCanonicalTable) throws {
|
|
try self.withConnectionLock {
|
|
try self.validateTableShape(Self.descriptor(table))
|
|
}
|
|
}
|
|
|
|
public func prepare(_ sql: String) throws -> OpenClawNativeStateSQLiteStatement {
|
|
try self.withConnectionLock {
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(self.database, sql, -1, &statement, nil) == SQLITE_OK,
|
|
let statement
|
|
else {
|
|
throw self.databaseError(operation: "prepare SQLite statement")
|
|
}
|
|
return OpenClawNativeStateSQLiteStatement(connection: self, statement: statement)
|
|
}
|
|
}
|
|
|
|
public func execute(_ sql: String) throws {
|
|
try self.withConnectionLock {
|
|
var errorMessage: UnsafeMutablePointer<CChar>?
|
|
let result = sqlite3_exec(self.database, sql, nil, nil, &errorMessage)
|
|
guard result == SQLITE_OK else {
|
|
let detail = errorMessage.map { String(cString: $0) }
|
|
?? String(cString: sqlite3_errmsg(self.database))
|
|
sqlite3_free(errorMessage)
|
|
throw OpenClawNativeStateError("SQLite operation failed: \(detail)")
|
|
}
|
|
}
|
|
}
|
|
|
|
public func scalarInt64(_ sql: String) throws -> Int64 {
|
|
try self.withConnectionLock {
|
|
let statement = try self.prepare(sql)
|
|
guard try statement.step() == .row,
|
|
statement.valueType(at: 0) == .integer
|
|
else {
|
|
throw OpenClawNativeStateError("SQLite integer query did not return one integer row")
|
|
}
|
|
let value = statement.int64(at: 0)
|
|
guard try statement.step() == .done else {
|
|
throw OpenClawNativeStateError("SQLite integer query returned multiple rows")
|
|
}
|
|
return value
|
|
}
|
|
}
|
|
|
|
public func scalarText(_ sql: String) throws -> String? {
|
|
try self.withConnectionLock {
|
|
let statement = try self.prepare(sql)
|
|
let result = try statement.step()
|
|
if result == .done { return nil }
|
|
let value = try statement.requiredText(at: 0, field: "query result")
|
|
guard try statement.step() == .done else {
|
|
throw OpenClawNativeStateError("SQLite text query returned multiple rows")
|
|
}
|
|
return value
|
|
}
|
|
}
|
|
|
|
public func schemaObjectExists(type: String, name: String) throws -> Bool {
|
|
try self.withConnectionLock {
|
|
let statement = try self.prepare(
|
|
"SELECT 1 FROM sqlite_schema WHERE type = ? AND name = ? LIMIT 1")
|
|
try statement.bindText(type, at: 1)
|
|
try statement.bindText(name, at: 2)
|
|
let result = try statement.step()
|
|
if result == .done { return false }
|
|
guard try statement.step() == .done else {
|
|
throw OpenClawNativeStateError("SQLite schema query returned multiple rows")
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
fileprivate func databaseError(operation: String) -> OpenClawNativeStateError {
|
|
OpenClawNativeStateError(
|
|
"Could not \(operation): \(String(cString: sqlite3_errmsg(self.database)))")
|
|
}
|
|
|
|
fileprivate func withConnectionLock<T>(_ body: () throws -> T) rethrows -> T {
|
|
self.connectionLock.lock()
|
|
defer { self.connectionLock.unlock() }
|
|
return try body()
|
|
}
|
|
|
|
private static func descriptor(_ table: OpenClawNativeStateCanonicalTable) -> CanonicalTable {
|
|
switch table {
|
|
case .deviceAuthTokens: self.deviceAuthTokens
|
|
case .deviceIdentities: self.deviceIdentities
|
|
case .execApprovalsConfig: self.execApprovalsConfig
|
|
case .macosPortGuardianRecords: self.macosPortGuardianRecords
|
|
}
|
|
}
|
|
|
|
private func validateVersionZeroOwnership() throws {
|
|
let statement = try self.prepare("""
|
|
SELECT type, name
|
|
FROM sqlite_schema
|
|
WHERE name NOT LIKE 'sqlite_%'
|
|
ORDER BY type, name
|
|
""")
|
|
var objects = Set<SchemaObject>()
|
|
while try statement.step() == .row {
|
|
try objects.insert(SchemaObject(
|
|
type: statement.requiredText(at: 0, field: "schema object type"),
|
|
name: statement.requiredText(at: 1, field: "schema object name")))
|
|
}
|
|
let allowedObjects = Self.canonicalTables.reduce(into: Set<SchemaObject>()) {
|
|
$0.formUnion($1.objects)
|
|
}
|
|
guard objects.isSubset(of: allowedObjects) else {
|
|
throw OpenClawNativeStateError(
|
|
"Schema version zero database contains objects not owned by a native OpenClaw store")
|
|
}
|
|
|
|
// A known name is not enough: every present native table must be complete and exact
|
|
// before another store composes its own schema into the same version-zero database.
|
|
for table in Self.canonicalTables where !objects.isDisjoint(with: table.objects) {
|
|
guard table.objects.isSubset(of: objects) else {
|
|
throw OpenClawNativeStateError(
|
|
"Schema version zero database has incomplete objects for \(table.name)")
|
|
}
|
|
try self.validateTableShape(table)
|
|
}
|
|
}
|
|
|
|
private func validateSharedDatabaseMetadata(userVersion: Int64) throws {
|
|
guard try self.schemaObjectExists(type: "table", name: "schema_meta") else {
|
|
throw OpenClawNativeStateError("Versioned OpenClaw state database is missing schema_meta")
|
|
}
|
|
let statement = try self.prepare(
|
|
"SELECT role, schema_version FROM schema_meta WHERE meta_key = 'primary' LIMIT 1")
|
|
guard try statement.step() == .row,
|
|
try statement.requiredText(at: 0, field: "schema role") == "global",
|
|
statement.valueType(at: 1) == .integer,
|
|
statement.int64(at: 1) == userVersion,
|
|
try statement.step() == .done
|
|
else {
|
|
throw OpenClawNativeStateError(
|
|
"OpenClaw state database schema metadata does not match its global schema version")
|
|
}
|
|
}
|
|
|
|
private func validateTableShape(_ table: CanonicalTable) throws {
|
|
guard try self.tableColumns(table.name) == table.columns else {
|
|
throw OpenClawNativeStateError("\(table.name) has an incompatible schema")
|
|
}
|
|
let tableSQL = try self.scalarText(
|
|
"SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = '\(table.name)'") ?? ""
|
|
let normalizedTableSQL = tableSQL
|
|
.split(whereSeparator: \.isWhitespace)
|
|
.joined(separator: " ")
|
|
.uppercased()
|
|
guard normalizedTableSQL.hasSuffix(") STRICT") else {
|
|
throw OpenClawNativeStateError("\(table.name) must be a STRICT table")
|
|
}
|
|
guard try self.validRequiredIndex(table) else {
|
|
throw OpenClawNativeStateError("\(table.indexName ?? table.name) has an incompatible index schema")
|
|
}
|
|
}
|
|
|
|
private func tableColumns(_ tableName: String) throws -> [Column] {
|
|
let statement = try self.prepare("PRAGMA table_xinfo('\(tableName)')")
|
|
var columns: [Column] = []
|
|
while try statement.step() == .row {
|
|
try columns.append(Column(
|
|
name: statement.requiredText(at: 1, field: "column name"),
|
|
type: statement.requiredText(at: 2, field: "column type").uppercased(),
|
|
notNull: statement.int32(at: 3) == 1,
|
|
primaryKeyPosition: statement.int32(at: 5),
|
|
hidden: statement.int32(at: 6)))
|
|
}
|
|
return columns
|
|
}
|
|
|
|
private func validRequiredIndex(_ table: CanonicalTable) throws -> Bool {
|
|
guard let indexName = table.indexName else { return table.indexColumns.isEmpty }
|
|
let list = try self.prepare("PRAGMA index_list('\(table.name)')")
|
|
var found = false
|
|
while try list.step() == .row {
|
|
let name = try list.requiredText(at: 1, field: "index name")
|
|
if name == indexName {
|
|
found = try list.int32(at: 2) == 0
|
|
&& (list.requiredText(at: 3, field: "index origin")) == "c"
|
|
&& list.int32(at: 4) == 0
|
|
}
|
|
}
|
|
guard found else { return false }
|
|
|
|
let details = try self.prepare("PRAGMA index_xinfo('\(indexName)')")
|
|
var keyColumns: [IndexColumn] = []
|
|
while try details.step() == .row {
|
|
guard details.int32(at: 5) == 1 else { continue }
|
|
try keyColumns.append(IndexColumn(
|
|
name: details.requiredText(at: 2, field: "index column"),
|
|
descending: details.int32(at: 3) == 1))
|
|
}
|
|
return keyColumns == table.indexColumns
|
|
}
|
|
|
|
private static func secureDirectory(_ url: URL) throws {
|
|
let fileManager = FileManager.default
|
|
try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
|
|
var attributes: [FileAttributeKey: Any] = [.posixPermissions: 0o700]
|
|
#if os(iOS) || os(watchOS)
|
|
attributes[.protectionKey] = FileProtectionType.completeUntilFirstUserAuthentication
|
|
#endif
|
|
try fileManager.setAttributes(attributes, ofItemAtPath: url.path)
|
|
}
|
|
|
|
static func secureDatabaseFiles(
|
|
_ databaseURL: URL,
|
|
fileExists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) },
|
|
setAttributes: ([FileAttributeKey: Any], String) throws -> Void = {
|
|
try FileManager.default.setAttributes($0, ofItemAtPath: $1)
|
|
}) throws
|
|
{
|
|
for (url, isTransient) in [
|
|
(databaseURL, false),
|
|
(URL(fileURLWithPath: databaseURL.path + "-wal", isDirectory: false), true),
|
|
(URL(fileURLWithPath: databaseURL.path + "-shm", isDirectory: false), true),
|
|
(URL(fileURLWithPath: databaseURL.path + "-journal", isDirectory: false), true),
|
|
] where fileExists(url.path) {
|
|
var attributes: [FileAttributeKey: Any] = [.posixPermissions: 0o600]
|
|
#if os(iOS) || os(watchOS)
|
|
attributes[.protectionKey] = FileProtectionType.completeUntilFirstUserAuthentication
|
|
#endif
|
|
do {
|
|
try setAttributes(attributes, url.path)
|
|
} catch where isTransient && Self.isMissingFileError(error) {
|
|
// SQLite can remove sidecars between the probe and chmod. A vanished sidecar is
|
|
// already secure; surfacing it after COMMIT would misreport a durable write.
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func isMissingFileError(_ error: Error) -> Bool {
|
|
let error = error as NSError
|
|
return (error.domain == NSCocoaErrorDomain && error.code == NSFileNoSuchFileError)
|
|
|| (error.domain == NSPOSIXErrorDomain
|
|
&& error.code == Int(POSIXErrorCode.ENOENT.rawValue))
|
|
}
|
|
}
|
|
|
|
public final class OpenClawNativeStateSQLiteStatement {
|
|
private let connection: OpenClawNativeStateSQLite
|
|
private let statement: OpaquePointer
|
|
|
|
fileprivate init(connection: OpenClawNativeStateSQLite, statement: OpaquePointer) {
|
|
self.connection = connection
|
|
self.statement = statement
|
|
}
|
|
|
|
deinit {
|
|
_ = self.connection.withConnectionLock {
|
|
sqlite3_finalize(self.statement)
|
|
}
|
|
}
|
|
|
|
public func step() throws -> OpenClawNativeStateSQLiteStep {
|
|
try self.connection.withConnectionLock {
|
|
switch sqlite3_step(self.statement) {
|
|
case SQLITE_ROW: .row
|
|
case SQLITE_DONE: .done
|
|
default: throw self.connection.databaseError(operation: "step SQLite statement")
|
|
}
|
|
}
|
|
}
|
|
|
|
public func bindText(_ value: String, at index: Int32) throws {
|
|
try self.connection.withConnectionLock {
|
|
let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
guard sqlite3_bind_text(self.statement, index, value, -1, transient) == SQLITE_OK else {
|
|
throw self.connection.databaseError(operation: "bind SQLite text")
|
|
}
|
|
}
|
|
}
|
|
|
|
public func bindInt64(_ value: Int64, at index: Int32) throws {
|
|
try self.connection.withConnectionLock {
|
|
guard sqlite3_bind_int64(self.statement, index, value) == SQLITE_OK else {
|
|
throw self.connection.databaseError(operation: "bind SQLite integer")
|
|
}
|
|
}
|
|
}
|
|
|
|
public func bindNull(at index: Int32) throws {
|
|
try self.connection.withConnectionLock {
|
|
guard sqlite3_bind_null(self.statement, index) == SQLITE_OK else {
|
|
throw self.connection.databaseError(operation: "bind SQLite null")
|
|
}
|
|
}
|
|
}
|
|
|
|
public func bindDouble(_ value: Double, at index: Int32) throws {
|
|
try self.connection.withConnectionLock {
|
|
guard sqlite3_bind_double(self.statement, index, value) == SQLITE_OK else {
|
|
throw self.connection.databaseError(operation: "bind SQLite double")
|
|
}
|
|
}
|
|
}
|
|
|
|
public func valueType(at column: Int32) -> OpenClawNativeStateSQLiteValueType {
|
|
self.connection.withConnectionLock {
|
|
switch sqlite3_column_type(self.statement, column) {
|
|
case SQLITE_INTEGER: .integer
|
|
case SQLITE_FLOAT: .float
|
|
case SQLITE_TEXT: .text
|
|
case SQLITE_BLOB: .blob
|
|
default: .null
|
|
}
|
|
}
|
|
}
|
|
|
|
public func int32(at column: Int32) -> Int32 {
|
|
self.connection.withConnectionLock {
|
|
sqlite3_column_int(self.statement, column)
|
|
}
|
|
}
|
|
|
|
public func int64(at column: Int32) -> Int64 {
|
|
self.connection.withConnectionLock {
|
|
sqlite3_column_int64(self.statement, column)
|
|
}
|
|
}
|
|
|
|
public func double(at column: Int32) -> Double {
|
|
self.connection.withConnectionLock {
|
|
sqlite3_column_double(self.statement, column)
|
|
}
|
|
}
|
|
|
|
public func requiredText(at column: Int32, field: String) throws -> String {
|
|
try self.connection.withConnectionLock {
|
|
guard self.valueType(at: column) == .text,
|
|
let value = sqlite3_column_text(self.statement, column)
|
|
else {
|
|
throw OpenClawNativeStateError("SQLite \(field) must be text")
|
|
}
|
|
return String(cString: value)
|
|
}
|
|
}
|
|
}
|