From 900e62e523ba52274f6644b52b5758196b5c6ad6 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 11 Aug 2026 19:59:18 +0200 Subject: [PATCH] Implement structured iPhone entry viewer --- apple/Generated/ironstorage_apple.swift | 454 +++++++++++++++ apple/Generated/ironstorage_appleFFI.h | 44 ++ apple/Sources/App/IronStorageApp.swift | 590 +++++++++++++++++--- crates/apple/src/lib.rs | 144 +++++ crates/storage/src/document.rs | 27 +- crates/storage/src/lib.rs | 1 + crates/storage/src/mobile_authentication.rs | 102 ++++ crates/storage/src/mobile_entry.rs | 305 ++++++++++ crates/storage/tests/entry_documents.rs | 153 ++++- 9 files changed, 1728 insertions(+), 92 deletions(-) create mode 100644 crates/storage/src/mobile_entry.rs diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index 189bc8e..a9b7235 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -587,8 +587,16 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func cancel() throws + func copyEntryField(path: String, field: UInt64) throws -> MobileEntryCopy + + func entryPage(path: String) throws -> MobileEntryPage + func manualLock() throws + func replaceEntryField(path: String, field: UInt64, value: String) throws -> MobileEntryPage + + func revealEntryField(path: String, field: UInt64) throws -> String + func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState func state() throws -> MobileAuthenticationState @@ -659,6 +667,27 @@ open func cancel()throws {try rustCallWithError(FfiConverterTypeMobileAuthenti } } +open func copyEntryField(path: String, field: UInt64)throws -> MobileEntryCopy { + return try FfiConverterTypeMobileEntryCopy_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_entry_field( + self.uniffiCloneHandle(), + FfiConverterString.lower(path), + FfiConverterUInt64.lower(field),uniffiCallStatus + ) +}) +} + +open func entryPage(path: String)throws -> MobileEntryPage { + return try FfiConverterTypeMobileEntryPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page( + self.uniffiCloneHandle(), + FfiConverterString.lower(path),uniffiCallStatus + ) +}) +} + open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock( @@ -667,6 +696,29 @@ open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuth } } +open func replaceEntryField(path: String, field: UInt64, value: String)throws -> MobileEntryPage { + return try FfiConverterTypeMobileEntryPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_replace_entry_field( + self.uniffiCloneHandle(), + FfiConverterString.lower(path), + FfiConverterUInt64.lower(field), + FfiConverterString.lower(value),uniffiCallStatus + ) +}) +} + +open func revealEntryField(path: String, field: UInt64)throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_reveal_entry_field( + self.uniffiCloneHandle(), + FfiConverterString.lower(path), + FfiConverterUInt64.lower(field),uniffiCallStatus + ) +}) +} + open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState { return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -1133,6 +1185,266 @@ public func FfiConverterTypeMobileAuthenticationState_lower(_ value: MobileAuthe } +public struct MobileEntryCopy: Equatable, Hashable { + public var value: String + public var timeoutSeconds: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(value: String, timeoutSeconds: UInt64) { + self.value = value + self.timeoutSeconds = timeoutSeconds + } + + + + +} + +#if compiler(>=6) +extension MobileEntryCopy: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileEntryCopy: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryCopy { + return + try MobileEntryCopy( + value: FfiConverterString.read(from: &buf), + timeoutSeconds: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: MobileEntryCopy, into buf: inout [UInt8]) { + FfiConverterString.write(value.value, into: &buf) + FfiConverterUInt64.write(value.timeoutSeconds, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryCopy_lift(_ buf: RustBuffer) throws -> MobileEntryCopy { + return try FfiConverterTypeMobileEntryCopy.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryCopy_lower(_ value: MobileEntryCopy) -> RustBuffer { + return FfiConverterTypeMobileEntryCopy.lower(value) +} + + +public struct MobileEntryField: Equatable, Hashable { + public var id: UInt64 + public var label: String + public var systemImage: String + public var value: String? + public var maskedValue: String + public var detail: String? + public var diagnostic: String? + public var sensitive: Bool + public var multiline: Bool + public var selectable: Bool + public var editable: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: UInt64, label: String, systemImage: String, value: String?, maskedValue: String, detail: String?, diagnostic: String?, sensitive: Bool, multiline: Bool, selectable: Bool, editable: Bool) { + self.id = id + self.label = label + self.systemImage = systemImage + self.value = value + self.maskedValue = maskedValue + self.detail = detail + self.diagnostic = diagnostic + self.sensitive = sensitive + self.multiline = multiline + self.selectable = selectable + self.editable = editable + } + + + + +} + +#if compiler(>=6) +extension MobileEntryField: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileEntryField: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryField { + return + try MobileEntryField( + id: FfiConverterUInt64.read(from: &buf), + label: FfiConverterString.read(from: &buf), + systemImage: FfiConverterString.read(from: &buf), + value: FfiConverterOptionString.read(from: &buf), + maskedValue: FfiConverterString.read(from: &buf), + detail: FfiConverterOptionString.read(from: &buf), + diagnostic: FfiConverterOptionString.read(from: &buf), + sensitive: FfiConverterBool.read(from: &buf), + multiline: FfiConverterBool.read(from: &buf), + selectable: FfiConverterBool.read(from: &buf), + editable: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: MobileEntryField, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.id, into: &buf) + FfiConverterString.write(value.label, into: &buf) + FfiConverterString.write(value.systemImage, into: &buf) + FfiConverterOptionString.write(value.value, into: &buf) + FfiConverterString.write(value.maskedValue, into: &buf) + FfiConverterOptionString.write(value.detail, into: &buf) + FfiConverterOptionString.write(value.diagnostic, into: &buf) + FfiConverterBool.write(value.sensitive, into: &buf) + FfiConverterBool.write(value.multiline, into: &buf) + FfiConverterBool.write(value.selectable, into: &buf) + FfiConverterBool.write(value.editable, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryField_lift(_ buf: RustBuffer) throws -> MobileEntryField { + return try FfiConverterTypeMobileEntryField.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryField_lower(_ value: MobileEntryField) -> RustBuffer { + return FfiConverterTypeMobileEntryField.lower(value) +} + + +public struct MobileEntryPage: Equatable, Hashable { + public var id: String + public var title: String + public var sections: [MobileEntrySection] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, title: String, sections: [MobileEntrySection]) { + self.id = id + self.title = title + self.sections = sections + } + + + + +} + +#if compiler(>=6) +extension MobileEntryPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileEntryPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryPage { + return + try MobileEntryPage( + id: FfiConverterString.read(from: &buf), + title: FfiConverterString.read(from: &buf), + sections: FfiConverterSequenceTypeMobileEntrySection.read(from: &buf) + ) + } + + public static func write(_ value: MobileEntryPage, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterSequenceTypeMobileEntrySection.write(value.sections, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryPage_lift(_ buf: RustBuffer) throws -> MobileEntryPage { + return try FfiConverterTypeMobileEntryPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryPage_lower(_ value: MobileEntryPage) -> RustBuffer { + return FfiConverterTypeMobileEntryPage.lower(value) +} + + +public struct MobileEntrySection: Equatable, Hashable { + public var kind: MobileEntrySectionKind + public var title: String + public var fields: [MobileEntryField] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(kind: MobileEntrySectionKind, title: String, fields: [MobileEntryField]) { + self.kind = kind + self.title = title + self.fields = fields + } + + + + +} + +#if compiler(>=6) +extension MobileEntrySection: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileEntrySection: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntrySection { + return + try MobileEntrySection( + kind: FfiConverterTypeMobileEntrySectionKind.read(from: &buf), + title: FfiConverterString.read(from: &buf), + fields: FfiConverterSequenceTypeMobileEntryField.read(from: &buf) + ) + } + + public static func write(_ value: MobileEntrySection, into buf: inout [UInt8]) { + FfiConverterTypeMobileEntrySectionKind.write(value.kind, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterSequenceTypeMobileEntryField.write(value.fields, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntrySection_lift(_ buf: RustBuffer) throws -> MobileEntrySection { + return try FfiConverterTypeMobileEntrySection.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntrySection_lower(_ value: MobileEntrySection) -> RustBuffer { + return FfiConverterTypeMobileEntrySection.lower(value) +} + + public struct MobileHomeChange: Equatable, Hashable { public var id: String public var title: String @@ -2150,6 +2462,86 @@ public func FfiConverterTypeMobileAuthenticationFfiError_lower(_ value: MobileAu +public enum MobileEntrySectionKind: Equatable, Hashable { + + case password + case details + case oneTimePassword + case notes + + + + + +} + +#if compiler(>=6) +extension MobileEntrySectionKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileEntrySectionKind: FfiConverterRustBuffer { + typealias SwiftType = MobileEntrySectionKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntrySectionKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .password + + case 2: return .details + + case 3: return .oneTimePassword + + case 4: return .notes + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileEntrySectionKind, into buf: inout [UInt8]) { + switch value { + + + case .password: + writeInt(&buf, Int32(1)) + + + case .details: + writeInt(&buf, Int32(2)) + + + case .oneTimePassword: + writeInt(&buf, Int32(3)) + + + case .notes: + writeInt(&buf, Int32(4)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntrySectionKind_lift(_ buf: RustBuffer) throws -> MobileEntrySectionKind { + return try FfiConverterTypeMobileEntrySectionKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntrySectionKind_lower(_ value: MobileEntrySectionKind) -> RustBuffer { + return FfiConverterTypeMobileEntrySectionKind.lower(value) +} + + + + public enum MobileHomeChangeKind: Equatable, Hashable { case passwordEntry @@ -3514,6 +3906,56 @@ fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeMobileEntryField: FfiConverterRustBuffer { + typealias SwiftType = [MobileEntryField] + + public static func write(_ value: [MobileEntryField], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeMobileEntryField.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileEntryField] { + let len: Int32 = try readInt(&buf) + var seq = [MobileEntryField]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeMobileEntryField.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeMobileEntrySection: FfiConverterRustBuffer { + typealias SwiftType = [MobileEntrySection] + + public static func write(_ value: [MobileEntrySection], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeMobileEntrySection.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileEntrySection] { + let len: Int32 = try readInt(&buf) + var seq = [MobileEntrySection]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeMobileEntrySection.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -3754,9 +4196,21 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel() != 6512) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_entry_field() != 17773) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page() != 56594) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_replace_entry_field() != 15315) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_reveal_entry_field() != 13396) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) { return InitializationResult.apiChecksumMismatch } diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index c7c0c31..7519be3 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -258,11 +258,31 @@ void uniffi_ironstorage_apple_fn_free_mobileauthentication(uint64_t handle, Rust void uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_entry_field(uint64_t ptr, RustBuffer path, uint64_t field, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(uint64_t ptr, RustBuffer path, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK 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_REPLACE_ENTRY_FIELD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REPLACE_ENTRY_FIELD +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_replace_entry_field(uint64_t ptr, RustBuffer path, uint64_t field, RustBuffer value, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_reveal_entry_field(uint64_t ptr, RustBuffer path, uint64_t field, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status @@ -720,12 +740,36 @@ uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CANCEL uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_entry_field(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK #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_REPLACE_ENTRY_FIELD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REPLACE_ENTRY_FIELD +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_replace_entry_field(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_reveal_entry_field(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index 7bf6cb1..b9691a3 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -26,6 +26,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { self.window = window return true } + + func applicationDidEnterBackground(_ application: UIApplication) { + context?.lockForBackground() + } } @MainActor @@ -73,6 +77,15 @@ private final class AppContext: NSObject, UITabBarControllerDelegate { return tabs } + func lockForBackground() { + guard let authentication else { return } + try? authentication.manualLock() + NotificationCenter.default.post( + name: .ironStorageAuthenticationDidChange, + object: authentication + ) + } + func tabBarController( _ tabBarController: UITabBarController, didSelect viewController: UIViewController @@ -275,7 +288,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) + tableView.deselectRow(at: indexPath, animated: false) guard shellTab == .home, let homePage else { return } let section = homeSections[indexPath.section] let commit: MobileHomeCommit @@ -1142,20 +1155,21 @@ private struct PasswordFailure: Error, Sendable { } @MainActor -private final class LockedPasswordViewController: UIViewController { +private final class LockedPasswordViewController: UITableViewController { private let entry: MobilePasswordRow private let authentication: MobileAuthentication? - private let imageView = UIImageView() - private let titleLabel = UILabel() - private let detailLabel = UILabel() - private let unlockButton = UIButton(type: .system) private var unlockTask: Task? + private var entryTask: Task? + private var clipboardTask: Task? + private var feedbackTask: Task? private var state: MobileAuthenticationState? + private var page: MobileEntryPage? + private var revealedValues: [UInt64: String] = [:] init(entry: MobilePasswordRow, authentication: MobileAuthentication?) { self.entry = entry self.authentication = authentication - super.init(nibName: nil, bundle: nil) + super.init(style: .insetGrouped) title = entry.title navigationItem.largeTitleDisplayMode = .never } @@ -1167,34 +1181,9 @@ private final class LockedPasswordViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .systemGroupedBackground - imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 44) - imageView.tintColor = .secondaryLabel - titleLabel.font = .preferredFont(forTextStyle: .title2) - titleLabel.adjustsFontForContentSizeCategory = true - titleLabel.textAlignment = .center - detailLabel.font = .preferredFont(forTextStyle: .body) - detailLabel.adjustsFontForContentSizeCategory = true - detailLabel.textAlignment = .center - detailLabel.textColor = .secondaryLabel - detailLabel.numberOfLines = 0 - unlockButton.configuration = .filled() - unlockButton.configuration?.cornerStyle = .capsule - unlockButton.addTarget(self, action: #selector(unlockRequested), for: .touchUpInside) - let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, detailLabel, unlockButton]) - stack.axis = .vertical - stack.alignment = .center - stack.spacing = 12 - stack.setCustomSpacing(24, after: detailLabel) - stack.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(stack) - NSLayoutConstraint.activate([ - stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), - stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), - stack.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor), - detailLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 360), - unlockButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 140), - ]) + tableView.register(MobileEntryFieldCell.self, forCellReuseIdentifier: "EntryField") + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 92 view.accessibilityIdentifier = entry.id NotificationCenter.default.addObserver( self, @@ -1202,11 +1191,14 @@ private final class LockedPasswordViewController: UIViewController { name: .ironStorageAuthenticationDidChange, object: nil ) - render() + refreshState() } deinit { unlockTask?.cancel() + entryTask?.cancel() + clipboardTask?.cancel() + feedbackTask?.cancel() NotificationCenter.default.removeObserver(self) } @@ -1215,23 +1207,59 @@ private final class LockedPasswordViewController: UIViewController { refreshState() } + override func numberOfSections(in tableView: UITableView) -> Int { + page?.sections.count ?? 0 + } + + override func tableView( + _ tableView: UITableView, + numberOfRowsInSection section: Int + ) -> Int { + page?.sections[section].fields.count ?? 0 + } + + override func tableView( + _ tableView: UITableView, + titleForHeaderInSection section: Int + ) -> String? { + page?.sections[section].title + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + guard + let cell = tableView.dequeueReusableCell( + withIdentifier: "EntryField", + for: indexPath + ) as? MobileEntryFieldCell, + let field = field(at: indexPath) + else { return UITableViewCell() } + cell.configure( + field: field, + revealedValue: revealedValues[field.id], + copy: { [weak self] tappedCell in self?.copy(field, in: tappedCell) }, + reveal: { [weak self] in self?.revealOrHide(field) }, + edit: { [weak self] in self?.edit(field) } + ) + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard + let field = field(at: indexPath), + let cell = tableView.cellForRow(at: indexPath) as? MobileEntryFieldCell + else { return } + copy(field, in: cell) + } + @objc private func authenticationDidChange() { refreshState() } @objc private func unlockRequested() { - if state?.unlocked == true, let authentication { - do { - try authentication.touchUserActivity() - refreshState() - UIAccessibility.post(notification: .announcement, argument: "Unlock extended") - } catch let error as MobileAuthenticationFfiError { - presentAuthenticationFailure(AuthenticationFailure(error)) - } catch { - presentAuthenticationFailure(.unexpected) - } - return - } unlock(passphrase: nil) } @@ -1244,7 +1272,6 @@ private final class LockedPasswordViewController: UIViewController { name: .ironStorageAuthenticationDidChange, object: authentication ) - render() } catch let error as MobileAuthenticationFfiError { presentAuthenticationFailure(AuthenticationFailure(error)) } catch { @@ -1254,7 +1281,12 @@ private final class LockedPasswordViewController: UIViewController { private func refreshState() { state = try? authentication?.state() - render() + if state?.unlocked == true { + if page == nil { loadEntry() } + } else { + maskAndDiscardEntry() + showLocked() + } } private func unlock(passphrase: String?) { @@ -1263,8 +1295,7 @@ private final class LockedPasswordViewController: UIViewController { return } unlockTask?.cancel() - unlockButton.isEnabled = false - unlockButton.configuration?.showsActivityIndicator = true + showLoading(title: "Unlocking Password") let path = entry.path unlockTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { @@ -1279,8 +1310,6 @@ private final class LockedPasswordViewController: UIViewController { } }.value guard !Task.isCancelled, let self else { return } - unlockButton.isEnabled = true - unlockButton.configuration?.showsActivityIndicator = false switch result { case let .success(state): self.state = state @@ -1288,7 +1317,7 @@ private final class LockedPasswordViewController: UIViewController { name: .ironStorageAuthenticationDidChange, object: authentication ) - render() + loadEntry() UIAccessibility.post(notification: .announcement, argument: "Password unlocked") case let .failure(failure): if passphrase == nil, @@ -1296,6 +1325,7 @@ private final class LockedPasswordViewController: UIViewController { promptForPassphrase(message: failure.detail) } else if failure.kind != .cancelled { presentAuthenticationFailure(failure) + showLocked() } } } @@ -1324,28 +1354,440 @@ private final class LockedPasswordViewController: UIViewController { present(alert, animated: true) } - private func render() { - let unlocked = state?.unlocked == true - imageView.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill") - titleLabel.text = unlocked ? "Key Unlocked" : "Locked Password" - detailLabel.text = if unlocked { - "The GPG key is available for protected operations for up to \(state?.remainingSeconds ?? 0) seconds." - } else { - "Authenticate to unlock this password entry. Browsing remains available while locked." + private func loadEntry() { + guard let authentication else { + showLocked() + return } - unlockButton.configuration?.title = unlocked ? "Extend Unlock" : "Unlock" - navigationItem.rightBarButtonItem = unlocked - ? UIBarButtonItem( - image: UIImage(systemName: "lock.fill"), - style: .plain, - target: self, - action: #selector(lockRequested) + entryTask?.cancel() + showLoading(title: "Opening Password") + let path = entry.path + entryTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try authentication.entryPage(path: path) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + switch result { + case let .success(page): + self.page = page + title = page.title + contentUnavailableConfiguration = nil + installLockButton() + tableView.reloadData() + case let .failure(failure): + handle(failure) + } + } + } + + private func field(at indexPath: IndexPath) -> MobileEntryField? { + guard + let page, + page.sections.indices.contains(indexPath.section), + page.sections[indexPath.section].fields.indices.contains(indexPath.row) + else { return nil } + return page.sections[indexPath.section].fields[indexPath.row] + } + + private func revealOrHide(_ field: MobileEntryField) { + if revealedValues.removeValue(forKey: field.id) != nil { + tableView.reloadData() + UIAccessibility.post(notification: .announcement, argument: "\(field.label) hidden") + return + } + performFieldAction(field) { authentication, path in + try authentication.revealEntryField(path: path, field: field.id) + } success: { [weak self] value in + self?.revealedValues[field.id] = value + self?.tableView.reloadData() + UIAccessibility.post(notification: .announcement, argument: "\(field.label) revealed") + } + } + + private func copy(_ field: MobileEntryField, in cell: MobileEntryFieldCell?) { + cell?.flashCopied() + performFieldAction(field) { authentication, path in + try authentication.copyEntryField(path: path, field: field.id) + } success: { [weak self] copy in + guard let self else { return } + UIPasteboard.general.string = copy.value + showFeedback("\(field.label) copied") + UIAccessibility.post(notification: .announcement, argument: "\(field.label) copied") + clipboardTask?.cancel() + clipboardTask = Task { @MainActor in + do { + try await Task.sleep(for: .seconds(copy.timeoutSeconds)) + } catch { + return + } + if UIPasteboard.general.string == copy.value { + UIPasteboard.general.items = [] + } + } + } + } + + private func edit(_ field: MobileEntryField) { + performFieldAction(field) { authentication, path in + try authentication.revealEntryField(path: path, field: field.id) + } success: { [weak self] value in + guard let self else { return } + let editor = MobileEntryEditorViewController(field: field, value: value) { + [weak self] updated in self?.save(updated, for: field) + } + present(UINavigationController(rootViewController: editor), animated: true) + } + } + + private func save(_ value: String, for field: MobileEntryField) { + guard let authentication else { return } + entryTask?.cancel() + showLoading(title: "Saving Password") + let path = entry.path + entryTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + try authentication.touchUserActivity() + return Result.success( + try authentication.replaceEntryField( + path: path, + field: field.id, + value: value + ) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + switch result { + case let .success(page): + self.page = page + revealedValues.removeValue(forKey: field.id) + contentUnavailableConfiguration = nil + tableView.reloadData() + NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil) + showFeedback("\(field.label) saved") + case let .failure(failure): + handle(failure) + } + } + } + + private func performFieldAction( + _ field: MobileEntryField, + operation: @escaping @Sendable (MobileAuthentication, String) throws -> Value, + success: @escaping @MainActor (Value) -> Void + ) { + guard let authentication else { + presentAuthenticationFailure(.unavailable) + return + } + let path = entry.path + Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + try authentication.touchUserActivity() + return Result.success( + try operation(authentication, path) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + switch result { + case let .success(value): success(value) + case let .failure(failure): handle(failure) + } + } + } + + private func handle(_ failure: AuthenticationFailure) { + if failure.kind == .expired { + state = try? authentication?.state() + maskAndDiscardEntry() + showLocked() + NotificationCenter.default.post( + name: .ironStorageAuthenticationDidChange, + object: authentication ) - : nil + } + presentAuthenticationFailure(failure) + } + + private func maskAndDiscardEntry() { + entryTask?.cancel() + feedbackTask?.cancel() + page = nil + revealedValues.removeAll(keepingCapacity: false) + tableView.reloadData() + navigationItem.rightBarButtonItem = nil + navigationItem.prompt = nil + title = entry.title + } + + private func showLocked() { + var configuration = UIContentUnavailableConfiguration.empty() + configuration.image = UIImage(systemName: "lock.fill") + configuration.text = "Locked Password" + configuration.secondaryText = + "Authenticate to decrypt this entry. Browsing remains available while locked." + configuration.button = .filled() + configuration.button.title = "Unlock" + configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in + self?.unlockRequested() + } + contentUnavailableConfiguration = configuration + } + + private func showLoading(title: String) { + var configuration = UIContentUnavailableConfiguration.loading() + configuration.text = title + configuration.secondaryText = "Reading the structured entry from secure storage." + contentUnavailableConfiguration = configuration + } + + private func showFeedback(_ message: String) { + feedbackTask?.cancel() + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "checkmark.circle.fill"), + style: .plain, + target: nil, + action: nil + ) + navigationItem.rightBarButtonItem?.accessibilityLabel = message + UINotificationFeedbackGenerator().notificationOccurred(.success) + feedbackTask = Task { [weak self] in + do { + try await Task.sleep(for: .seconds(2)) + } catch { + return + } + guard let self else { return } + if page != nil { installLockButton() } + } + } + + private func installLockButton() { + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "lock.fill"), + style: .plain, + target: self, + action: #selector(lockRequested) + ) navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage" - unlockButton.accessibilityHint = unlocked - ? "Extends access after authentication." - : "Requests biometric authentication or the GPG key passphrase." + } +} + +@MainActor +private final class MobileEntryFieldCell: UITableViewCell { + private let iconView = UIImageView() + private let labelView = UILabel() + private let valueView = UITextView() + private let detailView = UILabel() + private let diagnosticView = UILabel() + private let revealButton = UIButton(type: .system) + private let editButton = UIButton(type: .system) + private var copyValue: ((MobileEntryFieldCell) -> Void)? + private var highlightTask: Task? + + private static let revealAction = UIAction.Identifier("reveal-entry-field") + private static let editAction = UIAction.Identifier("edit-entry-field") + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .default + iconView.tintColor = .secondaryLabel + iconView.setContentHuggingPriority(.required, for: .horizontal) + iconView.translatesAutoresizingMaskIntoConstraints = false + labelView.font = .preferredFont(forTextStyle: .caption1) + labelView.textColor = .secondaryLabel + labelView.adjustsFontForContentSizeCategory = true + valueView.font = .preferredFont(forTextStyle: .body) + valueView.adjustsFontForContentSizeCategory = true + valueView.backgroundColor = .clear + valueView.isEditable = false + valueView.isScrollEnabled = false + valueView.textContainerInset = .zero + valueView.textContainer.lineFragmentPadding = 0 + let valueTap = UITapGestureRecognizer(target: self, action: #selector(valueTapped)) + valueTap.cancelsTouchesInView = false + valueView.addGestureRecognizer(valueTap) + detailView.font = .preferredFont(forTextStyle: .footnote) + detailView.textColor = .secondaryLabel + detailView.adjustsFontForContentSizeCategory = true + detailView.numberOfLines = 0 + diagnosticView.font = .preferredFont(forTextStyle: .footnote) + diagnosticView.textColor = .systemOrange + diagnosticView.adjustsFontForContentSizeCategory = true + diagnosticView.numberOfLines = 0 + revealButton.configuration = .plain() + editButton.configuration = .plain() + editButton.configuration?.image = UIImage(systemName: "pencil") + editButton.accessibilityLabel = "Edit field" + let labels = UIStackView(arrangedSubviews: [labelView, valueView, detailView, diagnosticView]) + labels.axis = .vertical + labels.spacing = 3 + labels.setContentHuggingPriority(.defaultLow, for: .horizontal) + labels.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let actions = UIStackView(arrangedSubviews: [revealButton, editButton]) + actions.distribution = .fillEqually + NSLayoutConstraint.activate([ + actions.widthAnchor.constraint(equalToConstant: 88), + actions.heightAnchor.constraint(greaterThanOrEqualToConstant: 44), + ]) + actions.setContentHuggingPriority(.required, for: .horizontal) + actions.setContentCompressionResistancePriority(.required, for: .horizontal) + let row = UIStackView(arrangedSubviews: [iconView, labels, actions]) + row.alignment = .top + row.spacing = 12 + row.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(row) + NSLayoutConstraint.activate([ + iconView.widthAnchor.constraint(equalToConstant: 24), + iconView.heightAnchor.constraint(equalToConstant: 24), + row.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + row.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + row.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), + row.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + func configure( + field: MobileEntryField, + revealedValue: String?, + copy: @escaping (MobileEntryFieldCell) -> Void, + reveal: @escaping () -> Void, + edit: @escaping () -> Void + ) { + let value = revealedValue ?? field.value ?? field.maskedValue + iconView.image = UIImage(systemName: field.systemImage) + labelView.text = field.label + valueView.text = value + copyValue = copy + valueView.textColor = field.sensitive && revealedValue == nil ? .secondaryLabel : .label + valueView.isSelectable = field.selectable + detailView.text = field.detail + detailView.isHidden = field.detail == nil + diagnosticView.text = field.diagnostic + diagnosticView.isHidden = field.diagnostic == nil + revealButton.configuration?.image = UIImage( + systemName: revealedValue == nil ? "eye" : "eye.slash" + ) + revealButton.accessibilityLabel = revealedValue == nil ? "Reveal field" : "Hide field" + revealButton.removeAction(identifiedBy: Self.revealAction, for: .touchUpInside) + revealButton.addAction( + UIAction(identifier: Self.revealAction) { _ in reveal() }, + for: .touchUpInside + ) + revealButton.alpha = field.sensitive ? 1 : 0 + revealButton.isEnabled = field.sensitive + revealButton.accessibilityElementsHidden = !field.sensitive + editButton.removeAction(identifiedBy: Self.editAction, for: .touchUpInside) + editButton.addAction( + UIAction(identifier: Self.editAction) { _ in edit() }, + for: .touchUpInside + ) + editButton.alpha = field.editable ? 1 : 0 + editButton.isEnabled = field.editable + editButton.accessibilityElementsHidden = !field.editable + accessibilityLabel = [field.label, value, field.detail, field.diagnostic] + .compactMap { $0 } + .joined(separator: ", ") + accessibilityHint = field.sensitive + ? "Double tap the row to copy. Reveal and Edit buttons follow." + : "Double tap the row to copy. An Edit button follows." + } + + func flashCopied() { + highlightTask?.cancel() + setHighlighted(true, animated: false) + highlightTask = Task { [weak self] in + do { + try await Task.sleep(for: .milliseconds(350)) + } catch { + return + } + self?.setHighlighted(false, animated: true) + } + } + + override func prepareForReuse() { + super.prepareForReuse() + highlightTask?.cancel() + setHighlighted(false, animated: false) + } + + @objc private func valueTapped() { + copyValue?(self) + } +} + +@MainActor +private final class MobileEntryEditorViewController: UIViewController { + private let field: MobileEntryField + private let valueView = UITextView() + private let save: (String) -> Void + + init(field: MobileEntryField, value: String, save: @escaping (String) -> Void) { + self.field = field + self.save = save + super.init(nibName: nil, bundle: nil) + valueView.text = value + title = "Edit \(field.label)" + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemGroupedBackground + valueView.font = .preferredFont(forTextStyle: .body) + valueView.adjustsFontForContentSizeCategory = true + valueView.autocorrectionType = field.sensitive ? .no : .default + valueView.autocapitalizationType = field.sensitive ? .none : .sentences + valueView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(valueView) + NSLayoutConstraint.activate([ + valueView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), + valueView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + valueView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + valueView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -16), + ]) + navigationItem.leftBarButtonItem = UIBarButtonItem( + systemItem: .cancel, + primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) } + ) + navigationItem.rightBarButtonItem = UIBarButtonItem( + systemItem: .save, + primaryAction: UIAction { [weak self] _ in + guard let self else { return } + let value = valueView.text ?? "" + dismiss(animated: true) { self.save(value) } + } + ) + valueView.becomeFirstResponder() } } diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index 9565d62..eb3bbb8 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -12,6 +12,10 @@ use ironstorage::{ MobileAuthenticationError as StorageAuthenticationError, MobileAuthenticationErrorKind as StorageAuthenticationErrorKind, MobileAuthenticationState as StorageAuthenticationState, + MobileEntryCopy as StorageEntryCopy, + }, + mobile_entry::{ + MobileEntryPage as StorageEntryPage, MobileEntrySectionKind as StorageEntrySectionKind, }, mobile_home::{ self, MobileHomeChangeKind as StorageHomeChangeKind, @@ -532,6 +536,103 @@ pub struct MobileAuthenticationState { pub remaining_seconds: u64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileEntrySectionKind { + Password, + Details, + OneTimePassword, + Notes, +} + +impl From for MobileEntrySectionKind { + fn from(kind: StorageEntrySectionKind) -> Self { + match kind { + StorageEntrySectionKind::Password => Self::Password, + StorageEntrySectionKind::Details => Self::Details, + StorageEntrySectionKind::OneTimePassword => Self::OneTimePassword, + StorageEntrySectionKind::Notes => Self::Notes, + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileEntryField { + pub id: u64, + pub label: String, + pub system_image: String, + pub value: Option, + pub masked_value: String, + pub detail: Option, + pub diagnostic: Option, + pub sensitive: bool, + pub multiline: bool, + pub selectable: bool, + pub editable: bool, +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileEntrySection { + pub kind: MobileEntrySectionKind, + pub title: String, + pub fields: Vec, +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileEntryPage { + pub id: String, + pub title: String, + pub sections: Vec, +} + +impl From for MobileEntryPage { + fn from(page: StorageEntryPage) -> Self { + Self { + id: page.id().to_owned(), + title: page.title().to_owned(), + sections: page + .sections() + .iter() + .map(|section| MobileEntrySection { + kind: section.kind().into(), + title: section.title().to_owned(), + fields: section + .fields() + .iter() + .map(|field| MobileEntryField { + id: field.id(), + label: field.label().to_owned(), + system_image: field.system_image().to_owned(), + value: field.value().map(str::to_owned), + masked_value: field.masked_value().to_owned(), + detail: field.detail().map(str::to_owned), + diagnostic: field.diagnostic().map(str::to_owned), + sensitive: field.sensitive(), + multiline: field.multiline(), + selectable: field.selectable(), + editable: field.editable(), + }) + .collect(), + }) + .collect(), + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileEntryCopy { + pub value: String, + pub timeout_seconds: u64, +} + +impl From for MobileEntryCopy { + fn from(copy: StorageEntryCopy) -> Self { + Self { + value: copy.value().to_owned(), + timeout_seconds: copy.timeout_seconds(), + } + } +} + impl From for MobileAuthenticationState { fn from(state: StorageAuthenticationState) -> Self { Self { @@ -616,6 +717,49 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn entry_page( + &self, + path: String, + ) -> Result { + self.authentication + .entry_page(&path) + .map(Into::into) + .map_err(Into::into) + } + + pub fn reveal_entry_field( + &self, + path: String, + field: u64, + ) -> Result { + self.authentication + .reveal_entry_field(&path, field) + .map_err(Into::into) + } + + pub fn copy_entry_field( + &self, + path: String, + field: u64, + ) -> Result { + self.authentication + .copy_entry_field(&path, field) + .map(Into::into) + .map_err(Into::into) + } + + pub fn replace_entry_field( + &self, + path: String, + field: u64, + value: String, + ) -> Result { + self.authentication + .replace_entry_field(&path, field, value) + .map(Into::into) + .map_err(Into::into) + } + pub fn manual_lock(&self) -> Result<(), MobileAuthenticationFfiError> { self.authentication.manual_lock().map_err(Into::into) } diff --git a/crates/storage/src/document.rs b/crates/storage/src/document.rs index d4c5b62..fa82cd8 100644 --- a/crates/storage/src/document.rs +++ b/crates/storage/src/document.rs @@ -17,6 +17,10 @@ use crate::{ pub struct EntryFieldId(u64); impl EntryFieldId { + pub fn from_value(value: u64) -> Self { + Self(value) + } + pub fn value(self) -> u64 { self.0 } @@ -629,7 +633,11 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata { if index == 0 { return EntryFieldMetadata { kind: EntryFieldKind::Password, - sensitivity: EntrySensitivity::Sensitive, + sensitivity: if line.is_empty() { + EntrySensitivity::Empty + } else { + EntrySensitivity::Sensitive + }, name: Some("password".to_owned()), otp: None, diagnostic: (!line.is_ascii() && std::str::from_utf8(line).is_err()) @@ -663,7 +671,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata { let kind = semantic_field_kind(name); return EntryFieldMetadata { kind, - sensitivity: field_sensitivity(name, kind), + sensitivity: EntrySensitivity::Ordinary, name: Some(name.to_owned()), otp: None, diagnostic: std::str::from_utf8(&line[value_start..]) @@ -674,7 +682,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata { } EntryFieldMetadata { kind: EntryFieldKind::Note, - sensitivity: EntrySensitivity::Sensitive, + sensitivity: EntrySensitivity::Ordinary, name: None, otp: None, diagnostic: std::str::from_utf8(line) @@ -693,19 +701,6 @@ fn semantic_field_kind(name: &str) -> EntryFieldKind { } } -fn field_sensitivity(name: &str, kind: EntryFieldKind) -> EntrySensitivity { - if matches!( - kind, - EntryFieldKind::Username | EntryFieldKind::Email | EntryFieldKind::Url - ) { - return EntrySensitivity::Ordinary; - } - match name.to_ascii_lowercase().as_str() { - "title" | "site" | "host" | "autotype_enabled" | "icon" => EntrySensitivity::Ordinary, - _ => EntrySensitivity::Sensitive, - } -} - fn display_key(field: &EntryField) -> (u8, String) { let metadata = field.metadata(); let name = metadata.name().unwrap_or_default().to_lowercase(); diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index e9ce4b5..fe27ca9 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -16,6 +16,7 @@ pub mod git; pub mod kdbx; pub mod mobile; pub mod mobile_authentication; +pub mod mobile_entry; pub mod mobile_home; pub mod mobile_onboarding; pub mod mobile_passwords; diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index 1e64a0d..a2732c8 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -8,6 +8,9 @@ use crate::{ }, config::{Config, ConfigError}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, + document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId}, + git::{AutomaticEntryCommitter, GitIdentity}, + mobile_entry::{MobileEntryPage, MobileEntryValueError, field_value}, repository::{EntryPath, Repository, RepositoryError, SecretBytes}, secret_store::{SecretProtectionPolicy, SecretStoreError}, }; @@ -109,6 +112,22 @@ pub struct MobileAuthenticationState { remaining_seconds: u64, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileEntryCopy { + value: String, + timeout_seconds: u64, +} + +impl MobileEntryCopy { + pub fn value(&self) -> &str { + &self.value + } + + pub fn timeout_seconds(&self) -> u64 { + self.timeout_seconds + } +} + impl MobileAuthenticationState { pub fn unlocked(self) -> bool { self.unlocked @@ -328,6 +347,50 @@ impl MobileAuthentication { .map_err(MobileAuthenticationError::authentication) } + pub fn entry_page(&self, path: &str) -> Result { + let document = self.open_active_document(path)?; + Ok(MobileEntryPage::from_document(&document)) + } + + pub fn reveal_entry_field( + &self, + path: &str, + field: u64, + ) -> Result { + let document = self.open_active_document(path)?; + field_value(&document, field).map_err(value_error) + } + + pub fn copy_entry_field( + &self, + path: &str, + field: u64, + ) -> Result { + Ok(MobileEntryCopy { + value: self.reveal_entry_field(path, field)?, + timeout_seconds: self.config.clipboard_timeout().duration().as_secs(), + }) + } + + pub fn replace_entry_field( + &self, + path: &str, + field: u64, + value: String, + ) -> Result { + let mut document = self.open_active_document(path)?; + document + .replace_field_value(EntryFieldId::from_value(field), value.into_bytes()) + .map_err(document_error)?; + let mut committer = + AutomaticEntryCommitter::for_entry(&self.repository, path, GitIdentity::ironstorage()) + .map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?; + EntryDocumentService::new(&self.repository, &self.keys) + .save_recoverable(&document, None, &mut committer) + .map_err(document_error)?; + Ok(MobileEntryPage::from_document(&document)) + } + pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> { self.session .manual_lock() @@ -383,6 +446,21 @@ impl MobileAuthentication { self.status()?.active = Some(ActiveMobileLease { handle, key }); Ok(()) } + + fn open_active_document(&self, path: &str) -> 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()) + }; + handle + .ensure_active() + .map_err(MobileAuthenticationError::authentication)?; + let mut provider = KeyOnlyProvider::new(handle, &key); + EntryDocumentService::new(&self.repository, &self.keys) + .open(path, &mut provider) + .map_err(document_error) + } } struct KeyOnlyProvider<'a> { @@ -431,3 +509,27 @@ fn key_error(error: CryptoError) -> MobileAuthenticationError { error.to_string(), ) } + +fn locked_error() -> MobileAuthenticationError { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Expired, + "IronStorage Locked", + "Authenticate before using protected content.", + ) +} + +fn document_error(error: DocumentError) -> MobileAuthenticationError { + entry_detail("Password Entry Is Unavailable", error) +} + +fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError { + entry_detail("Field Value Is Unavailable", error) +} + +fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationError { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Entry, + title, + error.to_string(), + ) +} diff --git a/crates/storage/src/mobile_entry.rs b/crates/storage/src/mobile_entry.rs new file mode 100644 index 0000000..407b37f --- /dev/null +++ b/crates/storage/src/mobile_entry.rs @@ -0,0 +1,305 @@ +//! Storage-owned projection of lossless entry documents for native mobile viewers. + +use std::{error::Error, fmt}; + +use crate::document::{ + DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId, EntryFieldKind, + EntrySensitivity, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileEntrySectionKind { + Password, + Details, + OneTimePassword, + Notes, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileEntryField { + id: u64, + label: String, + system_image: String, + value: Option, + masked_value: String, + detail: Option, + diagnostic: Option, + sensitive: bool, + multiline: bool, + selectable: bool, + editable: bool, +} + +impl MobileEntryField { + pub fn id(&self) -> u64 { + self.id + } + + pub fn label(&self) -> &str { + &self.label + } + + pub fn system_image(&self) -> &str { + &self.system_image + } + + pub fn value(&self) -> Option<&str> { + self.value.as_deref() + } + + pub fn masked_value(&self) -> &str { + &self.masked_value + } + + pub fn detail(&self) -> Option<&str> { + self.detail.as_deref() + } + + pub fn diagnostic(&self) -> Option<&str> { + self.diagnostic.as_deref() + } + + pub fn sensitive(&self) -> bool { + self.sensitive + } + + pub fn multiline(&self) -> bool { + self.multiline + } + + pub fn selectable(&self) -> bool { + self.selectable + } + + pub fn editable(&self) -> bool { + self.editable + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileEntrySection { + kind: MobileEntrySectionKind, + title: String, + fields: Vec, +} + +impl MobileEntrySection { + pub fn kind(&self) -> MobileEntrySectionKind { + self.kind + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn fields(&self) -> &[MobileEntryField] { + &self.fields + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileEntryPage { + id: String, + title: String, + sections: Vec, +} + +impl MobileEntryPage { + pub fn from_document(document: &EntryDocument) -> Self { + let title = document + .path() + .as_path() + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Password") + .to_owned(); + let mut sections = Vec::new(); + for (kind, section_title) in [ + (MobileEntrySectionKind::Password, "Password"), + (MobileEntrySectionKind::Details, "Details"), + (MobileEntrySectionKind::OneTimePassword, "One-Time Password"), + (MobileEntrySectionKind::Notes, "Notes"), + ] { + let fields = document + .display_fields() + .into_iter() + .filter(|field| section_kind(field) == kind) + .map(mobile_field) + .collect::>(); + if !fields.is_empty() { + sections.push(MobileEntrySection { + kind, + title: section_title.to_owned(), + fields, + }); + } + } + Self { + id: format!("entry:{}", document.path()), + title, + sections, + } + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn sections(&self) -> &[MobileEntrySection] { + &self.sections + } +} + +#[derive(Debug)] +pub enum MobileEntryValueError { + Document(DocumentError), + NonUtf8, +} + +impl fmt::Display for MobileEntryValueError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Document(error) => error.fmt(formatter), + Self::NonUtf8 => formatter.write_str("the entry field is not valid UTF-8"), + } + } +} + +impl Error for MobileEntryValueError {} + +impl From for MobileEntryValueError { + fn from(error: DocumentError) -> Self { + Self::Document(error) + } +} + +pub fn field_value(document: &EntryDocument, id: u64) -> Result { + let value = document.copy_field_value(EntryFieldId::from_value(id))?; + String::from_utf8(value.expose().to_vec()).map_err(|_| MobileEntryValueError::NonUtf8) +} + +fn mobile_field(field: &EntryField) -> MobileEntryField { + let metadata = field.metadata(); + let sensitive = metadata.sensitivity() == EntrySensitivity::Sensitive; + let value = if sensitive || metadata.sensitivity() == EntrySensitivity::Empty { + None + } else { + String::from_utf8(field.value().to_vec()).ok() + }; + MobileEntryField { + id: field.id().value(), + label: label(field), + system_image: system_image(metadata.kind()).to_owned(), + value, + masked_value: if metadata.sensitivity() == EntrySensitivity::Empty { + "Empty" + } else if sensitive { + "Hidden" + } else { + "Unavailable" + } + .to_owned(), + detail: otp_detail(field), + diagnostic: metadata.diagnostic().map(diagnostic), + sensitive, + multiline: matches!(metadata.kind(), EntryFieldKind::Note) + || field.value().contains(&b'\n'), + selectable: !sensitive && metadata.diagnostic() != Some(EntryFieldDiagnostic::NonUtf8Value), + editable: true, + } +} + +fn section_kind(field: &EntryField) -> MobileEntrySectionKind { + if field.metadata().name().is_some_and(|name| { + ["comment", "comments", "note", "notes"] + .iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) + }) { + return MobileEntrySectionKind::Notes; + } + match field.metadata().kind() { + EntryFieldKind::Password => MobileEntrySectionKind::Password, + EntryFieldKind::OtpUri => MobileEntrySectionKind::OneTimePassword, + EntryFieldKind::Note => MobileEntrySectionKind::Notes, + EntryFieldKind::Username + | EntryFieldKind::Email + | EntryFieldKind::Url + | EntryFieldKind::Field => MobileEntrySectionKind::Details, + } +} + +fn label(field: &EntryField) -> String { + if field.metadata().kind() == EntryFieldKind::Password { + return "Password".to_owned(); + } + if field.metadata().kind() == EntryFieldKind::OtpUri { + return "OTP URI".to_owned(); + } + field + .metadata() + .name() + .map(display_name) + .unwrap_or_else(|| match field.metadata().kind() { + EntryFieldKind::Username => "Username".to_owned(), + EntryFieldKind::Email => "Email".to_owned(), + EntryFieldKind::Url => "Website".to_owned(), + EntryFieldKind::Field => "Field".to_owned(), + EntryFieldKind::Note => "Notes".to_owned(), + EntryFieldKind::Password | EntryFieldKind::OtpUri => unreachable!(), + }) +} + +fn display_name(name: &str) -> String { + let mut characters = name.chars(); + match characters.next() { + Some(first) => first.to_uppercase().chain(characters).collect(), + None => "Field".to_owned(), + } +} + +fn system_image(kind: EntryFieldKind) -> &'static str { + match kind { + EntryFieldKind::Password => "key.fill", + EntryFieldKind::Username => "person.fill", + EntryFieldKind::Email => "envelope.fill", + EntryFieldKind::Url => "globe", + EntryFieldKind::OtpUri => "timer", + EntryFieldKind::Field => "text.alignleft", + EntryFieldKind::Note => "note.text", + } +} + +fn diagnostic(diagnostic: EntryFieldDiagnostic) -> String { + match diagnostic { + EntryFieldDiagnostic::MalformedOtpUri => { + "This OTP URI is malformed. Its original value is preserved.".to_owned() + } + EntryFieldDiagnostic::NonUtf8Value => { + "This field is not UTF-8 text. Its original bytes are preserved.".to_owned() + } + } +} + +fn otp_detail(field: &EntryField) -> Option { + let otp = field.metadata().otp()?; + let mut parts = vec![format!("{:?}", otp.kind()).to_uppercase()]; + if let Some(issuer) = otp.issuer() { + parts.push(issuer.to_owned()); + } + if !otp.account().is_empty() { + parts.push(otp.account().to_owned()); + } + parts.push(format!("{:?}", otp.algorithm()).to_uppercase()); + parts.push(format!("{} digits", otp.digits())); + if let Some(period) = otp.period() { + parts.push(format!("{period} seconds")); + } + if let Some(counter) = otp.counter() { + parts.push(format!("counter {counter}")); + } + Some(parts.join(" · ")) +} diff --git a/crates/storage/tests/entry_documents.rs b/crates/storage/tests/entry_documents.rs index d8fc6b7..5a64a21 100644 --- a/crates/storage/tests/entry_documents.rs +++ b/crates/storage/tests/entry_documents.rs @@ -9,6 +9,7 @@ use ironstorage::{ document::{ DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity, }, + mobile_entry::{MobileEntryPage, MobileEntrySectionKind, field_value}, recipient::RecipientPolicyManager, repository::{EntryPath, Repository, SecretBytes}, write::{EntryCommit, EntryCommitError, EntryCommitter, WriteError}, @@ -134,6 +135,15 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { document.fields()[6].metadata().sensitivity(), EntrySensitivity::Sensitive ); + assert_eq!( + document + .fields() + .iter() + .filter(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive) + .map(|field| field.metadata().kind()) + .collect::>(), + [EntryFieldKind::Password, EntryFieldKind::OtpUri] + ); let otp = document.fields()[6] .metadata() .otp() @@ -147,11 +157,123 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { assert_eq!(otp.counter(), None); let copied = document.copy_field_value(document.fields()[4].id())?; assert_eq!(copied.expose(), b"one"); + let mobile = MobileEntryPage::from_document(&document); + let mobile_fields = mobile + .sections() + .iter() + .flat_map(|section| section.fields()) + .collect::>(); + assert_eq!(mobile.title(), "complex"); + assert_eq!(mobile_fields.len(), document.fields().len()); + assert_eq!(mobile_fields[0].label(), "Password"); + assert!(mobile_fields[0].sensitive()); + assert_eq!(mobile_fields[0].value(), None); + assert_eq!(mobile_fields[1].value(), Some("alice")); + let mobile_custom = mobile_fields + .iter() + .filter(|field| field.label() == "Custom") + .copied() + .collect::>(); + assert_eq!(mobile_custom.len(), 2); + assert_ne!(mobile_custom[0].id(), mobile_custom[1].id()); + assert_eq!(mobile_custom[0].value(), Some("one")); + assert_eq!(mobile_custom[1].value(), Some("")); + let mobile_otp = mobile_fields + .iter() + .find(|field| field.system_image() == "timer") + .expect("OTP field"); + assert!(mobile_otp.sensitive()); + assert_eq!(mobile_otp.value(), None); + assert!(mobile_otp.detail().is_some_and(|detail| { + detail.contains("TOTP") && detail.contains("Example") && detail.contains("alice") + })); + let mobile_note = mobile_fields + .iter() + .find(|field| field.system_image() == "note.text") + .expect("multiline note"); + assert!(!mobile_note.sensitive()); + assert!(mobile_note.multiline()); + assert!(mobile_note.selectable()); + assert_eq!(mobile_note.value(), Some("\r\nfirst note\r\nsecond note")); + assert_eq!( + field_value(&document, document.fields()[4].id().value())?, + "one" + ); assert!(!format!("{document:?}").contains("pässwörd")); assert!(!format!("{:?}", document.fields()[6]).contains("JBSWY3DPEHPK3PXP")); Ok(()) } +#[test] +fn mobile_projection_preserves_partially_understood_fields_without_exposing_them() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let mut secrets = FixtureSecrets::all(&fixture); + write_plaintext( + &repository, + &keys, + "documents/diagnostics", + b"password\notpauth://totp/broken\ncustom: \xff\n", + )?; + + let document = EntryDocumentService::new(&repository, &keys) + .open("documents/diagnostics", &mut secrets)?; + let page = MobileEntryPage::from_document(&document); + let fields = page + .sections() + .iter() + .flat_map(|section| section.fields()) + .collect::>(); + assert_eq!(fields.len(), 3); + assert_eq!( + fields + .iter() + .filter(|field| field.diagnostic().is_some()) + .count(), + 2 + ); + assert!(fields.iter().all(|field| field.value().is_none())); + assert!(field_value(&document, document.fields()[2].id().value()).is_err()); + assert_eq!( + document.serialize().expose(), + b"password\notpauth://totp/broken\ncustom: \xff\n" + ); + Ok(()) +} + +#[test] +fn mobile_projection_marks_an_empty_first_line_without_a_reveal_control() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let mut secrets = FixtureSecrets::all(&fixture); + write_plaintext( + &repository, + &keys, + "documents/empty-password", + b"\ncomments: ordinary note\n", + )?; + + let document = EntryDocumentService::new(&repository, &keys) + .open("documents/empty-password", &mut secrets)?; + let password = document.password().expect("empty password field"); + assert_eq!(password.metadata().sensitivity(), EntrySensitivity::Empty); + let page = MobileEntryPage::from_document(&document); + let password = page.sections()[0].fields().first().expect("password row"); + assert_eq!(password.label(), "Password"); + assert_eq!(password.value(), None); + assert_eq!(password.masked_value(), "Empty"); + assert!(!password.sensitive()); + assert_eq!( + document.serialize().expose(), + b"\ncomments: ordinary note\n" + ); + Ok(()) +} + #[test] fn named_multiline_fields_are_one_lossless_logical_field() -> TestResult { let fixture = FixtureSet::load()?; @@ -189,7 +311,7 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult { let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let mut secrets = FixtureSecrets::all(&fixture); - let plaintext = "password\ncomments: PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n\nautotype_enabled: True\nicon: 0\n"; + let plaintext = "password\ncomments: PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n\nautotype_enabled: True\nicon: 0\ntags: travel, train\n"; write_plaintext( &repository, &keys, @@ -200,8 +322,12 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult { let document = EntryDocumentService::new(&repository, &keys).open("documents/comments", &mut secrets)?; assert_eq!(document.serialize().expose(), plaintext.as_bytes()); - assert_eq!(document.fields().len(), 4); + assert_eq!(document.fields().len(), 5); assert_eq!(document.fields()[1].metadata().name(), Some("comments")); + assert_eq!( + document.fields()[1].metadata().sensitivity(), + EntrySensitivity::Ordinary + ); assert_eq!( document.fields()[1].value(), "PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n" @@ -212,6 +338,25 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult { Some("autotype_enabled") ); assert_eq!(document.fields()[3].metadata().name(), Some("icon")); + assert_eq!(document.fields()[4].metadata().name(), Some("tags")); + assert_eq!( + document.fields()[4].metadata().sensitivity(), + EntrySensitivity::Ordinary + ); + let mobile = MobileEntryPage::from_document(&document); + let comments = mobile + .sections() + .iter() + .find(|section| section.kind() == MobileEntrySectionKind::Notes) + .and_then(|section| section.fields().first()) + .expect("multiline comments section"); + assert_eq!(comments.label(), "Comments"); + assert!(!comments.sensitive()); + assert!(comments.multiline()); + assert_eq!( + comments.value(), + std::str::from_utf8(document.fields()[1].value()).ok() + ); assert!(matches!( EntryFieldDraft::field("www.bahn.de Login", Vec::new()), Err(DocumentError::InvalidFieldName) @@ -228,6 +373,10 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult { assert_eq!(note.password().expect("password").value(), b"password"); assert_eq!(note.fields().len(), 2); assert_eq!(note.fields()[1].value(), b"\nordinary note\ncontinued"); + assert_eq!( + note.fields()[1].metadata().sensitivity(), + EntrySensitivity::Ordinary + ); let unordered = "password\nzeta: last\nnote: named\ncomments: heading\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\nordinary note\nurl: https://example.test\nlogin: alice\nalpha: first\n"; write_plaintext(