mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
Compare commits
2 Commits
feat/devic
...
feat/devic
| Author | SHA1 | Date | |
|---|---|---|---|
| ce8f19b9ff | |||
| 0ea9a8e49c |
@@ -161,6 +161,15 @@ A crash at any step must preserve at least one valid copy and must not change
|
|||||||
the endpoint identity. Confirmed unrecoverable loss or an explicit identity
|
the endpoint identity. Confirmed unrecoverable loss or an explicit identity
|
||||||
reset is required before replacement.
|
reset is required before replacement.
|
||||||
|
|
||||||
|
An identity reset is a destructive, user-confirmed recovery operation exposed
|
||||||
|
before normal core initialization. It is accepted only when the protected
|
||||||
|
endpoint credential is missing or corrupted; a readable identity can never be
|
||||||
|
reset through this path. The reset preserves received files and transfer
|
||||||
|
history, stops old active Invitation transfers, cancels resumable Targeted
|
||||||
|
transfers, and removes relationships, pairing eligibility, grants,
|
||||||
|
authorizations, and retry state bound to the lost identity. A replacement
|
||||||
|
identity is minted only after that invalidation transaction commits.
|
||||||
|
|
||||||
Secrets must not synchronize through platform cloud backup. Restored metadata
|
Secrets must not synchronize through platform cloud backup. Restored metadata
|
||||||
without its device-bound secrets reconciles to disabled relationships, never a
|
without its device-bound secrets reconciles to disabled relationships, never a
|
||||||
cloned identity.
|
cloned identity.
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ label, forget, and block operations. Wrap those calls through the existing
|
|||||||
Apple `CoreGateway` / `CoreRepository` boundary rather than invoking generated
|
Apple `CoreGateway` / `CoreRepository` boundary rather than invoking generated
|
||||||
bindings from SwiftUI views.
|
bindings from SwiftUI views.
|
||||||
|
|
||||||
|
If startup reports a missing or corrupted endpoint identity, platforms must
|
||||||
|
offer the explicit `resetUnrecoverableIdentityWithLimitsAndNetworkConfig`
|
||||||
|
recovery flow rather than an endless Retry action. Confirm that Saved devices
|
||||||
|
must be paired again; transfer history and received files are retained. Locked
|
||||||
|
or temporarily unavailable credential storage remains retryable and must not
|
||||||
|
offer identity replacement.
|
||||||
|
|
||||||
Use SF Symbols and native iOS/macOS controls even when that duplicates Compose
|
Use SF Symbols and native iOS/macOS controls even when that duplicates Compose
|
||||||
presentation code. Share behavior and vocabulary across platforms, not widget
|
presentation code. Share behavior and vocabulary across platforms, not widget
|
||||||
implementations. Before handoff, run `make check-localization` and
|
implementations. Before handoff, run `make check-localization` and
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
import VnidropCore
|
||||||
@testable import VniDrop
|
@testable import VniDrop
|
||||||
|
|
||||||
/// Ports app-level assertions: core initialization on launch, destination
|
/// Ports app-level assertions: core initialization on launch, destination
|
||||||
@@ -50,4 +51,21 @@ final class AppModelTests: XCTestCase {
|
|||||||
await waitUntil { model.themeMode == .dark }
|
await waitUntil { model.themeMode == .dark }
|
||||||
XCTAssertEqual(model.themeMode, .dark)
|
XCTAssertEqual(model.themeMode, .dark)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testMissingEndpointIdentityOffersExplicitResetAndRecoversStartup() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
core.initializeResult = .failure(
|
||||||
|
VnidropError.SecureStorageMissing(reason: "credential is missing")
|
||||||
|
)
|
||||||
|
let model = makeModel(core, preferences: Fixtures.preferences())
|
||||||
|
|
||||||
|
await waitUntil { model.startupRecovery == .identityUnrecoverable }
|
||||||
|
XCTAssertFalse(core.state.isInitialized)
|
||||||
|
|
||||||
|
await model.resetUnrecoverableIdentity()
|
||||||
|
|
||||||
|
XCTAssertEqual(core.resetUnrecoverableIdentityCount, 1)
|
||||||
|
XCTAssertNil(model.startupRecovery)
|
||||||
|
XCTAssertTrue(core.state.isInitialized)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ private final class BlockingCoreBindingFactory: CoreBindingFactory, @unchecked S
|
|||||||
throw BlockingCoreFactoryError.stopped
|
throw BlockingCoreFactoryError.stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resetUnrecoverableIdentity(
|
||||||
|
appDataDir: String,
|
||||||
|
eventSink: CoreEventSink,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) throws -> VnidropCore {
|
||||||
|
throw BlockingCoreFactoryError.stopped
|
||||||
|
}
|
||||||
|
|
||||||
func waitUntilInitializationStarts() async {
|
func waitUntilInitializationStarts() async {
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
lock.lock()
|
lock.lock()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ final class FakeCoreGateway: CoreGateway {
|
|||||||
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
|
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
|
||||||
var initializeResult: Result<Void, Error> = .success(())
|
var initializeResult: Result<Void, Error> = .success(())
|
||||||
var initializeResults: [Result<Void, Error>] = []
|
var initializeResults: [Result<Void, Error>] = []
|
||||||
|
var resetUnrecoverableIdentityResult: Result<Void, Error> = .success(())
|
||||||
|
|
||||||
// Recorded calls
|
// Recorded calls
|
||||||
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
|
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
|
||||||
@@ -38,6 +39,7 @@ final class FakeCoreGateway: CoreGateway {
|
|||||||
private(set) var lastReceiveReceiverName: String?
|
private(set) var lastReceiveReceiverName: String?
|
||||||
private(set) var lastShareAccessPolicy: ShareAccessPolicy?
|
private(set) var lastShareAccessPolicy: ShareAccessPolicy?
|
||||||
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
|
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
|
||||||
|
private(set) var resetUnrecoverableIdentityCount = 0
|
||||||
|
|
||||||
func setState(_ state: CoreState) { stateSubject.send(state) }
|
func setState(_ state: CoreState) { stateSubject.send(state) }
|
||||||
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
|
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
|
||||||
@@ -54,6 +56,19 @@ final class FakeCoreGateway: CoreGateway {
|
|||||||
stateSubject.send(s)
|
stateSubject.send(s)
|
||||||
return .success(())
|
return .success(())
|
||||||
}
|
}
|
||||||
|
func resetUnrecoverableIdentity(
|
||||||
|
appDataDir: String,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) async -> Result<Void, Error> {
|
||||||
|
resetUnrecoverableIdentityCount += 1
|
||||||
|
guard case .success = resetUnrecoverableIdentityResult else {
|
||||||
|
return resetUnrecoverableIdentityResult
|
||||||
|
}
|
||||||
|
var s = stateSubject.value
|
||||||
|
s.isInitialized = true
|
||||||
|
stateSubject.send(s)
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
func shutdown() {}
|
func shutdown() {}
|
||||||
func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
|
func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
|
||||||
lastShareAccessPolicy = accessPolicy
|
lastShareAccessPolicy = accessPolicy
|
||||||
|
|||||||
@@ -39,19 +39,20 @@ final class SavedDeviceCoreContractTests: XCTestCase {
|
|||||||
defer { try? FileManager.default.removeItem(at: directory) }
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
|
||||||
let sink = RecordingSink()
|
let sink = RecordingSink()
|
||||||
let first = try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
var first: VnidropCore? = try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
||||||
appDataDir: directory.path,
|
appDataDir: directory.path,
|
||||||
eventSink: sink,
|
eventSink: sink,
|
||||||
limits: defaultCoreLimits(),
|
limits: defaultCoreLimits(),
|
||||||
networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: [])
|
networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: [])
|
||||||
)
|
)
|
||||||
let endpointId = first.status().endpointId
|
let endpointId = first!.status().endpointId
|
||||||
XCTAssertFalse(endpointId.isEmpty)
|
XCTAssertFalse(endpointId.isEmpty)
|
||||||
XCTAssertFalse(
|
XCTAssertFalse(
|
||||||
FileManager.default.fileExists(atPath: directory.appendingPathComponent("iroh.secret").path),
|
FileManager.default.fileExists(atPath: directory.appendingPathComponent("iroh.secret").path),
|
||||||
"protected identity must not fall back to plaintext"
|
"protected identity must not fall back to plaintext"
|
||||||
)
|
)
|
||||||
first.shutdown()
|
first?.shutdown()
|
||||||
|
first = nil
|
||||||
|
|
||||||
let restarted = try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
let restarted = try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
||||||
appDataDir: directory.path,
|
appDataDir: directory.path,
|
||||||
@@ -134,8 +135,18 @@ final class SavedDeviceCoreContractTests: XCTestCase {
|
|||||||
let _: (
|
let _: (
|
||||||
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
|
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
|
||||||
) = VnidropCore.initializeWithLimitsAndNetworkConfig
|
) = VnidropCore.initializeWithLimitsAndNetworkConfig
|
||||||
|
let _: (
|
||||||
|
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
|
||||||
|
) = VnidropCore.resetUnrecoverableIdentityWithLimitsAndNetworkConfig
|
||||||
let capabilities: SavedDeviceCapabilities = savedDeviceCapabilities()
|
let capabilities: SavedDeviceCapabilities = savedDeviceCapabilities()
|
||||||
|
let role: TargetedTransferRole = .sender
|
||||||
XCTAssertGreaterThanOrEqual(capabilities.domainContractVersion, 1)
|
XCTAssertGreaterThanOrEqual(capabilities.domainContractVersion, 1)
|
||||||
|
switch role {
|
||||||
|
case .sender:
|
||||||
|
break
|
||||||
|
case .receiver:
|
||||||
|
XCTFail("targeted-transfer role binding decoded the wrong case")
|
||||||
|
}
|
||||||
XCTAssertNotNil(defaultCoreLimits().maxSavedDevices)
|
XCTAssertNotNil(defaultCoreLimits().maxSavedDevices)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -165,8 +176,11 @@ final class SavedDeviceCoreContractTests: XCTestCase {
|
|||||||
XCTAssertFalse(source.contains("ExperimentalSavedDeviceCapabilities"))
|
XCTAssertFalse(source.contains("ExperimentalSavedDeviceCapabilities"))
|
||||||
XCTAssertFalse(source.contains("experimentalSavedDeviceCapabilities"))
|
XCTAssertFalse(source.contains("experimentalSavedDeviceCapabilities"))
|
||||||
XCTAssertTrue(source.contains("initializeWithLimitsAndNetworkConfig"))
|
XCTAssertTrue(source.contains("initializeWithLimitsAndNetworkConfig"))
|
||||||
|
XCTAssertTrue(source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig"))
|
||||||
XCTAssertTrue(source.contains("public struct SavedDeviceCapabilities"))
|
XCTAssertTrue(source.contains("public struct SavedDeviceCapabilities"))
|
||||||
XCTAssertTrue(source.contains("public func savedDeviceCapabilities()"))
|
XCTAssertTrue(source.contains("public func savedDeviceCapabilities()"))
|
||||||
|
XCTAssertTrue(source.contains("public var role: TargetedTransferRole"))
|
||||||
|
XCTAssertTrue(source.contains("public enum TargetedTransferRole"))
|
||||||
XCTAssertTrue(source.contains("setSavedDeviceLabel"))
|
XCTAssertTrue(source.contains("setSavedDeviceLabel"))
|
||||||
XCTAssertTrue(source.contains("listSavedDevices"))
|
XCTAssertTrue(source.contains("listSavedDevices"))
|
||||||
XCTAssertTrue(source.contains("revision"))
|
XCTAssertTrue(source.contains("revision"))
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ final class UserFacingErrorTests: XCTestCase {
|
|||||||
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput))
|
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput))
|
||||||
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput)
|
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput)
|
||||||
XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput)
|
XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput)
|
||||||
|
XCTAssertFalse(VnidropError.SecureStorageMissing(reason: "credential is missing").canRetryWithoutChangingInput)
|
||||||
|
XCTAssertFalse(VnidropError.SecureStorageCorrupted(reason: "credential is corrupted").canRetryWithoutChangingInput)
|
||||||
XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput)
|
XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput)
|
||||||
|
XCTAssertTrue(VnidropError.SecureStorageLocked(reason: "credential store is locked").canRetryWithoutChangingInput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,13 @@ struct RootView: View {
|
|||||||
// A small, unobtrusive indicator while the core finishes its async
|
// A small, unobtrusive indicator while the core finishes its async
|
||||||
// startup — otherwise the lists look empty and the app feels stalled.
|
// startup — otherwise the lists look empty and the app feels stalled.
|
||||||
if !sendModel.coreState.isInitialized {
|
if !sendModel.coreState.isInitialized {
|
||||||
CoreStartingOverlay()
|
CoreStartingOverlay(
|
||||||
|
recovery: appModel.startupRecovery,
|
||||||
|
isResettingIdentity: appModel.isResettingIdentity,
|
||||||
|
onResetIdentity: {
|
||||||
|
Task { await appModel.resetUnrecoverableIdentity() }
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized)
|
.animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized)
|
||||||
@@ -255,19 +261,59 @@ private struct ApprovalLayer: View {
|
|||||||
|
|
||||||
/// A full-window cover with a centered spinner shown while the core is starting.
|
/// A full-window cover with a centered spinner shown while the core is starting.
|
||||||
private struct CoreStartingOverlay: View {
|
private struct CoreStartingOverlay: View {
|
||||||
|
let recovery: AppStartupRecovery?
|
||||||
|
let isResettingIdentity: Bool
|
||||||
|
let onResetIdentity: () -> Void
|
||||||
|
@State private var confirmsIdentityReset = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
backgroundColor.ignoresSafeArea()
|
backgroundColor.ignoresSafeArea()
|
||||||
VStack(spacing: 16) {
|
if recovery == .identityUnrecoverable {
|
||||||
ProgressView().controlSize(.large)
|
VStack(spacing: 18) {
|
||||||
Text(String(localized: L10n.App.starting))
|
Image(systemSymbol: .exclamationmarkTriangleFill)
|
||||||
.font(.headline)
|
.font(.system(size: 44))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.orange)
|
||||||
|
Text(String(localized: L10n.App.identityResetTitle))
|
||||||
|
.font(.title2.bold())
|
||||||
|
Text(String(localized: L10n.App.identityResetMessage))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: 420)
|
||||||
|
Button(role: .destructive) {
|
||||||
|
confirmsIdentityReset = true
|
||||||
|
} label: {
|
||||||
|
if isResettingIdentity {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Text(String(localized: L10n.App.identityResetAction))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(isResettingIdentity)
|
||||||
|
}
|
||||||
|
.padding(32)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView().controlSize(.large)
|
||||||
|
Text(String(localized: L10n.App.starting))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.accessibilityElement(children: .combine)
|
.alert(
|
||||||
.accessibilityLabel(Text(String(localized: L10n.App.starting)))
|
String(localized: L10n.App.identityResetTitle),
|
||||||
|
isPresented: $confirmsIdentityReset
|
||||||
|
) {
|
||||||
|
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
|
||||||
|
Button(String(localized: L10n.App.identityResetAction), role: .destructive) {
|
||||||
|
onResetIdentity()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(String(localized: L10n.App.identityResetConfirmation))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
private var backgroundColor: Color {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ protocol CoreGateway: AnyObject {
|
|||||||
var signals: AnyPublisher<CoreSignal, Never> { get }
|
var signals: AnyPublisher<CoreSignal, Never> { get }
|
||||||
|
|
||||||
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
|
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
|
||||||
|
func resetUnrecoverableIdentity(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
|
||||||
func shutdown()
|
func shutdown()
|
||||||
func shareSources(
|
func shareSources(
|
||||||
_ sources: [ShareSource],
|
_ sources: [ShareSource],
|
||||||
|
|||||||
@@ -28,36 +28,50 @@ protocol CoreBindingFactory: Sendable {
|
|||||||
eventSink: CoreEventSink,
|
eventSink: CoreEventSink,
|
||||||
networkConfiguration: RelayConfiguration
|
networkConfiguration: RelayConfiguration
|
||||||
) throws -> VnidropCore
|
) throws -> VnidropCore
|
||||||
|
func resetUnrecoverableIdentity(
|
||||||
|
appDataDir: String,
|
||||||
|
eventSink: CoreEventSink,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) throws -> VnidropCore
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NativeCoreBindingFactory: CoreBindingFactory {
|
struct NativeCoreBindingFactory: CoreBindingFactory {
|
||||||
|
private func nativeConfiguration(_ networkConfiguration: RelayConfiguration) -> CoreNetworkConfig {
|
||||||
|
switch networkConfiguration.mode {
|
||||||
|
case .automatic:
|
||||||
|
return defaultCoreNetworkConfig()
|
||||||
|
case .strictCustom:
|
||||||
|
return CoreNetworkConfig(mode: .strictCustom, relayUrls: networkConfiguration.relayURLs)
|
||||||
|
case .customWithDirectFallback:
|
||||||
|
return CoreNetworkConfig(mode: .customWithDirectFallback, relayUrls: networkConfiguration.relayURLs)
|
||||||
|
case .localOnly:
|
||||||
|
return CoreNetworkConfig(mode: .localOnly, relayUrls: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func initialize(
|
func initialize(
|
||||||
appDataDir: String,
|
appDataDir: String,
|
||||||
eventSink: CoreEventSink,
|
eventSink: CoreEventSink,
|
||||||
networkConfiguration: RelayConfiguration
|
networkConfiguration: RelayConfiguration
|
||||||
) throws -> VnidropCore {
|
) throws -> VnidropCore {
|
||||||
let nativeConfiguration: CoreNetworkConfig
|
|
||||||
switch networkConfiguration.mode {
|
|
||||||
case .automatic:
|
|
||||||
nativeConfiguration = defaultCoreNetworkConfig()
|
|
||||||
case .strictCustom:
|
|
||||||
nativeConfiguration = CoreNetworkConfig(
|
|
||||||
mode: .strictCustom,
|
|
||||||
relayUrls: networkConfiguration.relayURLs
|
|
||||||
)
|
|
||||||
case .customWithDirectFallback:
|
|
||||||
nativeConfiguration = CoreNetworkConfig(
|
|
||||||
mode: .customWithDirectFallback,
|
|
||||||
relayUrls: networkConfiguration.relayURLs
|
|
||||||
)
|
|
||||||
case .localOnly:
|
|
||||||
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
|
|
||||||
}
|
|
||||||
return try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
return try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
||||||
appDataDir: appDataDir,
|
appDataDir: appDataDir,
|
||||||
eventSink: eventSink,
|
eventSink: eventSink,
|
||||||
limits: defaultCoreLimits(),
|
limits: defaultCoreLimits(),
|
||||||
networkConfig: nativeConfiguration
|
networkConfig: nativeConfiguration(networkConfiguration)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetUnrecoverableIdentity(
|
||||||
|
appDataDir: String,
|
||||||
|
eventSink: CoreEventSink,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) throws -> VnidropCore {
|
||||||
|
try VnidropCore.resetUnrecoverableIdentityWithLimitsAndNetworkConfig(
|
||||||
|
appDataDir: appDataDir,
|
||||||
|
eventSink: eventSink,
|
||||||
|
limits: defaultCoreLimits(),
|
||||||
|
networkConfig: nativeConfiguration(networkConfiguration)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +153,35 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resetUnrecoverableIdentity(
|
||||||
|
appDataDir: String,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) async -> Result<Void, Error> {
|
||||||
|
guard !isNetworkTransitionInProgress else {
|
||||||
|
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||||
|
}
|
||||||
|
isNetworkTransitionInProgress = true
|
||||||
|
defer { isNetworkTransitionInProgress = false }
|
||||||
|
let result = await runCore { [sink] in
|
||||||
|
let created = try self.coreFactory.resetUnrecoverableIdentity(
|
||||||
|
appDataDir: appDataDir,
|
||||||
|
eventSink: sink,
|
||||||
|
networkConfiguration: networkConfiguration
|
||||||
|
)
|
||||||
|
self.core = created
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
refreshSnapshot()
|
||||||
|
state.isInitialized = true
|
||||||
|
return .success(())
|
||||||
|
case .failure(let error):
|
||||||
|
state = CoreState()
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func shutdown() {
|
func shutdown() {
|
||||||
core?.shutdown()
|
core?.shutdown()
|
||||||
core = nil
|
core = nil
|
||||||
@@ -522,4 +565,3 @@ private extension ReceiverRequest {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
import VnidropCore
|
||||||
|
|
||||||
|
enum AppStartupRecovery: Equatable {
|
||||||
|
case identityUnrecoverable
|
||||||
|
}
|
||||||
|
|
||||||
/// Top-level app state, ported from `feature/app/AppViewModel.kt`. Initializes the
|
/// Top-level app state, ported from `feature/app/AppViewModel.kt`. Initializes the
|
||||||
/// core on launch and tracks the selected destination + theme.
|
/// core on launch and tracks the selected destination + theme.
|
||||||
@@ -7,10 +12,14 @@ import Combine
|
|||||||
final class AppModel: ObservableObject {
|
final class AppModel: ObservableObject {
|
||||||
@Published private(set) var destination: AppDestination = .send
|
@Published private(set) var destination: AppDestination = .send
|
||||||
@Published private(set) var themeMode: ThemeMode = .system
|
@Published private(set) var themeMode: ThemeMode = .system
|
||||||
|
@Published private(set) var startupRecovery: AppStartupRecovery?
|
||||||
|
@Published private(set) var isResettingIdentity = false
|
||||||
|
|
||||||
private let environment: PlatformEnvironment
|
private let environment: PlatformEnvironment
|
||||||
private let repository: CoreGateway
|
private let repository: CoreGateway
|
||||||
private let messages: UiMessageController
|
private let messages: UiMessageController
|
||||||
|
private let appDataDir: String
|
||||||
|
private let networkConfiguration: RelayConfiguration
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -22,15 +31,23 @@ final class AppModel: ObservableObject {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
|
self.appDataDir = environment.defaultCoreDataDir
|
||||||
|
self.networkConfiguration = preferences.preferences.relayConfiguration
|
||||||
|
|
||||||
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
|
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
let result = await repository.initialize(
|
let result = await repository.initialize(
|
||||||
appDataDir: environment.defaultCoreDataDir,
|
appDataDir: appDataDir,
|
||||||
networkConfiguration: preferences.preferences.relayConfiguration
|
networkConfiguration: networkConfiguration
|
||||||
)
|
)
|
||||||
if case .failure(let error) = result { messages.error(error) }
|
if case .failure(let error) = result {
|
||||||
|
if error.hasUnrecoverableEndpointIdentity {
|
||||||
|
startupRecovery = .identityUnrecoverable
|
||||||
|
} else {
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
preferences.$preferences
|
preferences.$preferences
|
||||||
@@ -44,4 +61,30 @@ final class AppModel: ObservableObject {
|
|||||||
guard destination != self.destination else { return }
|
guard destination != self.destination else { return }
|
||||||
self.destination = destination
|
self.destination = destination
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resetUnrecoverableIdentity() async {
|
||||||
|
guard startupRecovery == .identityUnrecoverable, !isResettingIdentity else { return }
|
||||||
|
isResettingIdentity = true
|
||||||
|
defer { isResettingIdentity = false }
|
||||||
|
let result = await repository.resetUnrecoverableIdentity(
|
||||||
|
appDataDir: appDataDir,
|
||||||
|
networkConfiguration: networkConfiguration
|
||||||
|
)
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
startupRecovery = nil
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Error {
|
||||||
|
var hasUnrecoverableEndpointIdentity: Bool {
|
||||||
|
guard let error = self as? VnidropError else { return false }
|
||||||
|
switch error {
|
||||||
|
case .SecureStorageMissing, .SecureStorageCorrupted: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ extension Error {
|
|||||||
var canRetryWithoutChangingInput: Bool {
|
var canRetryWithoutChangingInput: Bool {
|
||||||
guard let vni = self as? VnidropError else { return true }
|
guard let vni = self as? VnidropError else { return true }
|
||||||
switch vni {
|
switch vni {
|
||||||
case .FilesystemPermission, .DestinationExists, .InvalidInput: return false
|
case .FilesystemPermission, .DestinationExists, .InvalidInput,
|
||||||
|
.SecureStorageMissing, .SecureStorageCorrupted:
|
||||||
|
return false
|
||||||
default: return true
|
default: return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,13 @@ bytes through Kotlin memory.
|
|||||||
work before durable cleanup; forget and block revoke affected relationships
|
work before durable cleanup; forget and block revoke affected relationships
|
||||||
and active targeted work within their core operation. All four durably deny
|
and active targeted work within their core operation. All four durably deny
|
||||||
reuse and perform idempotent payload/secret cleanup.
|
reuse and perform idempotent payload/secret cleanup.
|
||||||
7. Saved devices, relationships, pairing eligibility, and targeted transfers are
|
7. A missing or corrupted endpoint credential remains fail-closed during normal
|
||||||
|
startup. The explicit pre-start identity-reset constructor accepts only that
|
||||||
|
unrecoverable state, atomically invalidates identity-bound relationships,
|
||||||
|
eligibility, targeted authorization, and retry state, then creates a new
|
||||||
|
protected identity. Transfer history and received files remain; old active
|
||||||
|
Invitation transfers are stopped and Saved devices must be paired again.
|
||||||
|
8. Saved devices, relationships, pairing eligibility, and targeted transfers are
|
||||||
shipped Rust core domains. Graduating the KMP and Apple Saved-device UI and
|
shipped Rust core domains. Graduating the KMP and Apple Saved-device UI and
|
||||||
their existing experimental preference gates is outside this release gate.
|
their existing experimental preference gates is outside this release gate.
|
||||||
|
|
||||||
|
|||||||
@@ -88,10 +88,18 @@ pub enum TargetedTransferState {
|
|||||||
Deleted,
|
Deleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This installation's immutable role in a Targeted transfer.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||||
|
pub enum TargetedTransferRole {
|
||||||
|
Sender,
|
||||||
|
Receiver,
|
||||||
|
}
|
||||||
|
|
||||||
/// Immutable recipient-bound transfer snapshot, separate from an ordinary share.
|
/// Immutable recipient-bound transfer snapshot, separate from an ordinary share.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||||
pub struct TargetedTransfer {
|
pub struct TargetedTransfer {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub role: TargetedTransferRole,
|
||||||
pub sender_endpoint_id: String,
|
pub sender_endpoint_id: String,
|
||||||
pub receiver_endpoint_id: String,
|
pub receiver_endpoint_id: String,
|
||||||
pub manifest_id: String,
|
pub manifest_id: String,
|
||||||
|
|||||||
127
crates/vnidrop/src/identity_recovery.rs
Normal file
127
crates/vnidrop/src/identity_recovery.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
//! Explicit recovery for an unrecoverable protected endpoint identity.
|
||||||
|
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
|
use crate::{error::VnidropError, util::now_ms};
|
||||||
|
|
||||||
|
/// Owns the cross-domain transaction that invalidates trust bound to an old identity.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct IdentityRecoveryStore {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityRecoveryStore {
|
||||||
|
pub(crate) fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preserve completed history and received artifacts, but revoke every
|
||||||
|
/// capability that could authenticate or resume work as the lost identity.
|
||||||
|
pub(crate) async fn reset_identity_bound_state(&self) -> Result<Vec<String>, VnidropError> {
|
||||||
|
let now = now_ms();
|
||||||
|
let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?;
|
||||||
|
let handles = sqlx::query("SELECT handle FROM protected_secret_refs ORDER BY handle")
|
||||||
|
.fetch_all(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| row.get::<String, _>(0))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE transfers
|
||||||
|
SET status = CASE
|
||||||
|
WHEN status = 'sharing' THEN 'stopped'
|
||||||
|
WHEN status IN ('importing', 'receiving') THEN 'cancelled'
|
||||||
|
ELSE status
|
||||||
|
END,
|
||||||
|
ticket = CASE
|
||||||
|
WHEN status IN ('sharing', 'importing', 'receiving') THEN NULL
|
||||||
|
ELSE ticket
|
||||||
|
END,
|
||||||
|
updated_at = CASE
|
||||||
|
WHEN status IN ('sharing', 'importing', 'receiving') THEN ?1
|
||||||
|
ELSE updated_at
|
||||||
|
END
|
||||||
|
WHERE status IN ('sharing', 'importing', 'receiving')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE receiver_requests
|
||||||
|
SET status = CASE WHEN status = 'requested' THEN 'expired' ELSE 'failed' END,
|
||||||
|
reason = 'device identity reset',
|
||||||
|
responded_at = COALESCE(responded_at, ?1)
|
||||||
|
WHERE status IN ('requested', 'accepted')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
sqlx::query("DELETE FROM pending_delivery_receipts")
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
|
||||||
|
for table in [
|
||||||
|
"targeted_accepted_offer_intents",
|
||||||
|
"targeted_authorization_delivery_outbox",
|
||||||
|
"targeted_completion_outbox",
|
||||||
|
"targeted_payload_release_outbox",
|
||||||
|
] {
|
||||||
|
sqlx::query(&format!("DELETE FROM {table}"))
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE targeted_transfers
|
||||||
|
SET state = CASE
|
||||||
|
WHEN state IN (
|
||||||
|
'preparing', 'offering', 'awaiting_approval', 'approved',
|
||||||
|
'connecting', 'transferring', 'interrupted'
|
||||||
|
) THEN 'cancelled'
|
||||||
|
ELSE state
|
||||||
|
END,
|
||||||
|
blob_ticket = NULL,
|
||||||
|
authorization_secret_handle = NULL,
|
||||||
|
updated_at = CASE
|
||||||
|
WHEN state IN (
|
||||||
|
'preparing', 'offering', 'awaiting_approval', 'approved',
|
||||||
|
'connecting', 'transferring', 'interrupted'
|
||||||
|
) OR blob_ticket IS NOT NULL OR authorization_secret_handle IS NOT NULL
|
||||||
|
THEN ?1
|
||||||
|
ELSE updated_at
|
||||||
|
END
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
|
||||||
|
for table in [
|
||||||
|
"pairing_eligibilities",
|
||||||
|
"device_relationships",
|
||||||
|
"relationship_generation_tombstones",
|
||||||
|
"protected_secret_refs",
|
||||||
|
] {
|
||||||
|
sqlx::query(&format!("DELETE FROM {table}"))
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
}
|
||||||
|
transaction
|
||||||
|
.commit()
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
Ok(handles)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ mod event_hub;
|
|||||||
mod filesystem;
|
mod filesystem;
|
||||||
mod grant;
|
mod grant;
|
||||||
mod handshake;
|
mod handshake;
|
||||||
|
mod identity_recovery;
|
||||||
mod invitation;
|
mod invitation;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod pairing_eligibility;
|
mod pairing_eligibility;
|
||||||
@@ -31,8 +32,8 @@ pub use api::{
|
|||||||
PairingEligibilitySummary, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink,
|
PairingEligibilitySummary, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink,
|
||||||
ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus,
|
ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus,
|
||||||
SavedDevice, SavedDeviceCapabilities, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
|
SavedDevice, SavedDeviceCapabilities, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
|
||||||
StoredTransfer, TargetedOfferResponse, TargetedTransfer, TargetedTransferState,
|
StoredTransfer, TargetedOfferResponse, TargetedTransfer, TargetedTransferRole,
|
||||||
TicketInspection, TransferAccessMode, TransferMetadata,
|
TargetedTransferState, TicketInspection, TransferAccessMode, TransferMetadata,
|
||||||
};
|
};
|
||||||
pub use error::VnidropError;
|
pub use error::VnidropError;
|
||||||
pub use runtime::VnidropCore;
|
pub use runtime::VnidropCore;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
|||||||
use crate::{
|
use crate::{
|
||||||
blocked_devices::{self, BlockStore},
|
blocked_devices::{self, BlockStore},
|
||||||
device_relationship::DeviceRelationshipStore,
|
device_relationship::DeviceRelationshipStore,
|
||||||
|
identity_recovery::IdentityRecoveryStore,
|
||||||
invitation::Repository,
|
invitation::Repository,
|
||||||
pairing_eligibility::PairingEligibilityStore,
|
pairing_eligibility::PairingEligibilityStore,
|
||||||
secure_secret::{self, SecretMetadataStore},
|
secure_secret::{self, SecretMetadataStore},
|
||||||
@@ -32,6 +33,8 @@ pub(crate) struct AppDataStores {
|
|||||||
pub(crate) secrets: SecretMetadataStore,
|
pub(crate) secrets: SecretMetadataStore,
|
||||||
/// Identity-wide deny list.
|
/// Identity-wide deny list.
|
||||||
pub(crate) blocked: BlockStore,
|
pub(crate) blocked: BlockStore,
|
||||||
|
/// Explicit endpoint-identity reset transaction.
|
||||||
|
pub(crate) identity_recovery: IdentityRecoveryStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the profile pool, apply all domain schemas, return [`AppDataStores`].
|
/// Create the profile pool, apply all domain schemas, return [`AppDataStores`].
|
||||||
@@ -66,6 +69,7 @@ pub(crate) async fn open_all(app_data_dir: &Path) -> Result<AppDataStores> {
|
|||||||
relationships: DeviceRelationshipStore::new(pool.clone()),
|
relationships: DeviceRelationshipStore::new(pool.clone()),
|
||||||
eligibility: PairingEligibilityStore::new(pool.clone()),
|
eligibility: PairingEligibilityStore::new(pool.clone()),
|
||||||
secrets: SecretMetadataStore::new(pool.clone()),
|
secrets: SecretMetadataStore::new(pool.clone()),
|
||||||
|
identity_recovery: IdentityRecoveryStore::new(pool.clone()),
|
||||||
blocked: BlockStore::new(pool),
|
blocked: BlockStore::new(pool),
|
||||||
invitation,
|
invitation,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ impl VnidropCore {
|
|||||||
self.runtime.handle().block_on(future)
|
self.runtime.handle().block_on(future)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn initialize_with_identity_mode(
|
pub(super) fn initialize_with_identity_mode(
|
||||||
app_data_dir: String,
|
app_data_dir: String,
|
||||||
event_sink: Arc<dyn CoreEventSink>,
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
limits: CoreLimits,
|
limits: CoreLimits,
|
||||||
@@ -497,6 +497,24 @@ impl VnidropCore {
|
|||||||
Self::initialize_protected(app_data_dir, event_sink, limits, network_config)
|
Self::initialize_protected(app_data_dir, event_sink, limits, network_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicitly replace an endpoint identity whose protected credential is
|
||||||
|
/// missing or corrupted, invalidate identity-bound trust, and initialize.
|
||||||
|
/// A readable identity is never reset by this constructor.
|
||||||
|
#[uniffi::constructor]
|
||||||
|
pub fn reset_unrecoverable_identity_with_limits_and_network_config(
|
||||||
|
app_data_dir: String,
|
||||||
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
|
limits: CoreLimits,
|
||||||
|
network_config: CoreNetworkConfig,
|
||||||
|
) -> Result<Arc<Self>, VnidropError> {
|
||||||
|
Self::reset_unrecoverable_identity_protected(
|
||||||
|
app_data_dir,
|
||||||
|
event_sink,
|
||||||
|
limits,
|
||||||
|
network_config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn status(&self) -> RuntimeStatus {
|
pub fn status(&self) -> RuntimeStatus {
|
||||||
self.block_on(self.inner.status())
|
self.block_on(self.inner.status())
|
||||||
}
|
}
|
||||||
|
|||||||
89
crates/vnidrop/src/runtime/identity_recovery.rs
Normal file
89
crates/vnidrop/src/runtime/identity_recovery.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//! Pre-start recovery for a missing or corrupted protected endpoint identity.
|
||||||
|
|
||||||
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
|
use super::{IdentityMode, VnidropCore};
|
||||||
|
use crate::{
|
||||||
|
api::{CoreEventSink, CoreLimits, CoreNetworkConfig},
|
||||||
|
error::VnidropError,
|
||||||
|
secure_secret::{lock_profile, platform_secret_store, SecretCustody, SecureSecretStore},
|
||||||
|
};
|
||||||
|
|
||||||
|
impl VnidropCore {
|
||||||
|
pub(super) fn reset_unrecoverable_identity_protected(
|
||||||
|
app_data_dir: String,
|
||||||
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
|
limits: CoreLimits,
|
||||||
|
network_config: CoreNetworkConfig,
|
||||||
|
) -> Result<Arc<Self>, VnidropError> {
|
||||||
|
let app_data_path = PathBuf::from(app_data_dir);
|
||||||
|
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
|
||||||
|
let app_data_path =
|
||||||
|
std::fs::canonicalize(app_data_path).map_err(VnidropError::filesystem)?;
|
||||||
|
let profile_lock = lock_profile(&app_data_path)?;
|
||||||
|
let store = platform_secret_store(&app_data_path)?;
|
||||||
|
Self::reset_unrecoverable_identity_with_store(
|
||||||
|
app_data_path,
|
||||||
|
event_sink,
|
||||||
|
limits,
|
||||||
|
network_config,
|
||||||
|
store,
|
||||||
|
profile_lock,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_unrecoverable_identity_with_store(
|
||||||
|
app_data_path: PathBuf,
|
||||||
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
|
limits: CoreLimits,
|
||||||
|
network_config: CoreNetworkConfig,
|
||||||
|
store: Arc<dyn SecureSecretStore>,
|
||||||
|
profile_lock: crate::secure_secret::ProfileLock,
|
||||||
|
) -> Result<Arc<Self>, VnidropError> {
|
||||||
|
let recovery_runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
recovery_runtime.block_on(async {
|
||||||
|
let stores = crate::persistence::open_all(&app_data_path)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
let custody =
|
||||||
|
SecretCustody::for_explicit_identity_reset(stores.secrets.clone(), store.clone());
|
||||||
|
custody.require_unrecoverable_endpoint_identity().await?;
|
||||||
|
let handles = stores
|
||||||
|
.identity_recovery
|
||||||
|
.reset_identity_bound_state()
|
||||||
|
.await?;
|
||||||
|
custody.delete_reset_handles(handles).await
|
||||||
|
})?;
|
||||||
|
Self::initialize_with_identity_mode(
|
||||||
|
app_data_path.to_string_lossy().into_owned(),
|
||||||
|
event_sink,
|
||||||
|
limits,
|
||||||
|
network_config,
|
||||||
|
IdentityMode::Protected {
|
||||||
|
store,
|
||||||
|
profile_lock,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn reset_unrecoverable_identity_with_test_secret_store(
|
||||||
|
app_data_dir: String,
|
||||||
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
|
store: Arc<dyn SecureSecretStore>,
|
||||||
|
) -> Result<Arc<Self>, VnidropError> {
|
||||||
|
let app_data_path = PathBuf::from(&app_data_dir);
|
||||||
|
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
|
||||||
|
let profile_lock = crate::secure_secret::unlocked_profile_for_test(&app_data_path)?;
|
||||||
|
Self::reset_unrecoverable_identity_with_store(
|
||||||
|
app_data_path,
|
||||||
|
event_sink,
|
||||||
|
CoreLimits::default(),
|
||||||
|
CoreNetworkConfig::default(),
|
||||||
|
store,
|
||||||
|
profile_lock,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
mod delivery;
|
mod delivery;
|
||||||
mod facade;
|
mod facade;
|
||||||
|
mod identity_recovery;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod receive;
|
mod receive;
|
||||||
|
|||||||
@@ -389,6 +389,64 @@ impl SecretCustody {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn for_explicit_identity_reset(
|
||||||
|
metadata: SecretMetadataStore,
|
||||||
|
store: Arc<dyn SecureSecretStore>,
|
||||||
|
) -> Self {
|
||||||
|
Self::from_parts(metadata, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject reset while the current identity remains readable. Missing or
|
||||||
|
/// corrupted endpoint custody is unrecoverable without an explicit reset.
|
||||||
|
pub(crate) async fn require_unrecoverable_endpoint_identity(&self) -> Result<(), VnidropError> {
|
||||||
|
let endpoints = self
|
||||||
|
.metadata
|
||||||
|
.list()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
entry.kind == SecretKind::EndpointIdentity
|
||||||
|
&& entry.state != SecretMetadataState::Disabled
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if endpoints.is_empty() {
|
||||||
|
// Idempotent continuation after a reset transaction committed but
|
||||||
|
// the process stopped before a replacement identity was activated.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for endpoint in endpoints {
|
||||||
|
match self.store_get_raw(endpoint.handle).await? {
|
||||||
|
Ok(material) => {
|
||||||
|
if validate_material(
|
||||||
|
SecretKind::EndpointIdentity,
|
||||||
|
&material,
|
||||||
|
endpoint.expected_identity.as_deref(),
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Err(VnidropError::InvalidInput {
|
||||||
|
reason: "protected endpoint identity is still available".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => {}
|
||||||
|
Err(error) => return Err(map_store_error(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_reset_handles(
|
||||||
|
&self,
|
||||||
|
handles: Vec<String>,
|
||||||
|
) -> Result<(), VnidropError> {
|
||||||
|
for handle in handles {
|
||||||
|
self.delete_if_present(&SecretHandle::from_stored(handle))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn protect(
|
pub(crate) async fn protect(
|
||||||
&self,
|
&self,
|
||||||
kind: SecretKind,
|
kind: SecretKind,
|
||||||
@@ -872,6 +930,23 @@ impl FaultInjectingSecretStore {
|
|||||||
handles.into_iter().next().unwrap()
|
handles.into_iter().next().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn endpoint_identity_handle_for_test(&self) -> SecretHandle {
|
||||||
|
let handles = self
|
||||||
|
.values
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.keys()
|
||||||
|
.filter(|handle| handle.as_str().contains("/endpoint-identity/"))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
handles.len(),
|
||||||
|
1,
|
||||||
|
"expected exactly one protected endpoint identity"
|
||||||
|
);
|
||||||
|
handles.into_iter().next().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn check_available(&self) -> Result<(), SecureSecretStoreError> {
|
fn check_available(&self) -> Result<(), SecureSecretStoreError> {
|
||||||
match *self.failure.lock().unwrap() {
|
match *self.failure.lock().unwrap() {
|
||||||
Some(ReferenceStoreFailure::Locked) => Err(SecureSecretStoreError::Locked),
|
Some(ReferenceStoreFailure::Locked) => Err(SecureSecretStoreError::Locked),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mod state;
|
|||||||
mod store;
|
mod store;
|
||||||
mod store_outbox;
|
mod store_outbox;
|
||||||
|
|
||||||
|
pub(crate) use crate::api::TargetedTransferRole;
|
||||||
pub(crate) use auth::{
|
pub(crate) use auth::{
|
||||||
auth_secret_material, reconstruct_authorization, TargetedAuthorization,
|
auth_secret_material, reconstruct_authorization, TargetedAuthorization,
|
||||||
TargetedAuthorizationDraft,
|
TargetedAuthorizationDraft,
|
||||||
@@ -18,6 +19,4 @@ pub(crate) use auth::{
|
|||||||
pub(crate) use inbox::{RespondError, TargetedOfferInbox};
|
pub(crate) use inbox::{RespondError, TargetedOfferInbox};
|
||||||
pub(crate) use protocol::TargetedTransferProtocol;
|
pub(crate) use protocol::TargetedTransferProtocol;
|
||||||
pub(crate) use schema::ensure_schema;
|
pub(crate) use schema::ensure_schema;
|
||||||
pub(crate) use store::{
|
pub(crate) use store::{state_as_str, TargetedTransferRow, TargetedTransferStore};
|
||||||
state_as_str, TargetedTransferRole, TargetedTransferRow, TargetedTransferStore,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
use sqlx::{Row, SqlitePool};
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{TargetedTransfer, TargetedTransferState},
|
api::{TargetedTransfer, TargetedTransferRole, TargetedTransferState},
|
||||||
error::VnidropError,
|
error::VnidropError,
|
||||||
util::now_ms,
|
util::now_ms,
|
||||||
};
|
};
|
||||||
@@ -430,7 +430,7 @@ impl TargetedTransferStore {
|
|||||||
pub(crate) async fn get(&self, id: &str) -> Result<Option<TargetedTransfer>, VnidropError> {
|
pub(crate) async fn get(&self, id: &str) -> Result<Option<TargetedTransfer>, VnidropError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, sender_endpoint_id, receiver_endpoint_id, manifest_id, transfer_name,
|
SELECT id, role, sender_endpoint_id, receiver_endpoint_id, manifest_id, transfer_name,
|
||||||
file_count, total_size, verified_bytes, state, created_at, updated_at
|
file_count, total_size, verified_bytes, state, created_at, updated_at
|
||||||
FROM targeted_transfers WHERE id = ?1
|
FROM targeted_transfers WHERE id = ?1
|
||||||
"#,
|
"#,
|
||||||
@@ -465,7 +465,7 @@ impl TargetedTransferStore {
|
|||||||
pub(crate) async fn list(&self) -> Result<Vec<TargetedTransfer>, VnidropError> {
|
pub(crate) async fn list(&self) -> Result<Vec<TargetedTransfer>, VnidropError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, sender_endpoint_id, receiver_endpoint_id, manifest_id, transfer_name,
|
SELECT id, role, sender_endpoint_id, receiver_endpoint_id, manifest_id, transfer_name,
|
||||||
file_count, total_size, verified_bytes, state, created_at, updated_at
|
file_count, total_size, verified_bytes, state, created_at, updated_at
|
||||||
FROM targeted_transfers
|
FROM targeted_transfers
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
@@ -692,12 +692,6 @@ impl TargetedTransferStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub(crate) enum TargetedTransferRole {
|
|
||||||
Sender,
|
|
||||||
Receiver,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct TargetedTransferRow {
|
pub(crate) struct TargetedTransferRow {
|
||||||
pub(crate) id: String,
|
pub(crate) id: String,
|
||||||
@@ -721,6 +715,7 @@ pub(crate) struct TargetedTransferRow {
|
|||||||
fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<TargetedTransfer, VnidropError> {
|
fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<TargetedTransfer, VnidropError> {
|
||||||
Ok(TargetedTransfer {
|
Ok(TargetedTransfer {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
|
role: parse_role(&row.get::<String, _>("role"))?,
|
||||||
sender_endpoint_id: row.get("sender_endpoint_id"),
|
sender_endpoint_id: row.get("sender_endpoint_id"),
|
||||||
receiver_endpoint_id: row.get("receiver_endpoint_id"),
|
receiver_endpoint_id: row.get("receiver_endpoint_id"),
|
||||||
manifest_id: row.get("manifest_id"),
|
manifest_id: row.get("manifest_id"),
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ fn public_api_exposes_saved_device_surface_without_prototype_contact_entry_point
|
|||||||
"fn share_files(",
|
"fn share_files(",
|
||||||
"fn receive(",
|
"fn receive(",
|
||||||
"saved_device_capabilities",
|
"saved_device_capabilities",
|
||||||
|
"fn reset_unrecoverable_identity_with_limits_and_network_config(",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
facade.contains(required) || api.contains(required) || lib.contains(required),
|
facade.contains(required) || api.contains(required) || lib.contains(required),
|
||||||
|
|||||||
@@ -682,6 +682,73 @@ fn saved_remote_name_and_local_label_survive_restart() {
|
|||||||
restarted.shutdown();
|
restarted.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_identity_reset_recovers_missing_credential_without_silent_replacement() {
|
||||||
|
let alice = ProtectedNode::new();
|
||||||
|
let bob = ProtectedNode::new();
|
||||||
|
reach_saved(&alice, &bob, 90_063);
|
||||||
|
let original_endpoint_id = alice.core.status().endpoint_id;
|
||||||
|
let invitation_history = alice
|
||||||
|
.core
|
||||||
|
.list_transfers()
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|transfer| (transfer.local_id, transfer.transfer_name))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(!alice.core.list_saved_devices().unwrap().is_empty());
|
||||||
|
|
||||||
|
alice.core.shutdown();
|
||||||
|
let endpoint_handle = alice.store.endpoint_identity_handle_for_test();
|
||||||
|
alice.store.remove_for_test(&endpoint_handle);
|
||||||
|
let app_data_dir = alice._data_dir.path().to_string_lossy().into_owned();
|
||||||
|
assert!(matches!(
|
||||||
|
VnidropCore::initialize_with_test_secret_store(
|
||||||
|
app_data_dir.clone(),
|
||||||
|
Arc::new(RecordingSink {
|
||||||
|
events: Mutex::new(Vec::new()),
|
||||||
|
}),
|
||||||
|
alice.store.clone(),
|
||||||
|
),
|
||||||
|
Err(crate::VnidropError::SecureStorageMissing { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let recovered = VnidropCore::reset_unrecoverable_identity_with_test_secret_store(
|
||||||
|
app_data_dir,
|
||||||
|
Arc::new(RecordingSink {
|
||||||
|
events: Mutex::new(Vec::new()),
|
||||||
|
}),
|
||||||
|
alice.store.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_ne!(recovered.status().endpoint_id, original_endpoint_id);
|
||||||
|
assert!(recovered.list_saved_devices().unwrap().is_empty());
|
||||||
|
assert!(recovered.list_device_relationships().unwrap().is_empty());
|
||||||
|
assert!(recovered.list_pairing_eligibilities().unwrap().is_empty());
|
||||||
|
let recovered_history = recovered.list_transfers().unwrap();
|
||||||
|
assert_eq!(recovered_history.len(), invitation_history.len());
|
||||||
|
assert_eq!(recovered_history[0].status, "stopped");
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
recovered_history[0].local_id.clone(),
|
||||||
|
recovered_history[0].transfer_name.clone(),
|
||||||
|
),
|
||||||
|
invitation_history[0]
|
||||||
|
);
|
||||||
|
recovered.shutdown();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
VnidropCore::reset_unrecoverable_identity_with_test_secret_store(
|
||||||
|
alice._data_dir.path().to_string_lossy().into_owned(),
|
||||||
|
Arc::new(RecordingSink {
|
||||||
|
events: Mutex::new(Vec::new()),
|
||||||
|
}),
|
||||||
|
alice.store.clone(),
|
||||||
|
),
|
||||||
|
Err(crate::VnidropError::InvalidInput { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn events_carry_stable_ids_and_monotonic_revisions() {
|
fn events_carry_stable_ids_and_monotonic_revisions() {
|
||||||
let alice = ProtectedNode::new();
|
let alice = ProtectedNode::new();
|
||||||
|
|||||||
@@ -847,11 +847,20 @@ fn apple_public_bindings_omit_raw_secrets_and_generic_mutation() {
|
|||||||
source.contains("initializeWithLimitsAndNetworkConfig"),
|
source.contains("initializeWithLimitsAndNetworkConfig"),
|
||||||
"Swift bindings must expose standard protected initialization"
|
"Swift bindings must expose standard protected initialization"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig"),
|
||||||
|
"Swift bindings must expose explicit endpoint-identity recovery"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
source.contains("public struct SavedDeviceCapabilities")
|
source.contains("public struct SavedDeviceCapabilities")
|
||||||
&& source.contains("public func savedDeviceCapabilities()"),
|
&& source.contains("public func savedDeviceCapabilities()"),
|
||||||
"Swift bindings must expose production saved-device capabilities"
|
"Swift bindings must expose production saved-device capabilities"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
source.contains("public var role: TargetedTransferRole")
|
||||||
|
&& source.contains("public enum TargetedTransferRole"),
|
||||||
|
"Swift targeted-transfer snapshots must expose their persisted role"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
source.contains("setSavedDeviceLabel"),
|
source.contains("setSavedDeviceLabel"),
|
||||||
"Swift bindings must expose saved-device rename"
|
"Swift bindings must expose saved-device rename"
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ use crate::{
|
|||||||
secure_secret::{FaultInjectingSecretStore, ReferenceStoreFailure},
|
secure_secret::{FaultInjectingSecretStore, ReferenceStoreFailure},
|
||||||
CoreEvent, CoreEventSink, CoreNetworkConfig, CoreRelayMode, DeviceRelationshipState,
|
CoreEvent, CoreEventSink, CoreNetworkConfig, CoreRelayMode, DeviceRelationshipState,
|
||||||
PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
|
PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
|
||||||
ReceivedLocatorKind, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState,
|
ReceivedLocatorKind, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferRole,
|
||||||
TransferAccessMode, VnidropCore, VnidropError,
|
TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct RecordingSink {
|
struct RecordingSink {
|
||||||
@@ -338,6 +338,58 @@ fn create_targeted_transfer_is_immutable_and_saved_only() {
|
|||||||
assert_eq!(listed.total_size, transfer.total_size);
|
assert_eq!(listed.total_size, transfer.total_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_reset_cancels_targeted_authorization_bound_to_the_lost_endpoint() {
|
||||||
|
let mut alice = ProtectedNode::new();
|
||||||
|
let bob = ProtectedNode::new();
|
||||||
|
let bob_id = bob.core().status().endpoint_id;
|
||||||
|
establish_saved(&alice, &bob, 10_002);
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("identity-bound.txt");
|
||||||
|
std::fs::write(&source_path, b"identity-bound authorization").unwrap();
|
||||||
|
let bob_core = bob.core();
|
||||||
|
let accept = std::thread::spawn(move || {
|
||||||
|
let offer = wait_for_pending_offer(&bob_core);
|
||||||
|
bob_core
|
||||||
|
.respond_to_targeted_offer(offer.transfer_id, true)
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
let transfer = alice
|
||||||
|
.core()
|
||||||
|
.create_targeted_transfer(
|
||||||
|
bob_id,
|
||||||
|
vec![targeted_source(&source_path)],
|
||||||
|
Some("identity-bound.txt".to_string()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
accept.join().unwrap(),
|
||||||
|
crate::TargetedOfferResponse::Approved { .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
alice.core.take().unwrap().shutdown();
|
||||||
|
let endpoint_handle = alice.secret_store.endpoint_identity_handle_for_test();
|
||||||
|
alice.secret_store.remove_for_test(&endpoint_handle);
|
||||||
|
let recovered = VnidropCore::reset_unrecoverable_identity_with_test_secret_store(
|
||||||
|
alice.data_dir.path().to_string_lossy().into_owned(),
|
||||||
|
alice.sink.clone(),
|
||||||
|
alice.secret_store.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let snapshot = recovered
|
||||||
|
.get_targeted_transfer(transfer.id.clone())
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(snapshot.role, TargetedTransferRole::Sender);
|
||||||
|
assert_eq!(snapshot.state, TargetedTransferState::Cancelled);
|
||||||
|
assert!(recovered
|
||||||
|
.targeted_blob_ticket_for_test(transfer.id)
|
||||||
|
.is_err());
|
||||||
|
assert!(recovered.list_saved_devices().unwrap().is_empty());
|
||||||
|
recovered.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preapproval_offer_is_authenticated_without_ordinary_share_ticket() {
|
fn preapproval_offer_is_authenticated_without_ordinary_share_ticket() {
|
||||||
let alice = ProtectedNode::new();
|
let alice = ProtectedNode::new();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use support::TestNode;
|
|||||||
use vnidrop::{
|
use vnidrop::{
|
||||||
saved_device_capabilities, DeviceRelationship, DeviceRelationshipState, SavedDevice,
|
saved_device_capabilities, DeviceRelationship, DeviceRelationshipState, SavedDevice,
|
||||||
SavedDeviceCapabilities, ShareMetadataInput, ShareSource, SourceKind, TargetedTransfer,
|
SavedDeviceCapabilities, ShareMetadataInput, ShareSource, SourceKind, TargetedTransfer,
|
||||||
TargetedTransferState, TransferAccessMode, VnidropError,
|
TargetedTransferRole, TargetedTransferState, TransferAccessMode, VnidropError,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -38,6 +38,7 @@ fn saved_devices_relationships_and_targeted_transfers_are_distinct_contracts() {
|
|||||||
};
|
};
|
||||||
let transfer = TargetedTransfer {
|
let transfer = TargetedTransfer {
|
||||||
id: "targeted-transfer-id".to_string(),
|
id: "targeted-transfer-id".to_string(),
|
||||||
|
role: TargetedTransferRole::Sender,
|
||||||
sender_endpoint_id: "sender-endpoint".to_string(),
|
sender_endpoint_id: "sender-endpoint".to_string(),
|
||||||
receiver_endpoint_id: device.endpoint_id.clone(),
|
receiver_endpoint_id: device.endpoint_id.clone(),
|
||||||
manifest_id: "immutable-manifest-id".to_string(),
|
manifest_id: "immutable-manifest-id".to_string(),
|
||||||
|
|||||||
@@ -335,6 +335,66 @@
|
|||||||
"ru": "Запуск…"
|
"ru": "Запуск…"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"app_identity_reset_action": {
|
||||||
|
"context": "Startup recovery: destructive action that replaces an unrecoverable device identity.",
|
||||||
|
"targets": ["apple"],
|
||||||
|
"translations": {
|
||||||
|
"en": "Reset device identity",
|
||||||
|
"fr": "Réinitialiser l’identité de l’appareil",
|
||||||
|
"es": "Restablecer la identidad del dispositivo",
|
||||||
|
"it": "Reimposta l’identità del dispositivo",
|
||||||
|
"de": "Geräteidentität zurücksetzen",
|
||||||
|
"pt": "Repor a identidade do dispositivo",
|
||||||
|
"pl": "Zresetuj tożsamość urządzenia",
|
||||||
|
"nl": "Apparaatidentiteit opnieuw instellen",
|
||||||
|
"ru": "Сбросить идентификатор устройства"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"app_identity_reset_confirmation": {
|
||||||
|
"context": "Startup recovery: confirmation explaining the effects of replacing an unrecoverable identity.",
|
||||||
|
"targets": ["apple"],
|
||||||
|
"translations": {
|
||||||
|
"en": "Your transfer history and received files stay on this device. Saved devices will be removed and must be paired again.",
|
||||||
|
"fr": "L’historique des transferts et les fichiers reçus restent sur cet appareil. Les appareils enregistrés seront supprimés et devront être associés à nouveau.",
|
||||||
|
"es": "El historial de transferencias y los archivos recibidos permanecerán en este dispositivo. Los dispositivos guardados se eliminarán y deberán vincularse de nuevo.",
|
||||||
|
"it": "La cronologia dei trasferimenti e i file ricevuti resteranno su questo dispositivo. I dispositivi salvati verranno rimossi e dovranno essere associati di nuovo.",
|
||||||
|
"de": "Übertragungsverlauf und empfangene Dateien bleiben auf diesem Gerät. Gespeicherte Geräte werden entfernt und müssen erneut gekoppelt werden.",
|
||||||
|
"pt": "O histórico de transferências e os ficheiros recebidos permanecem neste dispositivo. Os dispositivos guardados serão removidos e terão de ser emparelhados novamente.",
|
||||||
|
"pl": "Historia transferów i odebrane pliki pozostaną na tym urządzeniu. Zapisane urządzenia zostaną usunięte i trzeba będzie sparować je ponownie.",
|
||||||
|
"nl": "De overdrachtsgeschiedenis en ontvangen bestanden blijven op dit apparaat. Opgeslagen apparaten worden verwijderd en moeten opnieuw worden gekoppeld.",
|
||||||
|
"ru": "История передач и полученные файлы останутся на этом устройстве. Сохранённые устройства будут удалены, и их потребуется связать заново."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"app_identity_reset_title": {
|
||||||
|
"context": "Startup recovery: title when the protected endpoint identity is permanently unavailable.",
|
||||||
|
"targets": ["apple"],
|
||||||
|
"translations": {
|
||||||
|
"en": "Device identity unavailable",
|
||||||
|
"fr": "Identité de l’appareil indisponible",
|
||||||
|
"es": "La identidad del dispositivo no está disponible",
|
||||||
|
"it": "Identità del dispositivo non disponibile",
|
||||||
|
"de": "Geräteidentität nicht verfügbar",
|
||||||
|
"pt": "Identidade do dispositivo indisponível",
|
||||||
|
"pl": "Tożsamość urządzenia jest niedostępna",
|
||||||
|
"nl": "Apparaatidentiteit niet beschikbaar",
|
||||||
|
"ru": "Идентификатор устройства недоступен"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"app_identity_reset_message": {
|
||||||
|
"context": "Startup recovery: explanation when the protected endpoint identity cannot be recovered.",
|
||||||
|
"targets": ["apple"],
|
||||||
|
"translations": {
|
||||||
|
"en": "VniDrop can’t recover this device’s secure identity. Reset it to start again with a new identity.",
|
||||||
|
"fr": "VniDrop ne peut pas récupérer l’identité sécurisée de cet appareil. Réinitialisez-la pour recommencer avec une nouvelle identité.",
|
||||||
|
"es": "VniDrop no puede recuperar la identidad segura de este dispositivo. Restablézcala para empezar de nuevo con una identidad nueva.",
|
||||||
|
"it": "VniDrop non può recuperare l’identità sicura di questo dispositivo. La reimposti per ricominciare con una nuova identità.",
|
||||||
|
"de": "VniDrop kann die sichere Identität dieses Geräts nicht wiederherstellen. Setzen Sie sie zurück, um mit einer neuen Identität zu beginnen.",
|
||||||
|
"pt": "O VniDrop não consegue recuperar a identidade segura deste dispositivo. Reponha-a para começar de novo com uma nova identidade.",
|
||||||
|
"pl": "VniDrop nie może odzyskać bezpiecznej tożsamości tego urządzenia. Zresetuj ją, aby zacząć od nowej tożsamości.",
|
||||||
|
"nl": "VniDrop kan de beveiligde identiteit van dit apparaat niet herstellen. Stel deze opnieuw in om met een nieuwe identiteit te beginnen.",
|
||||||
|
"ru": "VniDrop не может восстановить защищённый идентификатор этого устройства. Сбросьте его, чтобы начать с новым идентификатором."
|
||||||
|
}
|
||||||
|
},
|
||||||
"appearance_auto_description": {
|
"appearance_auto_description": {
|
||||||
"context": "Settings > Appearance: description for the System/auto option.",
|
"context": "Settings > Appearance: description for the System/auto option.",
|
||||||
"translations": {
|
"translations": {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ class SavedDeviceCoreContractBindingHygieneTest {
|
|||||||
assertTrue(
|
assertTrue(
|
||||||
source.contains("public expect fun `savedDeviceCapabilities`(): SavedDeviceCapabilities"),
|
source.contains("public expect fun `savedDeviceCapabilities`(): SavedDeviceCapabilities"),
|
||||||
)
|
)
|
||||||
|
assertTrue(source.contains("var `role`: TargetedTransferRole"))
|
||||||
|
assertTrue(source.contains("public enum class TargetedTransferRole"))
|
||||||
assertTrue(
|
assertTrue(
|
||||||
source.contains("SavedDevice"),
|
source.contains("SavedDevice"),
|
||||||
"SavedDevice model must remain on the public surface",
|
"SavedDevice model must remain on the public surface",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import uniffi.vnidrop.SourceKind
|
|||||||
import uniffi.vnidrop.StoredTransfer
|
import uniffi.vnidrop.StoredTransfer
|
||||||
import uniffi.vnidrop.TargetedOfferResponse
|
import uniffi.vnidrop.TargetedOfferResponse
|
||||||
import uniffi.vnidrop.TargetedTransfer
|
import uniffi.vnidrop.TargetedTransfer
|
||||||
|
import uniffi.vnidrop.TargetedTransferRole
|
||||||
import uniffi.vnidrop.TargetedTransferState
|
import uniffi.vnidrop.TargetedTransferState
|
||||||
import uniffi.vnidrop.TicketInspection
|
import uniffi.vnidrop.TicketInspection
|
||||||
import uniffi.vnidrop.TransferMetadata
|
import uniffi.vnidrop.TransferMetadata
|
||||||
@@ -643,6 +644,7 @@ private fun PendingTargetedOffer.toModel(): PendingTargetedOfferModel = PendingT
|
|||||||
|
|
||||||
private fun TargetedTransfer.toModel(): TargetedTransferModel = TargetedTransferModel(
|
private fun TargetedTransfer.toModel(): TargetedTransferModel = TargetedTransferModel(
|
||||||
id = id,
|
id = id,
|
||||||
|
role = role.toModel(),
|
||||||
senderEndpointId = senderEndpointId,
|
senderEndpointId = senderEndpointId,
|
||||||
receiverEndpointId = receiverEndpointId,
|
receiverEndpointId = receiverEndpointId,
|
||||||
manifestId = manifestId,
|
manifestId = manifestId,
|
||||||
@@ -655,6 +657,11 @@ private fun TargetedTransfer.toModel(): TargetedTransferModel = TargetedTransfer
|
|||||||
updatedAt = updatedAt,
|
updatedAt = updatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun TargetedTransferRole.toModel(): TargetedTransferRoleModel = when (this) {
|
||||||
|
TargetedTransferRole.SENDER -> TargetedTransferRoleModel.Sender
|
||||||
|
TargetedTransferRole.RECEIVER -> TargetedTransferRoleModel.Receiver
|
||||||
|
}
|
||||||
|
|
||||||
private fun TargetedTransferState.toModel(): TargetedTransferStateModel = when (this) {
|
private fun TargetedTransferState.toModel(): TargetedTransferStateModel = when (this) {
|
||||||
TargetedTransferState.PREPARING -> TargetedTransferStateModel.Preparing
|
TargetedTransferState.PREPARING -> TargetedTransferStateModel.Preparing
|
||||||
TargetedTransferState.OFFERING -> TargetedTransferStateModel.Offering
|
TargetedTransferState.OFFERING -> TargetedTransferStateModel.Offering
|
||||||
|
|||||||
@@ -68,8 +68,14 @@ enum class TargetedTransferStateModel {
|
|||||||
Deleted,
|
Deleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class TargetedTransferRoleModel {
|
||||||
|
Sender,
|
||||||
|
Receiver,
|
||||||
|
}
|
||||||
|
|
||||||
data class TargetedTransferModel(
|
data class TargetedTransferModel(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
val role: TargetedTransferRoleModel,
|
||||||
val senderEndpointId: String,
|
val senderEndpointId: String,
|
||||||
val receiverEndpointId: String,
|
val receiverEndpointId: String,
|
||||||
val manifestId: String,
|
val manifestId: String,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.vnidrop.app.core.ReceiveFolder
|
|||||||
import com.vnidrop.app.core.SavedDeviceModel
|
import com.vnidrop.app.core.SavedDeviceModel
|
||||||
import com.vnidrop.app.core.TargetedOfferResponseModel
|
import com.vnidrop.app.core.TargetedOfferResponseModel
|
||||||
import com.vnidrop.app.core.TargetedTransferModel
|
import com.vnidrop.app.core.TargetedTransferModel
|
||||||
|
import com.vnidrop.app.core.TargetedTransferRoleModel
|
||||||
import com.vnidrop.app.core.TargetedTransferStateModel
|
import com.vnidrop.app.core.TargetedTransferStateModel
|
||||||
import com.vnidrop.app.preferences.PreferencesRepository
|
import com.vnidrop.app.preferences.PreferencesRepository
|
||||||
import com.vnidrop.app.ui.feedback.UiMessage
|
import com.vnidrop.app.ui.feedback.UiMessage
|
||||||
@@ -365,8 +366,7 @@ class SavedDevicesViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun TargetedTransferModel.toExperienceItem(savedNames: Map<String, String>): SavedDeviceTransferItem {
|
private fun TargetedTransferModel.toExperienceItem(savedNames: Map<String, String>): SavedDeviceTransferItem {
|
||||||
val localEndpointId = repository.state.value.status?.endpointId
|
val outgoing = role == TargetedTransferRoleModel.Sender
|
||||||
val outgoing = senderEndpointId == localEndpointId
|
|
||||||
val peerEndpointId = if (outgoing) receiverEndpointId else senderEndpointId
|
val peerEndpointId = if (outgoing) receiverEndpointId else senderEndpointId
|
||||||
return SavedDeviceTransferItem(
|
return SavedDeviceTransferItem(
|
||||||
id = id,
|
id = id,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.vnidrop.app.core.ReceiveFolderKind
|
|||||||
import com.vnidrop.app.core.SavedDeviceModel
|
import com.vnidrop.app.core.SavedDeviceModel
|
||||||
import com.vnidrop.app.core.TargetedOfferResponseModel
|
import com.vnidrop.app.core.TargetedOfferResponseModel
|
||||||
import com.vnidrop.app.core.TargetedTransferModel
|
import com.vnidrop.app.core.TargetedTransferModel
|
||||||
|
import com.vnidrop.app.core.TargetedTransferRoleModel
|
||||||
import com.vnidrop.app.core.TargetedTransferStateModel
|
import com.vnidrop.app.core.TargetedTransferStateModel
|
||||||
import com.vnidrop.app.preferences.AppPreferences
|
import com.vnidrop.app.preferences.AppPreferences
|
||||||
import com.vnidrop.app.support.FakeCoreGateway
|
import com.vnidrop.app.support.FakeCoreGateway
|
||||||
@@ -49,7 +50,15 @@ class SavedDevicesViewModelTest {
|
|||||||
deviceRelationships = listOf(incoming("incoming"))
|
deviceRelationships = listOf(incoming("incoming"))
|
||||||
savedDevices = listOf(device("peer", "Office PC", "Authenticated PC"))
|
savedDevices = listOf(device("peer", "Office PC", "Authenticated PC"))
|
||||||
pendingTargetedOffers = listOf(offer("offer", "peer"))
|
pendingTargetedOffers = listOf(offer("offer", "peer"))
|
||||||
targetedTransfers = listOf(transfer("history", "local", "peer", TargetedTransferStateModel.AwaitingApproval))
|
targetedTransfers = listOf(
|
||||||
|
transfer(
|
||||||
|
"history",
|
||||||
|
TargetedTransferRoleModel.Sender,
|
||||||
|
"local",
|
||||||
|
"peer",
|
||||||
|
TargetedTransferStateModel.AwaitingApproval,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val viewModel = createViewModel(core)
|
val viewModel = createViewModel(core)
|
||||||
runCurrent()
|
runCurrent()
|
||||||
@@ -74,6 +83,29 @@ class SavedDevicesViewModelTest {
|
|||||||
assertEquals(false, state.loadFailed)
|
assertEquals(false, state.loadFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun persistedOutgoingTransferKeepsDirectionAfterIdentityReset() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val core = initializedCore().apply {
|
||||||
|
targetedTransfers = listOf(
|
||||||
|
transfer(
|
||||||
|
id = "past-send",
|
||||||
|
role = TargetedTransferRoleModel.Sender,
|
||||||
|
sender = "retired-local-identity",
|
||||||
|
receiver = "peer",
|
||||||
|
state = TargetedTransferStateModel.Completed,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val viewModel = createViewModel(core)
|
||||||
|
runCurrent()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val transfer = viewModel.state.value.targetedTransfers.single()
|
||||||
|
assertEquals(SavedDeviceTransferDirection.Outgoing, transfer.direction)
|
||||||
|
assertEquals("peer", transfer.peerEndpointId)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun pairingPromptCommandsAndDismissalUseTheSameDurableLists() = runTest {
|
fun pairingPromptCommandsAndDismissalUseTheSameDurableLists() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
@@ -158,7 +190,15 @@ class SavedDevicesViewModelTest {
|
|||||||
fun interruptedTransferResumeUsesThePlatformSinkWhenAvailable() = runTest {
|
fun interruptedTransferResumeUsesThePlatformSinkWhenAvailable() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val core = initializedCore().apply {
|
val core = initializedCore().apply {
|
||||||
targetedTransfers = listOf(transfer("resume-sink", "peer", "local", TargetedTransferStateModel.Interrupted))
|
targetedTransfers = listOf(
|
||||||
|
transfer(
|
||||||
|
"resume-sink",
|
||||||
|
TargetedTransferRoleModel.Receiver,
|
||||||
|
"peer",
|
||||||
|
"local",
|
||||||
|
TargetedTransferStateModel.Interrupted,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val viewModel = createViewModel(
|
val viewModel = createViewModel(
|
||||||
core,
|
core,
|
||||||
@@ -180,9 +220,9 @@ class SavedDevicesViewModelTest {
|
|||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val core = initializedCore().apply {
|
val core = initializedCore().apply {
|
||||||
targetedTransfers = listOf(
|
targetedTransfers = listOf(
|
||||||
transfer("resume", "peer", "local", TargetedTransferStateModel.Interrupted),
|
transfer("resume", TargetedTransferRoleModel.Receiver, "peer", "local", TargetedTransferStateModel.Interrupted),
|
||||||
transfer("cancel", "local", "peer", TargetedTransferStateModel.AwaitingApproval),
|
transfer("cancel", TargetedTransferRoleModel.Sender, "local", "peer", TargetedTransferStateModel.AwaitingApproval),
|
||||||
transfer("delete", "peer", "local", TargetedTransferStateModel.Completed),
|
transfer("delete", TargetedTransferRoleModel.Receiver, "peer", "local", TargetedTransferStateModel.Completed),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val viewModel = createViewModel(core)
|
val viewModel = createViewModel(core)
|
||||||
@@ -336,11 +376,13 @@ class SavedDevicesViewModelTest {
|
|||||||
|
|
||||||
private fun transfer(
|
private fun transfer(
|
||||||
id: String,
|
id: String,
|
||||||
|
role: TargetedTransferRoleModel,
|
||||||
sender: String,
|
sender: String,
|
||||||
receiver: String,
|
receiver: String,
|
||||||
state: TargetedTransferStateModel,
|
state: TargetedTransferStateModel,
|
||||||
) = TargetedTransferModel(
|
) = TargetedTransferModel(
|
||||||
id = id,
|
id = id,
|
||||||
|
role = role,
|
||||||
senderEndpointId = sender,
|
senderEndpointId = sender,
|
||||||
receiverEndpointId = receiver,
|
receiverEndpointId = receiver,
|
||||||
manifestId = "manifest-$id",
|
manifestId = "manifest-$id",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.vnidrop.app.core.SavedDeviceModel
|
|||||||
import com.vnidrop.app.core.Share
|
import com.vnidrop.app.core.Share
|
||||||
import com.vnidrop.app.core.ShareAccessPolicy
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
import com.vnidrop.app.core.TargetedTransferModel
|
import com.vnidrop.app.core.TargetedTransferModel
|
||||||
|
import com.vnidrop.app.core.TargetedTransferRoleModel
|
||||||
import com.vnidrop.app.core.TargetedTransferStateModel
|
import com.vnidrop.app.core.TargetedTransferStateModel
|
||||||
import com.vnidrop.app.support.FakeCoreGateway
|
import com.vnidrop.app.support.FakeCoreGateway
|
||||||
import com.vnidrop.app.support.FakeFilePreviewRepository
|
import com.vnidrop.app.support.FakeFilePreviewRepository
|
||||||
@@ -235,6 +236,7 @@ class TransferDraftViewModelTest {
|
|||||||
|
|
||||||
private fun targeted(id: String, peerId: String) = TargetedTransferModel(
|
private fun targeted(id: String, peerId: String) = TargetedTransferModel(
|
||||||
id = id,
|
id = id,
|
||||||
|
role = TargetedTransferRoleModel.Sender,
|
||||||
senderEndpointId = "me",
|
senderEndpointId = "me",
|
||||||
receiverEndpointId = peerId,
|
receiverEndpointId = peerId,
|
||||||
manifestId = "manifest",
|
manifestId = "manifest",
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ class SavedDeviceCoreContractBindingHygieneTest {
|
|||||||
assertTrue(
|
assertTrue(
|
||||||
source.contains("public expect fun `savedDeviceCapabilities`(): SavedDeviceCapabilities"),
|
source.contains("public expect fun `savedDeviceCapabilities`(): SavedDeviceCapabilities"),
|
||||||
)
|
)
|
||||||
|
assertTrue(source.contains("var `role`: TargetedTransferRole"))
|
||||||
|
assertTrue(source.contains("public enum class TargetedTransferRole"))
|
||||||
assertTrue(
|
assertTrue(
|
||||||
source.contains("SavedDevice"),
|
source.contains("SavedDevice"),
|
||||||
"SavedDevice model must remain on the public surface",
|
"SavedDevice model must remain on the public surface",
|
||||||
|
|||||||
Reference in New Issue
Block a user