- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
215 lines
8.4 KiB
Swift
215 lines
8.4 KiB
Swift
import Foundation
|
|
|
|
public struct FileInstanceRepository: InstanceRepository {
|
|
private struct Store: Codable {
|
|
var instances: [Instance] = []
|
|
}
|
|
|
|
private let location: URL
|
|
private let encoder: JSONEncoder
|
|
private let decoder: JSONDecoder
|
|
private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.instance-repository")
|
|
|
|
public init(location: URL) {
|
|
self.location = location
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
self.encoder = encoder
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
self.decoder = decoder
|
|
}
|
|
|
|
public func list(_ query: InstanceQuery) async throws -> [Instance] {
|
|
try await run { store in
|
|
var instances = store.instances
|
|
if let search = query.search?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!search.isEmpty {
|
|
instances = instances.filter {
|
|
$0.name.localizedCaseInsensitiveContains(search)
|
|
|| $0.endpoint.displayAddress.localizedCaseInsensitiveContains(search)
|
|
}
|
|
}
|
|
if let tags = query.tags, !tags.isEmpty {
|
|
instances = instances.filter { !Set($0.tags).isDisjoint(with: tags) }
|
|
}
|
|
return instances.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
|
|
}
|
|
}
|
|
|
|
public func get(_ id: UUID) async throws -> Instance? {
|
|
try await run { $0.instances.first { $0.id == id } }
|
|
}
|
|
|
|
public func create(_ input: InstanceInput) async throws -> Instance {
|
|
try await run { store in
|
|
guard !input.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
throw RepositoryError.invalidOrigin
|
|
}
|
|
let endpoint = try Self.normalized(input.endpoint)
|
|
guard !store.instances.contains(where: { $0.endpoint.dedupKey == endpoint.dedupKey }) else {
|
|
throw RepositoryError.duplicateOrigin
|
|
}
|
|
let now = Date()
|
|
let instance = Instance(
|
|
id: UUID(),
|
|
name: input.name.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
endpoint: endpoint,
|
|
tags: Self.normalizedTags(input.tags),
|
|
credentialConfigured: !(input.password ?? "").isEmpty,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
)
|
|
store.instances.append(instance)
|
|
return instance
|
|
}
|
|
}
|
|
|
|
public func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance {
|
|
try await run { store in
|
|
guard let index = store.instances.firstIndex(where: { $0.id == id }) else {
|
|
throw RepositoryError.notFound
|
|
}
|
|
guard store.instances[index].configRevision == expectedRevision else {
|
|
throw RepositoryError.revisionConflict
|
|
}
|
|
|
|
var instance = store.instances[index]
|
|
if let name = patch.name {
|
|
let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !normalized.isEmpty else { throw RepositoryError.invalidOrigin }
|
|
instance.name = normalized
|
|
}
|
|
if let endpoint = patch.endpoint {
|
|
let normalized = try Self.normalized(endpoint)
|
|
guard !store.instances.contains(where: {
|
|
$0.id != id && $0.endpoint.dedupKey == normalized.dedupKey
|
|
}) else {
|
|
throw RepositoryError.duplicateOrigin
|
|
}
|
|
instance.endpoint = normalized
|
|
}
|
|
if let tags = patch.tags {
|
|
instance.tags = Self.normalizedTags(tags)
|
|
}
|
|
if let password = patch.password {
|
|
instance.credentialConfigured = !password.isEmpty
|
|
}
|
|
instance.updatedAt = Date()
|
|
instance.configRevision += 1
|
|
store.instances[index] = instance
|
|
return instance
|
|
}
|
|
}
|
|
|
|
public func delete(_ id: UUID, expectedRevision: Int) async throws {
|
|
try await run { store in
|
|
guard let index = store.instances.firstIndex(where: { $0.id == id }) else {
|
|
throw RepositoryError.notFound
|
|
}
|
|
guard store.instances[index].configRevision == expectedRevision else {
|
|
throw RepositoryError.revisionConflict
|
|
}
|
|
store.instances.remove(at: index)
|
|
}
|
|
}
|
|
|
|
private func run<T: Sendable>(_ transform: @escaping @Sendable (inout Store) throws -> T) async throws -> T {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
queue.async {
|
|
do {
|
|
var store = try self.readStore()
|
|
let output = try transform(&store)
|
|
try self.writeStore(store)
|
|
continuation.resume(returning: output)
|
|
} catch {
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func readStore() throws -> Store {
|
|
guard FileManager.default.fileExists(atPath: location.path) else {
|
|
return Store()
|
|
}
|
|
do {
|
|
return try decoder.decode(Store.self, from: Data(contentsOf: location))
|
|
} catch {
|
|
throw RepositoryError.databaseFailed
|
|
}
|
|
}
|
|
|
|
private func writeStore(_ store: Store) throws {
|
|
do {
|
|
let directory = location.deletingLastPathComponent()
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
try encoder.encode(store).write(to: location, options: [.atomic])
|
|
} catch {
|
|
throw RepositoryError.databaseFailed
|
|
}
|
|
}
|
|
|
|
private static func normalized(_ endpoint: InstanceEndpoint) throws -> InstanceEndpoint {
|
|
switch endpoint {
|
|
case .direct(let url):
|
|
return .direct(try normalizedOrigin(url))
|
|
case .relay(let relay):
|
|
// A relay endpoint is validated on its own terms: https, a parseable node id, and the
|
|
// exact key sizes the envelope format requires. A half-configured relay entry that
|
|
// reached the transport would fail as an unreadable response rather than as "this
|
|
// pairing is incomplete".
|
|
guard relay.relayBaseURL.scheme == "https",
|
|
relay.relayBaseURL.host?.isEmpty == false,
|
|
relay.relayBaseURL.path.isEmpty || relay.relayBaseURL.path == "/"
|
|
else {
|
|
throw RepositoryError.invalidOrigin
|
|
}
|
|
guard relay.nodeID.hasPrefix("nd_"), relay.nodeID.count >= 11 else {
|
|
throw RepositoryError.invalidOrigin
|
|
}
|
|
guard relay.agentSigningKey.count == RelayWire.keyBytes,
|
|
relay.agentKxKey.count == RelayWire.keyBytes,
|
|
relay.deviceToken.count >= 16,
|
|
relay.deviceID.hasPrefix("ap_")
|
|
else {
|
|
throw RepositoryError.invalidOrigin
|
|
}
|
|
var normalized = relay
|
|
normalized.relayBaseURL = URL(string: relay.relayBaseURL.absoluteString.trimmingCharacters(
|
|
in: CharacterSet(charactersIn: "/")
|
|
)) ?? relay.relayBaseURL
|
|
return .relay(normalized)
|
|
}
|
|
}
|
|
|
|
private static func normalizedOrigin(_ url: URL) throws -> URL {
|
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
|
components.scheme == "http" || components.scheme == "https",
|
|
let host = components.host, !host.isEmpty
|
|
else {
|
|
throw RepositoryError.invalidOrigin
|
|
}
|
|
guard components.path.isEmpty || components.path == "/" else {
|
|
throw RepositoryError.invalidOrigin
|
|
}
|
|
var normalized = components
|
|
normalized.path = ""
|
|
normalized.query = nil
|
|
normalized.fragment = nil
|
|
guard let result = normalized.url else {
|
|
throw RepositoryError.invalidOrigin
|
|
}
|
|
return result
|
|
}
|
|
|
|
private static func normalizedTags(_ tags: [String]) -> [String] {
|
|
var seen = Set<String>()
|
|
return tags
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted }
|
|
}
|
|
}
|