From 6edcb5fc86023bd6a1078bb531b9bce77677fcac Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 11 Aug 2026 22:13:29 +0200 Subject: [PATCH] Implement iPhone password swipe actions --- apple/Generated/ironstorage_apple.swift | 405 ++++++++++++++ apple/Generated/ironstorage_appleFFI.h | 22 + apple/Sources/App/IronStorageApp.swift | 520 +++++++++++++++++- crates/apple/src/lib.rs | 141 +++++ crates/storage/src/lib.rs | 1 + crates/storage/src/mobile_authentication.rs | 166 +++++- crates/storage/src/mobile_mutation.rs | 567 ++++++++++++++++++++ 7 files changed, 1816 insertions(+), 6 deletions(-) create mode 100644 crates/storage/src/mobile_mutation.rs diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index 5dffca2..0d1fc57 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -625,6 +625,10 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func manualLock() throws + func performEntryMutation(request: MobileMutationRequest) throws -> MobileMutationOutcome + + func prepareEntryMutation(path: String, action: MobileMutationAction) throws -> MobileMutationPlan + func removeEntryEditorField(editor: UInt64, field: UInt64) throws -> MobileEntryEditorPage func reorderEntryEditorField(editor: UInt64, field: UInt64, index: UInt32) throws -> MobileEntryEditorPage @@ -821,6 +825,27 @@ open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuth } } +open func performEntryMutation(request: MobileMutationRequest)throws -> MobileMutationOutcome { + return try FfiConverterTypeMobileMutationOutcome_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_perform_entry_mutation( + self.uniffiCloneHandle(), + FfiConverterTypeMobileMutationRequest_lower(request),uniffiCallStatus + ) +}) +} + +open func prepareEntryMutation(path: String, action: MobileMutationAction)throws -> MobileMutationPlan { + return try FfiConverterTypeMobileMutationPlan_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_entry_mutation( + self.uniffiCloneHandle(), + FfiConverterString.lower(path), + FfiConverterTypeMobileMutationAction_lower(action),uniffiCallStatus + ) +}) +} + open func removeEntryEditorField(editor: UInt64, field: UInt64)throws -> MobileEntryEditorPage { return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -2908,6 +2933,282 @@ public func FfiConverterTypeMobileKeyTransferProgress_lower(_ value: MobileKeyTr } +public struct MobileMutationDestination: Equatable, Hashable { + public var path: String + public var title: String + public var detail: String + public var requiresOverwrite: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(path: String, title: String, detail: String, requiresOverwrite: Bool) { + self.path = path + self.title = title + self.detail = detail + self.requiresOverwrite = requiresOverwrite + } + + + + +} + +#if compiler(>=6) +extension MobileMutationDestination: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileMutationDestination: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileMutationDestination { + return + try MobileMutationDestination( + path: FfiConverterString.read(from: &buf), + title: FfiConverterString.read(from: &buf), + detail: FfiConverterString.read(from: &buf), + requiresOverwrite: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: MobileMutationDestination, into buf: inout [UInt8]) { + FfiConverterString.write(value.path, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.detail, into: &buf) + FfiConverterBool.write(value.requiresOverwrite, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileMutationDestination_lift(_ buf: RustBuffer) throws -> MobileMutationDestination { + return try FfiConverterTypeMobileMutationDestination.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileMutationDestination_lower(_ value: MobileMutationDestination) -> RustBuffer { + return FfiConverterTypeMobileMutationDestination.lower(value) +} + + +public struct MobileMutationOutcome: Equatable, Hashable { + public var action: MobileMutationAction + public var source: String + public var destination: String? + public var title: String + public var detail: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(action: MobileMutationAction, source: String, destination: String?, title: String, detail: String) { + self.action = action + self.source = source + self.destination = destination + self.title = title + self.detail = detail + } + + + + +} + +#if compiler(>=6) +extension MobileMutationOutcome: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileMutationOutcome: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileMutationOutcome { + return + try MobileMutationOutcome( + action: FfiConverterTypeMobileMutationAction.read(from: &buf), + source: FfiConverterString.read(from: &buf), + destination: FfiConverterOptionString.read(from: &buf), + title: FfiConverterString.read(from: &buf), + detail: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: MobileMutationOutcome, into buf: inout [UInt8]) { + FfiConverterTypeMobileMutationAction.write(value.action, into: &buf) + FfiConverterString.write(value.source, into: &buf) + FfiConverterOptionString.write(value.destination, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.detail, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileMutationOutcome_lift(_ buf: RustBuffer) throws -> MobileMutationOutcome { + return try FfiConverterTypeMobileMutationOutcome.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileMutationOutcome_lower(_ value: MobileMutationOutcome) -> RustBuffer { + return FfiConverterTypeMobileMutationOutcome.lower(value) +} + + +public struct MobileMutationPlan: Equatable, Hashable { + public var action: MobileMutationAction + public var source: String + public var sourceTitle: String + public var revision: String + public var destinations: [MobileMutationDestination] + public var hasOpenEditor: Bool + public var hasDirtyEditor: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(action: MobileMutationAction, source: String, sourceTitle: String, revision: String, destinations: [MobileMutationDestination], hasOpenEditor: Bool, hasDirtyEditor: Bool) { + self.action = action + self.source = source + self.sourceTitle = sourceTitle + self.revision = revision + self.destinations = destinations + self.hasOpenEditor = hasOpenEditor + self.hasDirtyEditor = hasDirtyEditor + } + + + + +} + +#if compiler(>=6) +extension MobileMutationPlan: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileMutationPlan: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileMutationPlan { + return + try MobileMutationPlan( + action: FfiConverterTypeMobileMutationAction.read(from: &buf), + source: FfiConverterString.read(from: &buf), + sourceTitle: FfiConverterString.read(from: &buf), + revision: FfiConverterString.read(from: &buf), + destinations: FfiConverterSequenceTypeMobileMutationDestination.read(from: &buf), + hasOpenEditor: FfiConverterBool.read(from: &buf), + hasDirtyEditor: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: MobileMutationPlan, into buf: inout [UInt8]) { + FfiConverterTypeMobileMutationAction.write(value.action, into: &buf) + FfiConverterString.write(value.source, into: &buf) + FfiConverterString.write(value.sourceTitle, into: &buf) + FfiConverterString.write(value.revision, into: &buf) + FfiConverterSequenceTypeMobileMutationDestination.write(value.destinations, into: &buf) + FfiConverterBool.write(value.hasOpenEditor, into: &buf) + FfiConverterBool.write(value.hasDirtyEditor, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileMutationPlan_lift(_ buf: RustBuffer) throws -> MobileMutationPlan { + return try FfiConverterTypeMobileMutationPlan.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileMutationPlan_lower(_ value: MobileMutationPlan) -> RustBuffer { + return FfiConverterTypeMobileMutationPlan.lower(value) +} + + +public struct MobileMutationRequest: Equatable, Hashable { + public var action: MobileMutationAction + public var source: String + public var revision: String + public var destination: String? + public var confirmed: Bool + public var overwrite: Bool + public var discardEditor: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(action: MobileMutationAction, source: String, revision: String, destination: String?, confirmed: Bool, overwrite: Bool, discardEditor: Bool) { + self.action = action + self.source = source + self.revision = revision + self.destination = destination + self.confirmed = confirmed + self.overwrite = overwrite + self.discardEditor = discardEditor + } + + + + +} + +#if compiler(>=6) +extension MobileMutationRequest: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileMutationRequest: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileMutationRequest { + return + try MobileMutationRequest( + action: FfiConverterTypeMobileMutationAction.read(from: &buf), + source: FfiConverterString.read(from: &buf), + revision: FfiConverterString.read(from: &buf), + destination: FfiConverterOptionString.read(from: &buf), + confirmed: FfiConverterBool.read(from: &buf), + overwrite: FfiConverterBool.read(from: &buf), + discardEditor: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: MobileMutationRequest, into buf: inout [UInt8]) { + FfiConverterTypeMobileMutationAction.write(value.action, into: &buf) + FfiConverterString.write(value.source, into: &buf) + FfiConverterString.write(value.revision, into: &buf) + FfiConverterOptionString.write(value.destination, into: &buf) + FfiConverterBool.write(value.confirmed, into: &buf) + FfiConverterBool.write(value.overwrite, into: &buf) + FfiConverterBool.write(value.discardEditor, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileMutationRequest_lift(_ buf: RustBuffer) throws -> MobileMutationRequest { + return try FfiConverterTypeMobileMutationRequest.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileMutationRequest_lower(_ value: MobileMutationRequest) -> RustBuffer { + return FfiConverterTypeMobileMutationRequest.lower(value) +} + + public struct MobileOnboardingDiscovery: Equatable, Hashable { public var branches: [String] public var selectedBranch: UInt32 @@ -4611,6 +4912,79 @@ public func FfiConverterTypeMobileKeyTransferKind_lower(_ value: MobileKeyTransf +public enum MobileMutationAction: Equatable, Hashable { + + case move + case copy + case delete + + + + + +} + +#if compiler(>=6) +extension MobileMutationAction: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileMutationAction: FfiConverterRustBuffer { + typealias SwiftType = MobileMutationAction + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileMutationAction { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .move + + case 2: return .copy + + case 3: return .delete + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileMutationAction, into buf: inout [UInt8]) { + switch value { + + + case .move: + writeInt(&buf, Int32(1)) + + + case .copy: + writeInt(&buf, Int32(2)) + + + case .delete: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileMutationAction_lift(_ buf: RustBuffer) throws -> MobileMutationAction { + return try FfiConverterTypeMobileMutationAction.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileMutationAction_lower(_ value: MobileMutationAction) -> RustBuffer { + return FfiConverterTypeMobileMutationAction.lower(value) +} + + + + public enum MobileOnboardingErrorKind: Equatable, Hashable { case invalidInput @@ -5807,6 +6181,31 @@ fileprivate struct FfiConverterSequenceTypeMobileKeyTransferKey: FfiConverterRus } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeMobileMutationDestination: FfiConverterRustBuffer { + typealias SwiftType = [MobileMutationDestination] + + public static func write(_ value: [MobileMutationDestination], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeMobileMutationDestination.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileMutationDestination] { + let len: Int32 = try readInt(&buf) + var seq = [MobileMutationDestination]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeMobileMutationDestination.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -6048,6 +6447,12 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation() != 7053) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation() != 6234) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_entry_editor_field() != 12238) { return InitializationResult.apiChecksumMismatch } diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index 6b35e2c..c5e2035 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -308,6 +308,16 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_generate_entr void uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_perform_entry_mutation(uint64_t ptr, RustBuffer request, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_entry_mutation(uint64_t ptr, RustBuffer path, RustBuffer action, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_entry_editor_field(uint64_t ptr, uint64_t editor, uint64_t field, RustCallStatus *_Nonnull out_status @@ -943,6 +953,18 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_generate_ #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index ae41e32..c052b94 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -2309,6 +2309,483 @@ private extension UInt64 { } } +@MainActor +private final class PasswordMutationCoordinator { + private weak var presenter: UIViewController? + private let authentication: MobileAuthentication? + private var operationTask: Task? + private var working = false + private var contextualCompletion: ((Bool) -> Void)? + + init(presenter: UIViewController, authentication: MobileAuthentication?) { + self.presenter = presenter + self.authentication = authentication + } + + deinit { + operationTask?.cancel() + } + + func accessoryView(for row: MobilePasswordRow) -> UIView { + let button = UIButton(type: .system) + button.frame = CGRect(x: 0, y: 0, width: 44, height: 44) + button.configuration = .plain() + button.configuration?.image = UIImage(systemName: "ellipsis.circle") + button.configuration?.buttonSize = .medium + button.menu = menu(for: row) + button.showsMenuAsPrimaryAction = true + button.accessibilityLabel = "Actions for \(row.title)" + return button + } + + func accessibilityActions(for row: MobilePasswordRow) -> [UIAccessibilityCustomAction] { + [ + accessibilityAction("Move", image: "folder", action: .move, row: row), + accessibilityAction("Copy", image: "doc.on.doc", action: .copy, row: row), + accessibilityAction("Delete", image: "trash", action: .delete, row: row), + ] + } + + func swipeConfiguration(for row: MobilePasswordRow) -> UISwipeActionsConfiguration { + let delete = UIContextualAction(style: .destructive, title: "Delete") { + [weak self] _, _, completion in + self?.begin(.delete, row: row, completion: completion) + } + delete.image = UIImage(systemName: "trash") + + let copy = UIContextualAction(style: .normal, title: "Copy") { + [weak self] _, _, completion in + self?.begin(.copy, row: row, completion: completion) + } + copy.image = UIImage(systemName: "doc.on.doc") + copy.backgroundColor = .systemBlue + + let move = UIContextualAction(style: .normal, title: "Move") { + [weak self] _, _, completion in + self?.begin(.move, row: row, completion: completion) + } + move.image = UIImage(systemName: "folder") + move.backgroundColor = .systemOrange + + let configuration = UISwipeActionsConfiguration(actions: [delete, copy, move]) + configuration.performsFirstActionWithFullSwipe = true + return configuration + } + + private func menu(for row: MobilePasswordRow) -> UIMenu { + UIMenu(children: [ + UIAction(title: "Move", image: UIImage(systemName: "folder")) { [weak self] _ in + self?.begin(.move, row: row) + }, + UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in + self?.begin(.copy, row: row) + }, + UIAction( + title: "Delete", + image: UIImage(systemName: "trash"), + attributes: .destructive + ) { [weak self] _ in + self?.begin(.delete, row: row) + }, + ]) + } + + private func accessibilityAction( + _ title: String, + image: String, + action: MobileMutationAction, + row: MobilePasswordRow + ) -> UIAccessibilityCustomAction { + UIAccessibilityCustomAction( + name: title, + image: UIImage(systemName: image) + ) { [weak self] _ in + self?.begin(action, row: row) + return true + } + } + + private func begin( + _ action: MobileMutationAction, + row: MobilePasswordRow, + completion: ((Bool) -> Void)? = nil + ) { + guard !working else { + completion?(false) + return + } + guard let authentication else { + completion?(false) + presenter?.presentAuthenticationFailure(.unavailable) + return + } + working = true + contextualCompletion = completion + operationTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try authentication.prepareEntryMutation(path: row.path, action: action) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + operationTask = nil + switch result { + case let .success(plan): + present(plan) + case let .failure(failure): + fail(failure) + } + } + } + + private func present(_ plan: MobileMutationPlan) { + switch plan.action { + case .delete: + confirmDelete(plan) + case .move, .copy: + guard !plan.destinations.isEmpty else { + fail(AuthenticationFailure( + kind: .entry, + title: "No Destination Available", + detail: "Create another password folder before moving or copying this entry." + )) + return + } + let destinations = PasswordDestinationViewController( + plan: plan, + selected: { [weak self] destination in + self?.presenter?.dismiss(animated: true) { + self?.destinationSelected(destination, plan: plan) + } + }, + cancelled: { [weak self] in + self?.presenter?.dismiss(animated: true) { self?.finish(false) } + } + ) + let navigation = UINavigationController(rootViewController: destinations) + navigation.modalPresentationStyle = .formSheet + presenter?.present(navigation, animated: true) + } + } + + private func destinationSelected( + _ destination: MobileMutationDestination, + plan: MobileMutationPlan + ) { + if destination.requiresOverwrite { + let verb = plan.action == .move ? "Move" : "Copy" + let alert = UIAlertController( + title: "Replace Existing Password?", + message: "\(destination.title) already contains “\(plan.sourceTitle)”. \(verb) and replace it?", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { + [weak self] _ in self?.finish(false) + }) + alert.addAction(UIAlertAction(title: "Replace", style: .destructive) { + [weak self] _ in + self?.confirmEditorIfNeeded(plan, destination: destination.path, overwrite: true) + }) + presenter?.present(alert, animated: true) + } else { + confirmEditorIfNeeded(plan, destination: destination.path, overwrite: false) + } + } + + private func confirmDelete(_ plan: MobileMutationPlan) { + let suffix = plan.hasDirtyEditor + ? " Unsaved changes in its open editor will also be discarded." + : " This can’t be undone." + let alert = UIAlertController( + title: "Delete “\(plan.sourceTitle)”?", + message: "The encrypted password entry will be permanently deleted.\(suffix)", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { + [weak self] _ in self?.finish(false) + }) + alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { + [weak self] _ in + self?.perform( + plan, + destination: nil, + overwrite: false, + discardEditor: plan.hasOpenEditor, + mayAuthenticate: true + ) + }) + presenter?.present(alert, animated: true) + } + + private func confirmEditorIfNeeded( + _ plan: MobileMutationPlan, + destination: String, + overwrite: Bool + ) { + guard plan.hasOpenEditor else { + perform( + plan, + destination: destination, + overwrite: overwrite, + discardEditor: false, + mayAuthenticate: true + ) + return + } + let verb = plan.action == .move ? "Move" : "Copy" + let alert = UIAlertController( + title: plan.hasDirtyEditor ? "Discard Changes and \(verb)?" : "Close Editor and \(verb)?", + message: plan.hasDirtyEditor + ? "Unsaved changes in the open editor for “\(plan.sourceTitle)” will be discarded." + : "The open editor for “\(plan.sourceTitle)” must close before this action.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { + [weak self] _ in self?.finish(false) + }) + alert.addAction(UIAlertAction( + title: plan.hasDirtyEditor ? "Discard and \(verb)" : "Close and \(verb)", + style: plan.hasDirtyEditor ? .destructive : .default + ) { [weak self] _ in + self?.perform( + plan, + destination: destination, + overwrite: overwrite, + discardEditor: true, + mayAuthenticate: true + ) + }) + presenter?.present(alert, animated: true) + } + + private func perform( + _ plan: MobileMutationPlan, + destination: String?, + overwrite: Bool, + discardEditor: Bool, + mayAuthenticate: Bool + ) { + guard let authentication else { + fail(.unavailable) + return + } + let request = MobileMutationRequest( + action: plan.action, + source: plan.source, + revision: plan.revision, + destination: destination, + confirmed: plan.action == .delete, + overwrite: overwrite, + discardEditor: discardEditor + ) + operationTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try authentication.performEntryMutation(request: request) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + operationTask = nil + switch result { + case let .success(outcome): + UINotificationFeedbackGenerator().notificationOccurred(.success) + UIAccessibility.post(notification: .announcement, argument: outcome.detail) + NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil) + finish(true) + case let .failure(failure) + where mayAuthenticate && failure.kind == .expired: + unlockAndRetry( + plan, + destination: destination, + overwrite: overwrite, + discardEditor: discardEditor, + passphrase: nil + ) + case let .failure(failure): + fail(failure) + } + } + } + + private func unlockAndRetry( + _ plan: MobileMutationPlan, + destination: String?, + overwrite: Bool, + discardEditor: Bool, + passphrase: String? + ) { + guard let authentication else { + fail(.unavailable) + return + } + operationTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + _ = try authentication.unlockEntry(path: plan.source, passphrase: passphrase) + return Result.success(()) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + operationTask = nil + switch result { + case .success: + perform( + plan, + destination: destination, + overwrite: overwrite, + discardEditor: discardEditor, + mayAuthenticate: false + ) + case let .failure(failure) + where passphrase == nil + && (failure.kind == .passphraseRequired + || failure.kind == .biometryUnavailable): + promptForPassphrase( + plan, + destination: destination, + overwrite: overwrite, + discardEditor: discardEditor, + message: failure.detail + ) + case let .failure(failure): + fail(failure) + } + } + } + + private func promptForPassphrase( + _ plan: MobileMutationPlan, + destination: String?, + overwrite: Bool, + discardEditor: Bool, + message: String + ) { + let alert = UIAlertController( + title: "GPG Key Passphrase", + message: message, + preferredStyle: .alert + ) + alert.addTextField { field in + field.isSecureTextEntry = true + field.textContentType = .password + field.placeholder = "Passphrase" + field.returnKeyType = .go + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { + [weak self] _ in self?.finish(false) + }) + alert.addAction(UIAlertAction(title: "Unlock", style: .default) { + [weak self, weak alert] _ in + guard let value = alert?.textFields?.first?.text, !value.isEmpty else { + self?.finish(false) + return + } + alert?.textFields?.first?.text = nil + self?.unlockAndRetry( + plan, + destination: destination, + overwrite: overwrite, + discardEditor: discardEditor, + passphrase: value + ) + }) + presenter?.present(alert, animated: true) + } + + private func fail(_ failure: AuthenticationFailure) { + presenter?.presentAuthenticationFailure(failure) + finish(false) + } + + private func finish(_ applied: Bool) { + contextualCompletion?(applied) + contextualCompletion = nil + working = false + } +} + +@MainActor +private final class PasswordDestinationViewController: UITableViewController { + private let plan: MobileMutationPlan + private let selected: (MobileMutationDestination) -> Void + private let cancelled: () -> Void + + init( + plan: MobileMutationPlan, + selected: @escaping (MobileMutationDestination) -> Void, + cancelled: @escaping () -> Void + ) { + self.plan = plan + self.selected = selected + self.cancelled = cancelled + super.init(style: .insetGrouped) + title = plan.action == .move ? "Move to Folder" : "Copy to Folder" + navigationItem.largeTitleDisplayMode = .never + navigationItem.leftBarButtonItem = UIBarButtonItem( + systemItem: .cancel, + primaryAction: UIAction { [weak self] _ in self?.cancelled() } + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + plan.destinations.count + } + + override func tableView( + _ tableView: UITableView, + titleForHeaderInSection section: Int + ) -> String? { + plan.sourceTitle + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let destination = plan.destinations[indexPath.row] + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + content.image = UIImage(systemName: "folder") + content.text = destination.title + content.secondaryText = destination.requiresOverwrite + ? "\(destination.detail) · Replaces existing password" + : destination.detail + content.secondaryTextProperties.numberOfLines = 2 + cell.contentConfiguration = content + cell.accessoryType = .disclosureIndicator + cell.accessibilityHint = destination.requiresOverwrite + ? "Requires confirmation before replacing the existing password." + : "Selects this destination folder." + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + selected(plan.destinations[indexPath.row]) + } +} + @MainActor private final class PasswordSearchViewController: UITableViewController, MobileTabRoot, UISearchResultsUpdating @@ -2321,6 +2798,10 @@ private final class PasswordSearchViewController: UITableViewController, MobileT private var searchTask: Task? private var shellTask: Task? private var generation = 0 + private lazy var mutationCoordinator = PasswordMutationCoordinator( + presenter: self, + authentication: authentication + ) init(shellPage: MobilePage, authentication: MobileAuthentication?) { self.shellPage = shellPage @@ -2395,12 +2876,22 @@ private final class PasswordSearchViewController: UITableViewController, MobileT content.textProperties.numberOfLines = 2 content.secondaryTextProperties.numberOfLines = 2 cell.contentConfiguration = content - cell.accessoryType = .disclosureIndicator + cell.accessoryView = mutationCoordinator.accessoryView(for: row) + cell.accessibilityCustomActions = mutationCoordinator.accessibilityActions(for: row) cell.accessibilityLabel = "\(row.title), in \(row.detail)" - cell.accessibilityHint = "Opens the locked password viewer." + cell.accessibilityHint = "Opens the locked password viewer. More actions follow." return cell } + override func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + guard let rows = searchPage?.rows, rows.indices.contains(indexPath.row) else { return nil } + let row = rows[indexPath.row] + return mutationCoordinator.swipeConfiguration(for: row) + } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard let row = searchPage?.rows[indexPath.row] else { return } @@ -2534,6 +3025,10 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi private var loadTask: Task? private var createTask: Task? private var loadGeneration = 0 + private lazy var mutationCoordinator = PasswordMutationCoordinator( + presenter: self, + authentication: authentication + ) init( shellPage: MobilePage, @@ -2626,14 +3121,29 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi content.textProperties.numberOfLines = 2 content.secondaryTextProperties.numberOfLines = 1 cell.contentConfiguration = content - cell.accessoryType = .disclosureIndicator + if row.kind == .entry { + cell.accessoryView = mutationCoordinator.accessoryView(for: row) + cell.accessibilityCustomActions = mutationCoordinator.accessibilityActions(for: row) + } else { + cell.accessoryType = .disclosureIndicator + } cell.accessibilityLabel = "\(row.title), \(row.detail)" cell.accessibilityHint = row.kind == .directory ? "Opens this password folder." - : "Opens the locked password viewer." + : "Opens the locked password viewer. More actions follow." return cell } + override func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + guard let rows = directoryPage?.rows, rows.indices.contains(indexPath.row) else { return nil } + let row = rows[indexPath.row] + guard row.kind == .entry else { return nil } + return mutationCoordinator.swipeConfiguration(for: row) + } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let row = directoryPage?.rows[indexPath.row] else { return } let controller: UIViewController = switch row.kind { @@ -4125,7 +4635,7 @@ private struct AuthenticationFailure: Error, Sendable { detail: "Finish password-store setup before unlocking entries." ) - private init(kind: MobileAuthenticationErrorKind, title: String, detail: String) { + fileprivate init(kind: MobileAuthenticationErrorKind, title: String, detail: String) { self.kind = kind self.title = title self.detail = detail diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index d25c607..cb61b69 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -37,6 +37,11 @@ use ironstorage::{ MobileKeyTransferKind as StorageKeyTransferKind, MobileKeyTransferProgress as StorageKeyTransferProgress, }, + mobile_mutation::{ + MobileMutationAction as StorageMutationAction, + MobileMutationOutcome as StorageMutationOutcome, MobileMutationPlan as StorageMutationPlan, + MobileMutationRequest as StorageMutationRequest, + }, mobile_onboarding::{ self, MobileOnboardingError as StorageOnboardingError, MobileOnboardingErrorKind as StorageOnboardingErrorKind, @@ -560,6 +565,121 @@ pub struct MobileAuthenticationState { pub remaining_seconds: u64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileMutationAction { + Move, + Copy, + Delete, +} + +impl From for MobileMutationAction { + fn from(action: StorageMutationAction) -> Self { + match action { + StorageMutationAction::Move => Self::Move, + StorageMutationAction::Copy => Self::Copy, + StorageMutationAction::Delete => Self::Delete, + } + } +} + +impl From for StorageMutationAction { + fn from(action: MobileMutationAction) -> Self { + match action { + MobileMutationAction::Move => Self::Move, + MobileMutationAction::Copy => Self::Copy, + MobileMutationAction::Delete => Self::Delete, + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileMutationDestination { + pub path: String, + pub title: String, + pub detail: String, + pub requires_overwrite: bool, +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileMutationPlan { + pub action: MobileMutationAction, + pub source: String, + pub source_title: String, + pub revision: String, + pub destinations: Vec, + pub has_open_editor: bool, + pub has_dirty_editor: bool, +} + +impl From for MobileMutationPlan { + fn from(plan: StorageMutationPlan) -> Self { + Self { + action: plan.action().into(), + source: plan.source().to_owned(), + source_title: plan.source_title().to_owned(), + revision: plan.revision().to_owned(), + destinations: plan + .destinations() + .iter() + .map(|destination| MobileMutationDestination { + path: destination.path().to_owned(), + title: destination.title().to_owned(), + detail: destination.detail().to_owned(), + requires_overwrite: destination.requires_overwrite(), + }) + .collect(), + has_open_editor: plan.has_open_editor(), + has_dirty_editor: plan.has_dirty_editor(), + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileMutationRequest { + pub action: MobileMutationAction, + pub source: String, + pub revision: String, + pub destination: Option, + pub confirmed: bool, + pub overwrite: bool, + pub discard_editor: bool, +} + +impl From for StorageMutationRequest { + fn from(request: MobileMutationRequest) -> Self { + Self { + action: request.action.into(), + source: request.source, + revision: request.revision, + destination: request.destination, + confirmed: request.confirmed, + overwrite: request.overwrite, + discard_editor: request.discard_editor, + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileMutationOutcome { + pub action: MobileMutationAction, + pub source: String, + pub destination: Option, + pub title: String, + pub detail: String, +} + +impl From for MobileMutationOutcome { + fn from(outcome: StorageMutationOutcome) -> Self { + Self { + action: outcome.action().into(), + source: outcome.source().to_owned(), + destination: outcome.destination().map(str::to_owned), + title: outcome.title().to_owned(), + detail: outcome.detail().to_owned(), + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] pub enum MobileEntrySectionKind { Password, @@ -1193,6 +1313,27 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn prepare_entry_mutation( + &self, + path: String, + action: MobileMutationAction, + ) -> Result { + self.authentication + .prepare_entry_mutation(&path, action.into()) + .map(Into::into) + .map_err(Into::into) + } + + pub fn perform_entry_mutation( + &self, + request: MobileMutationRequest, + ) -> Result { + self.authentication + .perform_entry_mutation(request.into()) + .map(Into::into) + .map_err(Into::into) + } + pub fn replace_entry_field( &self, path: String, diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 500e000..c9eb45c 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -19,6 +19,7 @@ pub mod mobile_authentication; pub mod mobile_entry; pub mod mobile_home; pub mod mobile_key_transfer; +pub mod mobile_mutation; pub mod mobile_onboarding; pub mod mobile_passwords; pub mod mobile_totp; diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index bb9b6cf..bb5dd33 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -9,12 +9,16 @@ use crate::{ config::{Config, ConfigError}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId}, - git::{AutomaticEntryCommitter, GitIdentity}, + git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity}, mobile_entry::{ MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind, MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage, MobileEntryValueError, field_value, }, + mobile_mutation::{ + MobileMutationAction, MobileMutationError, MobileMutationOutcome, MobileMutationPlan, + MobileMutationRequest, MobileMutationService, + }, mobile_totp::{MobileTotpDetail, MobileTotpError, MobileTotpPage, MobileTotpService}, recipient::RecipientPolicyManager, repository::{ @@ -162,6 +166,7 @@ struct MobileAuthenticationStatus { active: Option, next_editor_id: u64, editors: BTreeMap, + mutation_active: bool, watch_shared_totp_entries: std::collections::BTreeSet, } @@ -196,6 +201,7 @@ impl MobileAuthentication { active: None, next_editor_id: 0, editors: BTreeMap::new(), + mutation_active: false, watch_shared_totp_entries: config.watch_shared_totp_entries().clone(), }), config, @@ -399,6 +405,66 @@ impl MobileAuthentication { }) } + pub fn prepare_entry_mutation( + &self, + path: &str, + action: MobileMutationAction, + ) -> Result { + let (has_open_editor, has_dirty_editor) = self.editor_state(path)?; + MobileMutationService::new(&self.repository) + .prepare(path, action, has_open_editor, has_dirty_editor) + .map_err(mutation_error) + } + + pub fn perform_entry_mutation( + &self, + request: MobileMutationRequest, + ) -> Result { + self.ensure_active()?; + self.reserve_entry_mutation()?; + let result = self.perform_reserved_entry_mutation(request); + self.release_entry_mutation(); + result + } + + fn perform_reserved_entry_mutation( + &self, + request: MobileMutationRequest, + ) -> Result { + let (handle, key) = { + let status = self.status()?; + let active = status.active.as_ref().ok_or_else(locked_error)?; + (active.handle.clone(), active.key.clone()) + }; + let mut committer = AutomaticTreeCommitter::for_source( + &self.repository, + &request.source, + GitIdentity::ironstorage(), + ) + .map_err(git_mutation_error)?; + let has_open_editor = self.editor_state(&request.source)?.0; + let editors = if has_open_editor && request.discard_editor { + self.take_entry_editors(&request.source)? + } else { + Vec::new() + }; + let mut provider = KeyOnlyProvider::new(handle, &key); + let result = MobileMutationService::new(&self.repository).perform( + &request, + has_open_editor && editors.is_empty(), + &self.keys, + &mut provider, + &mut committer, + ); + match result { + Ok(outcome) => Ok(outcome), + Err(error) => { + self.restore_entry_editors(editors)?; + Err(mutation_error(error)) + } + } + } + pub fn unlock_totp( &self, passphrase: Option, @@ -766,6 +832,12 @@ impl MobileAuthentication { draft: MobileEntryDraft, ) -> Result { let mut status = self.status()?; + if status.mutation_active { + return Err(entry_detail( + "Password Action In Progress", + "wait for the current move, copy, or delete action to finish", + )); + } let id = status.next_editor_id; status.next_editor_id = status.next_editor_id.checked_add(1).ok_or_else(|| { entry_detail( @@ -778,6 +850,66 @@ impl MobileAuthentication { Ok(MobileEntryEditorSession::new(id, page)) } + fn reserve_entry_mutation(&self) -> Result<(), MobileAuthenticationError> { + let mut status = self.status()?; + // ponytail: serialize mobile mutations; use per-path reservations if concurrent UI needs it. + if status.mutation_active { + return Err(MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Conflict, + "Password Action In Progress", + "Wait for the current move, copy, or delete action to finish.", + )); + } + status.mutation_active = true; + Ok(()) + } + + fn release_entry_mutation(&self) { + if let Ok(mut status) = self.status.lock() { + status.mutation_active = false; + } + } + + fn editor_state(&self, path: &str) -> Result<(bool, bool), MobileAuthenticationError> { + let status = self.status()?; + let mut matching = status + .editors + .values() + .filter(|draft| draft.document().path().to_string() == path); + let Some(first) = matching.next() else { + return Ok((false, false)); + }; + Ok(( + true, + first.document().is_modified() || matching.any(|draft| draft.document().is_modified()), + )) + } + + fn take_entry_editors( + &self, + path: &str, + ) -> Result, MobileAuthenticationError> { + let mut status = self.status()?; + let ids = status + .editors + .iter() + .filter(|(_, draft)| draft.document().path().to_string() == path) + .map(|(id, _)| *id) + .collect::>(); + Ok(ids + .into_iter() + .filter_map(|id| status.editors.remove(&id).map(|draft| (id, draft))) + .collect()) + } + + fn restore_entry_editors( + &self, + editors: Vec<(u64, MobileEntryDraft)>, + ) -> Result<(), MobileAuthenticationError> { + self.status()?.editors.extend(editors); + Ok(()) + } + fn restore_editor( &self, editor: u64, @@ -870,6 +1002,38 @@ fn document_error(error: DocumentError) -> MobileAuthenticationError { MobileAuthenticationError::new(kind, "Password Entry Could Not Be Saved", error.to_string()) } +fn mutation_error(error: MobileMutationError) -> MobileAuthenticationError { + MobileAuthenticationError::new( + if error.is_conflict() { + MobileAuthenticationErrorKind::Conflict + } else { + MobileAuthenticationErrorKind::Entry + }, + error.title(), + error.to_string(), + ) +} + +fn git_mutation_error(error: GitError) -> MobileAuthenticationError { + let conflict = matches!( + &error, + GitError::DirtyWorktree | GitError::MergeConflicts { .. } + ); + MobileAuthenticationError::new( + if conflict { + MobileAuthenticationErrorKind::Conflict + } else { + MobileAuthenticationErrorKind::Entry + }, + if conflict { + "Password Action Blocked by Git" + } else { + "Password Action Failed" + }, + error.to_string(), + ) +} + fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError { entry_detail("Field Value Is Unavailable", error) } diff --git a/crates/storage/src/mobile_mutation.rs b/crates/storage/src/mobile_mutation.rs new file mode 100644 index 0000000..39ad591 --- /dev/null +++ b/crates/storage/src/mobile_mutation.rs @@ -0,0 +1,567 @@ +//! Storage-owned preparation and execution of native mobile entry mutations. + +use std::{error::Error, fmt}; + +use data_encoding::HEXLOWER; +use sha2::{Digest as _, Sha256}; + +use crate::{ + command::{CopyRequest, MoveRequest, RemoveRequest}, + crypto::{KeyStore, SecretProvider}, + mutation::{MutationError, TreeCommitter, TreeMutator}, + read::hidden_path, + repository::{DirectoryPath, EntryPath, Repository, RepositoryError}, + write::OverwriteDecision, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileMutationAction { + Move, + Copy, + Delete, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileMutationDestination { + path: String, + title: String, + detail: String, + requires_overwrite: bool, +} + +impl MobileMutationDestination { + pub fn path(&self) -> &str { + &self.path + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn detail(&self) -> &str { + &self.detail + } + + pub fn requires_overwrite(&self) -> bool { + self.requires_overwrite + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileMutationPlan { + action: MobileMutationAction, + source: String, + source_title: String, + revision: String, + destinations: Vec, + has_open_editor: bool, + has_dirty_editor: bool, +} + +impl MobileMutationPlan { + pub fn action(&self) -> MobileMutationAction { + self.action + } + + pub fn source(&self) -> &str { + &self.source + } + + pub fn source_title(&self) -> &str { + &self.source_title + } + + pub fn revision(&self) -> &str { + &self.revision + } + + pub fn destinations(&self) -> &[MobileMutationDestination] { + &self.destinations + } + + pub fn has_open_editor(&self) -> bool { + self.has_open_editor + } + + pub fn has_dirty_editor(&self) -> bool { + self.has_dirty_editor + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileMutationRequest { + pub action: MobileMutationAction, + pub source: String, + pub revision: String, + pub destination: Option, + pub confirmed: bool, + pub overwrite: bool, + pub discard_editor: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileMutationOutcome { + action: MobileMutationAction, + source: String, + destination: Option, + title: String, + detail: String, +} + +impl MobileMutationOutcome { + pub fn action(&self) -> MobileMutationAction { + self.action + } + + pub fn source(&self) -> &str { + &self.source + } + + pub fn destination(&self) -> Option<&str> { + self.destination.as_deref() + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn detail(&self) -> &str { + &self.detail + } +} + +pub struct MobileMutationService<'a> { + repository: &'a Repository, +} + +impl<'a> MobileMutationService<'a> { + pub fn new(repository: &'a Repository) -> Self { + Self { repository } + } + + pub fn prepare( + &self, + source: &str, + action: MobileMutationAction, + has_open_editor: bool, + has_dirty_editor: bool, + ) -> Result { + let source = EntryPath::parse(source)?; + if hidden_path(source.as_path()) { + return Err(MobileMutationError::InvalidSource { + path: source.to_string(), + }); + } + let ciphertext = self.repository.read_entry(&source)?; + let snapshot = self.repository.snapshot()?; + let source_title = source + .as_path() + .file_name() + .and_then(|name| name.to_str()) + .expect("a string entry path has a UTF-8 file name") + .to_owned(); + let destinations = if action == MobileMutationAction::Delete { + Vec::new() + } else { + snapshot + .directories() + .filter(|directory| !hidden_path(directory.path().as_path())) + .filter_map(|directory| { + let destination = EntryPath::parse( + directory + .path() + .as_path() + .join(source.as_path().file_name()?), + ) + .ok()?; + (destination != source).then(|| MobileMutationDestination { + path: directory_path(directory.path()), + title: directory_title(directory.path()), + detail: directory_detail(directory.path()), + requires_overwrite: snapshot + .entries() + .any(|entry| entry.path() == &destination), + }) + }) + .collect() + }; + Ok(MobileMutationPlan { + action, + source: source.to_string(), + source_title, + revision: revision(ciphertext.as_bytes()), + destinations, + has_open_editor, + has_dirty_editor, + }) + } + + pub fn perform( + &self, + request: &MobileMutationRequest, + has_open_editor: bool, + keys: &KeyStore, + provider: &mut impl SecretProvider, + committer: &mut impl TreeCommitter, + ) -> Result { + let destination = self.preflight(request, has_open_editor)?; + let mutator = TreeMutator::new(self.repository, keys); + let overwrite = if request.overwrite { + OverwriteDecision::Allow + } else { + OverwriteDecision::Decline + }; + let outcome = match request.action { + MobileMutationAction::Delete => mutator.remove( + &RemoveRequest { + entry: request.source.clone(), + recursive: false, + force: false, + }, + OverwriteDecision::Allow, + committer, + )?, + MobileMutationAction::Move => mutator.move_tree( + &MoveRequest { + source: request.source.clone(), + destination: destination.expect("move destination was validated"), + force: request.overwrite, + }, + overwrite, + None, + provider, + committer, + )?, + MobileMutationAction::Copy => mutator.copy( + &CopyRequest { + source: request.source.clone(), + destination: destination.expect("copy destination was validated"), + force: request.overwrite, + }, + overwrite, + None, + provider, + committer, + )?, + }; + let destination = outcome + .selection() + .map(|selection| selection.display_path()); + let (title, detail) = match request.action { + MobileMutationAction::Move => ( + "Password Moved", + format!( + "Moved {} to {}.", + request.source, + destination.as_deref().unwrap_or("the selected folder") + ), + ), + MobileMutationAction::Copy => ( + "Password Copied", + format!( + "Copied {} to {}.", + request.source, + destination.as_deref().unwrap_or("the selected folder") + ), + ), + MobileMutationAction::Delete => { + ("Password Deleted", format!("Deleted {}.", request.source)) + } + }; + Ok(MobileMutationOutcome { + action: request.action, + source: request.source.clone(), + destination, + title: title.to_owned(), + detail, + }) + } + + fn preflight( + &self, + request: &MobileMutationRequest, + has_open_editor: bool, + ) -> Result, MobileMutationError> { + let source = EntryPath::parse(&request.source)?; + if hidden_path(source.as_path()) { + return Err(MobileMutationError::InvalidSource { + path: source.to_string(), + }); + } + let current = self.repository.read_entry(&source)?; + if revision(current.as_bytes()) != request.revision { + return Err(MobileMutationError::StaleEntry { + path: source.to_string(), + }); + } + if has_open_editor && !request.discard_editor { + return Err(MobileMutationError::EditorOpen { + path: source.to_string(), + }); + } + if request.action == MobileMutationAction::Delete { + if !request.confirmed { + return Err(MobileMutationError::ConfirmationRequired { + path: source.to_string(), + }); + } + return Ok(None); + } + let destination = request + .destination + .as_deref() + .ok_or(MobileMutationError::DestinationRequired)?; + let destination = DirectoryPath::parse(destination)?; + if hidden_path(destination.as_path()) + || !self + .repository + .snapshot()? + .directories() + .any(|directory| directory.path() == &destination) + { + return Err(MobileMutationError::InvalidDestination { + path: directory_path(&destination), + }); + } + let destination_entry = EntryPath::parse( + destination + .as_path() + .join(source.as_path().file_name().expect("validated source name")), + )?; + if destination_entry == source { + return Err(MobileMutationError::SameDestination); + } + let destination_exists = match self.repository.read_entry(&destination_entry) { + Ok(_) => true, + Err(RepositoryError::NotFound { .. }) => false, + Err(error) => return Err(error.into()), + }; + if destination_exists && !request.overwrite { + return Err(MobileMutationError::OverwriteRequired { + path: destination_entry.to_string(), + }); + } + let destination = directory_path(&destination); + Ok(Some(if destination.is_empty() { + destination + } else { + format!("{destination}/") + })) + } +} + +fn revision(ciphertext: &[u8]) -> String { + HEXLOWER.encode(&Sha256::digest(ciphertext)) +} + +fn directory_path(path: &DirectoryPath) -> String { + path.as_path().to_string_lossy().into_owned() +} + +fn directory_title(path: &DirectoryPath) -> String { + path.as_path() + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Password Store") + .to_owned() +} + +fn directory_detail(path: &DirectoryPath) -> String { + if path.as_path().as_os_str().is_empty() { + "Store root".to_owned() + } else { + path.to_string() + } +} + +#[derive(Debug)] +pub enum MobileMutationError { + Repository(RepositoryError), + Mutation(MutationError), + InvalidSource { path: String }, + StaleEntry { path: String }, + EditorOpen { path: String }, + ConfirmationRequired { path: String }, + DestinationRequired, + InvalidDestination { path: String }, + SameDestination, + OverwriteRequired { path: String }, +} + +impl MobileMutationError { + pub fn is_conflict(&self) -> bool { + matches!( + self, + Self::StaleEntry { .. } + | Self::EditorOpen { .. } + | Self::OverwriteRequired { .. } + | Self::Mutation(MutationError::TreeChanged { .. } | MutationError::Commit(_)) + ) + } + + pub fn title(&self) -> &'static str { + match self { + Self::StaleEntry { .. } => "Password Entry Changed", + Self::EditorOpen { .. } => "Password Editor Is Open", + Self::ConfirmationRequired { .. } => "Delete Confirmation Required", + Self::DestinationRequired => "Destination Required", + Self::InvalidDestination { .. } | Self::SameDestination => "Destination Is Invalid", + Self::OverwriteRequired { .. } => "Password Already Exists", + Self::InvalidSource { .. } => "Password Entry Is Unavailable", + Self::Repository(_) | Self::Mutation(_) => "Password Action Failed", + } + } +} + +impl fmt::Display for MobileMutationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Repository(error) => error.fmt(formatter), + Self::Mutation(error) => error.fmt(formatter), + Self::InvalidSource { path } => { + write!(formatter, "the selected entry is not mutable: {path}") + } + Self::StaleEntry { path } => write!( + formatter, + "{path} changed after the action began; refresh and try again" + ), + Self::EditorOpen { path } => write!( + formatter, + "close or explicitly discard the open editor for {path}" + ), + Self::ConfirmationRequired { path } => { + write!(formatter, "confirm deletion of {path}") + } + Self::DestinationRequired => formatter.write_str("select a password folder"), + Self::InvalidDestination { path } => { + write!( + formatter, + "the selected password folder is unavailable: {path}" + ) + } + Self::SameDestination => { + formatter.write_str("the source is already in the selected folder") + } + Self::OverwriteRequired { path } => { + write!( + formatter, + "confirm replacement of the existing entry at {path}" + ) + } + } + } +} + +impl Error for MobileMutationError {} + +impl From for MobileMutationError { + fn from(error: RepositoryError) -> Self { + Self::Repository(error) + } +} + +impl From for MobileMutationError { + fn from(error: MutationError) -> Self { + Self::Mutation(error) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::{ + MobileMutationAction, MobileMutationError, MobileMutationRequest, MobileMutationService, + }; + use crate::repository::Repository; + + #[test] + fn plans_destinations_collisions_hidden_paths_and_stale_revisions() + -> Result<(), Box> { + let temporary = tempdir()?; + fs::create_dir_all(temporary.path().join("Personal"))?; + fs::create_dir_all(temporary.path().join("Work"))?; + fs::create_dir_all(temporary.path().join(".extensions"))?; + fs::write(temporary.path().join("Personal/bank.gpg"), b"source")?; + fs::write(temporary.path().join("Work/bank.gpg"), b"collision")?; + let repository = Repository::open(temporary.path())?; + let service = MobileMutationService::new(&repository); + + let plan = service.prepare("Personal/bank", MobileMutationAction::Move, true, true)?; + assert!(plan.has_open_editor()); + assert!(plan.has_dirty_editor()); + assert!(!plan.revision().is_empty()); + assert!(plan.destinations().iter().all(|destination| { + destination.path() != "Personal" && !destination.path().starts_with(".extensions") + })); + assert!( + plan.destinations() + .iter() + .any(|destination| destination.path() == "" && !destination.requires_overwrite()) + ); + assert!( + plan.destinations() + .iter() + .any(|destination| destination.path() == "Work" && destination.requires_overwrite()) + ); + + fs::write(temporary.path().join("Personal/bank.gpg"), b"changed")?; + let changed = service.prepare("Personal/bank", MobileMutationAction::Move, false, false)?; + assert_ne!(plan.revision(), changed.revision()); + + let stale = MobileMutationRequest { + action: MobileMutationAction::Move, + source: plan.source().to_owned(), + revision: plan.revision().to_owned(), + destination: Some("Work".to_owned()), + confirmed: false, + overwrite: true, + discard_editor: true, + }; + assert!(matches!( + service.preflight(&stale, false), + Err(MobileMutationError::StaleEntry { .. }) + )); + + let delete = MobileMutationRequest { + action: MobileMutationAction::Delete, + source: changed.source().to_owned(), + revision: changed.revision().to_owned(), + destination: None, + confirmed: false, + overwrite: false, + discard_editor: false, + }; + assert!(matches!( + service.preflight(&delete, false), + Err(MobileMutationError::ConfirmationRequired { .. }) + )); + assert!(matches!( + service.preflight( + &MobileMutationRequest { + confirmed: true, + ..delete.clone() + }, + true + ), + Err(MobileMutationError::EditorOpen { .. }) + )); + + let collision = MobileMutationRequest { + action: MobileMutationAction::Copy, + source: changed.source().to_owned(), + revision: changed.revision().to_owned(), + destination: Some("Work".to_owned()), + confirmed: false, + overwrite: false, + discard_editor: false, + }; + assert!(matches!( + service.preflight(&collision, false), + Err(MobileMutationError::OverwriteRequired { .. }) + )); + Ok(()) + } +}