Onboard iPhone password-store clone

This commit is contained in:
2026-08-11 16:33:08 +02:00
parent db898152bf
commit daf122aff3
11 changed files with 2770 additions and 38 deletions

View File

@@ -462,6 +462,46 @@ fileprivate final class UniffiHandleMap<T>: @unchecked Sendable {
// Public interface members begin here.
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
fileprivate struct FfiConverterUInt32: FfiConverterPrimitive {
typealias FfiType = UInt32
typealias SwiftType = UInt32
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt32 {
return try lift(readInt(&buf))
}
public static func write(_ value: SwiftType, into buf: inout [UInt8]) {
writeInt(&buf, lower(value))
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
fileprivate struct FfiConverterBool : FfiConverter {
typealias FfiType = Int8
typealias SwiftType = Bool
public static func lift(_ value: Int8) throws -> Bool {
return value != 0
}
public static func lower(_ value: Bool) -> Int8 {
return value ? 1 : 0
}
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Bool {
return try lift(readInt(&buf))
}
public static func write(_ value: Bool, into buf: inout [UInt8]) {
writeInt(&buf, lower(value))
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
@@ -509,6 +549,323 @@ fileprivate struct FfiConverterString: FfiConverter {
}
public protocol MobileOnboardingOperationProtocol: AnyObject, Sendable {
func cancel()
func discover() throws -> MobileOnboardingDiscovery
func progress() -> MobileOnboardingProgress
func setup(branch: String, useExisting: Bool) throws -> MobileOnboardingOutcome
}
open class MobileOnboardingOperation: MobileOnboardingOperationProtocol, @unchecked Sendable {
fileprivate let handle: UInt64
/// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly.
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct NoHandle {
public init() {}
}
// TODO: We'd like this to be `private` but for Swifty reasons,
// we can't implement `FfiConverter` without making this `required` and we can't
// make it `required` without making it `public`.
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
required public init(unsafeFromHandle handle: UInt64) {
self.handle = handle
}
// This constructor can be used to instantiate a fake object.
// - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject].
//
// - Warning:
// Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash.
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public init(noHandle: NoHandle) {
self.handle = 0
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func uniffiCloneHandle() -> UInt64 {
return try! rustCall { uniffi_ironstorage_apple_fn_clone_mobileonboardingoperation(self.handle, $0) }
}
// No primary constructor declared for this class.
deinit {
if handle == 0 {
// Mock objects have handle=0 don't try to free them
return
}
try! rustCall { uniffi_ironstorage_apple_fn_free_mobileonboardingoperation(handle, $0) }
}
open func cancel() {try! rustCall() {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_cancel(
self.uniffiCloneHandle(),uniffiCallStatus
)
}
}
open func discover()throws -> MobileOnboardingDiscovery {
return try FfiConverterTypeMobileOnboardingDiscovery_lift(try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_discover(
self.uniffiCloneHandle(),uniffiCallStatus
)
})
}
open func progress() -> MobileOnboardingProgress {
return try! FfiConverterTypeMobileOnboardingProgress_lift(try! rustCall() {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_progress(
self.uniffiCloneHandle(),uniffiCallStatus
)
})
}
open func setup(branch: String, useExisting: Bool)throws -> MobileOnboardingOutcome {
return try FfiConverterTypeMobileOnboardingOutcome_lift(try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_setup(
self.uniffiCloneHandle(),
FfiConverterString.lower(branch),
FfiConverterBool.lower(useExisting),uniffiCallStatus
)
})
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeMobileOnboardingOperation: FfiConverter {
typealias FfiType = UInt64
typealias SwiftType = MobileOnboardingOperation
public static func lift(_ handle: UInt64) throws -> MobileOnboardingOperation {
return MobileOnboardingOperation(unsafeFromHandle: handle)
}
public static func lower(_ value: MobileOnboardingOperation) -> UInt64 {
return value.uniffiCloneHandle()
}
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingOperation {
let handle: UInt64 = try readInt(&buf)
return try lift(handle)
}
public static func write(_ value: MobileOnboardingOperation, into buf: inout [UInt8]) {
writeInt(&buf, lower(value))
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingOperation_lift(_ handle: UInt64) throws -> MobileOnboardingOperation {
return try FfiConverterTypeMobileOnboardingOperation.lift(handle)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingOperation_lower(_ value: MobileOnboardingOperation) -> UInt64 {
return FfiConverterTypeMobileOnboardingOperation.lower(value)
}
public struct MobileOnboardingDiscovery: Equatable, Hashable {
public var branches: [String]
public var selectedBranch: UInt32
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(branches: [String], selectedBranch: UInt32) {
self.branches = branches
self.selectedBranch = selectedBranch
}
}
#if compiler(>=6)
extension MobileOnboardingDiscovery: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeMobileOnboardingDiscovery: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingDiscovery {
return
try MobileOnboardingDiscovery(
branches: FfiConverterSequenceString.read(from: &buf),
selectedBranch: FfiConverterUInt32.read(from: &buf)
)
}
public static func write(_ value: MobileOnboardingDiscovery, into buf: inout [UInt8]) {
FfiConverterSequenceString.write(value.branches, into: &buf)
FfiConverterUInt32.write(value.selectedBranch, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingDiscovery_lift(_ buf: RustBuffer) throws -> MobileOnboardingDiscovery {
return try FfiConverterTypeMobileOnboardingDiscovery.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingDiscovery_lower(_ value: MobileOnboardingDiscovery) -> RustBuffer {
return FfiConverterTypeMobileOnboardingDiscovery.lower(value)
}
public struct MobileOnboardingOutcome: Equatable, Hashable {
public var title: String
public var detail: String
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(title: String, detail: String) {
self.title = title
self.detail = detail
}
}
#if compiler(>=6)
extension MobileOnboardingOutcome: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeMobileOnboardingOutcome: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingOutcome {
return
try MobileOnboardingOutcome(
title: FfiConverterString.read(from: &buf),
detail: FfiConverterString.read(from: &buf)
)
}
public static func write(_ value: MobileOnboardingOutcome, into buf: inout [UInt8]) {
FfiConverterString.write(value.title, into: &buf)
FfiConverterString.write(value.detail, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingOutcome_lift(_ buf: RustBuffer) throws -> MobileOnboardingOutcome {
return try FfiConverterTypeMobileOnboardingOutcome.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingOutcome_lower(_ value: MobileOnboardingOutcome) -> RustBuffer {
return FfiConverterTypeMobileOnboardingOutcome.lower(value)
}
public struct MobileOnboardingProgress: Equatable, Hashable {
public var phase: MobileOnboardingPhase
public var title: String
public var detail: String
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(phase: MobileOnboardingPhase, title: String, detail: String) {
self.phase = phase
self.title = title
self.detail = detail
}
}
#if compiler(>=6)
extension MobileOnboardingProgress: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeMobileOnboardingProgress: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingProgress {
return
try MobileOnboardingProgress(
phase: FfiConverterTypeMobileOnboardingPhase.read(from: &buf),
title: FfiConverterString.read(from: &buf),
detail: FfiConverterString.read(from: &buf)
)
}
public static func write(_ value: MobileOnboardingProgress, into buf: inout [UInt8]) {
FfiConverterTypeMobileOnboardingPhase.write(value.phase, into: &buf)
FfiConverterString.write(value.title, into: &buf)
FfiConverterString.write(value.detail, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingProgress_lift(_ buf: RustBuffer) throws -> MobileOnboardingProgress {
return try FfiConverterTypeMobileOnboardingProgress.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingProgress_lower(_ value: MobileOnboardingProgress) -> RustBuffer {
return FfiConverterTypeMobileOnboardingProgress.lower(value)
}
public struct MobilePage: Equatable, Hashable {
public var tab: MobileTab
public var title: String
@@ -637,6 +994,287 @@ public func FfiConverterTypeMobileShell_lower(_ value: MobileShell) -> RustBuffe
}
public enum MobileOnboardingErrorKind: Equatable, Hashable {
case invalidInput
case unsupportedRemote
case authentication
case repository
case existingClone
case interrupted
case secureStorage
case configuration
case alreadyConfigured
}
#if compiler(>=6)
extension MobileOnboardingErrorKind: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeMobileOnboardingErrorKind: FfiConverterRustBuffer {
typealias SwiftType = MobileOnboardingErrorKind
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingErrorKind {
let variant: Int32 = try readInt(&buf)
switch variant {
case 1: return .invalidInput
case 2: return .unsupportedRemote
case 3: return .authentication
case 4: return .repository
case 5: return .existingClone
case 6: return .interrupted
case 7: return .secureStorage
case 8: return .configuration
case 9: return .alreadyConfigured
default: throw UniffiInternalError.unexpectedEnumCase
}
}
public static func write(_ value: MobileOnboardingErrorKind, into buf: inout [UInt8]) {
switch value {
case .invalidInput:
writeInt(&buf, Int32(1))
case .unsupportedRemote:
writeInt(&buf, Int32(2))
case .authentication:
writeInt(&buf, Int32(3))
case .repository:
writeInt(&buf, Int32(4))
case .existingClone:
writeInt(&buf, Int32(5))
case .interrupted:
writeInt(&buf, Int32(6))
case .secureStorage:
writeInt(&buf, Int32(7))
case .configuration:
writeInt(&buf, Int32(8))
case .alreadyConfigured:
writeInt(&buf, Int32(9))
}
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingErrorKind_lift(_ buf: RustBuffer) throws -> MobileOnboardingErrorKind {
return try FfiConverterTypeMobileOnboardingErrorKind.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingErrorKind_lower(_ value: MobileOnboardingErrorKind) -> RustBuffer {
return FfiConverterTypeMobileOnboardingErrorKind.lower(value)
}
public
enum MobileOnboardingFfiError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError {
case Failed(kind: MobileOnboardingErrorKind, title: String, detail: String
)
public var errorDescription: String? {
String(reflecting: self)
}
}
#if compiler(>=6)
extension MobileOnboardingFfiError: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeMobileOnboardingFfiError: FfiConverterRustBuffer {
typealias SwiftType = MobileOnboardingFfiError
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingFfiError {
let variant: Int32 = try readInt(&buf)
switch variant {
case 1: return .Failed(
kind: try FfiConverterTypeMobileOnboardingErrorKind.read(from: &buf),
title: try FfiConverterString.read(from: &buf),
detail: try FfiConverterString.read(from: &buf)
)
default: throw UniffiInternalError.unexpectedEnumCase
}
}
public static func write(_ value: MobileOnboardingFfiError, into buf: inout [UInt8]) {
switch value {
case let .Failed(kind,title,detail):
writeInt(&buf, Int32(1))
FfiConverterTypeMobileOnboardingErrorKind.write(kind, into: &buf)
FfiConverterString.write(title, into: &buf)
FfiConverterString.write(detail, into: &buf)
}
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingFfiError_lift(_ buf: RustBuffer) throws -> MobileOnboardingFfiError {
return try FfiConverterTypeMobileOnboardingFfiError.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingFfiError_lower(_ value: MobileOnboardingFfiError) -> RustBuffer {
return FfiConverterTypeMobileOnboardingFfiError.lower(value)
}
public enum MobileOnboardingPhase: Equatable, Hashable {
case validating
case authenticating
case receiving
case integrating
case finishing
}
#if compiler(>=6)
extension MobileOnboardingPhase: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeMobileOnboardingPhase: FfiConverterRustBuffer {
typealias SwiftType = MobileOnboardingPhase
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingPhase {
let variant: Int32 = try readInt(&buf)
switch variant {
case 1: return .validating
case 2: return .authenticating
case 3: return .receiving
case 4: return .integrating
case 5: return .finishing
default: throw UniffiInternalError.unexpectedEnumCase
}
}
public static func write(_ value: MobileOnboardingPhase, into buf: inout [UInt8]) {
switch value {
case .validating:
writeInt(&buf, Int32(1))
case .authenticating:
writeInt(&buf, Int32(2))
case .receiving:
writeInt(&buf, Int32(3))
case .integrating:
writeInt(&buf, Int32(4))
case .finishing:
writeInt(&buf, Int32(5))
}
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingPhase_lift(_ buf: RustBuffer) throws -> MobileOnboardingPhase {
return try FfiConverterTypeMobileOnboardingPhase.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileOnboardingPhase_lower(_ value: MobileOnboardingPhase) -> RustBuffer {
return FfiConverterTypeMobileOnboardingPhase.lower(value)
}
public
enum MobilePreferenceError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError {
@@ -878,6 +1516,31 @@ public func FfiConverterTypeMobileTab_lower(_ value: MobileTab) -> RustBuffer {
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer {
typealias SwiftType = [String]
public static func write(_ value: [String], into buf: inout [UInt8]) {
let len = Int32(value.count)
writeInt(&buf, len)
for item in value {
FfiConverterString.write(item, into: &buf)
}
}
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [String] {
let len: Int32 = try readInt(&buf)
var seq = [String]()
seq.reserveCapacity(Int(len))
for _ in 0 ..< len {
seq.append(try FfiConverterString.read(from: &buf))
}
return seq
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
@@ -902,6 +1565,17 @@ fileprivate struct FfiConverterSequenceTypeMobilePage: FfiConverterRustBuffer {
return seq
}
}
public func mobileOnboardingOperation(serverUrl: String, account: String, repositoryPath: String, applicationToken: String)throws -> MobileOnboardingOperation {
return try FfiConverterTypeMobileOnboardingOperation_lift(try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_func_mobile_onboarding_operation(
FfiConverterString.lower(serverUrl),
FfiConverterString.lower(account),
FfiConverterString.lower(repositoryPath),
FfiConverterString.lower(applicationToken),uniffiCallStatus
)
})
}
public func mobileShell() -> MobileShell {
return try! FfiConverterTypeMobileShell_lift(try! rustCall() {
uniffiCallStatus in
@@ -924,6 +1598,14 @@ public func productName() -> String {
)
})
}
public func replaceConfiguredMobileApplicationToken(account: String, applicationToken: String)throws {try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_func_replace_configured_mobile_application_token(
FfiConverterString.lower(account),
FfiConverterString.lower(applicationToken),uniffiCallStatus
)
}
}
public func setSelectedMobileTab(tab: MobileTab)throws {try rustCallWithError(FfiConverterTypeMobilePreferenceError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_func_set_selected_mobile_tab(
@@ -947,6 +1629,9 @@ private let initializationResult: InitializationResult = {
if bindings_contract_version != scaffolding_contract_version {
return InitializationResult.contractVersionMismatch
}
if (uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation() != 8354) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_func_mobile_shell() != 42687) {
return InitializationResult.apiChecksumMismatch
}
@@ -956,9 +1641,24 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_func_product_name() != 43533) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_func_replace_configured_mobile_application_token() != 32386) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab() != 65280) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_cancel() != 49755) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_discover() != 2309) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_progress() != 28201) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_setup() != 3525) {
return InitializationResult.apiChecksumMismatch
}
return InitializationResult.ok
}()

View File

@@ -242,6 +242,41 @@ typedef struct UniffiForeignFutureResultVoid {
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
uint64_t uniffi_ironstorage_apple_fn_clone_mobileonboardingoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEONBOARDINGOPERATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEONBOARDINGOPERATION
void uniffi_ironstorage_apple_fn_free_mobileonboardingoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
void uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_discover(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_PROGRESS
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_PROGRESS
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_progress(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_setup(uint64_t ptr, RustBuffer branch, int8_t use_existing, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_ONBOARDING_OPERATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_ONBOARDING_OPERATION
uint64_t uniffi_ironstorage_apple_fn_func_mobile_onboarding_operation(RustBuffer server_url, RustBuffer account, RustBuffer repository_path, RustBuffer application_token, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL
@@ -258,6 +293,11 @@ RustBuffer uniffi_ironstorage_apple_fn_func_mobile_shell_fixture(RustBuffer stat
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_PRODUCT_NAME
RustBuffer uniffi_ironstorage_apple_fn_func_product_name(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_REPLACE_CONFIGURED_MOBILE_APPLICATION_TOKEN
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_REPLACE_CONFIGURED_MOBILE_APPLICATION_TOKEN
void uniffi_ironstorage_apple_fn_func_replace_configured_mobile_application_token(RustBuffer account, RustBuffer application_token, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_SET_SELECTED_MOBILE_TAB
@@ -523,6 +563,12 @@ void ffi_ironstorage_apple_rust_future_free_void(uint64_t handle
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
void ffi_ironstorage_apple_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL
@@ -541,12 +587,42 @@ uint16_t uniffi_ironstorage_apple_checksum_func_mobile_shell_fixture(void
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_PRODUCT_NAME
uint16_t uniffi_ironstorage_apple_checksum_func_product_name(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_REPLACE_CONFIGURED_MOBILE_APPLICATION_TOKEN
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_REPLACE_CONFIGURED_MOBILE_APPLICATION_TOKEN
uint16_t uniffi_ironstorage_apple_checksum_func_replace_configured_mobile_application_token(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB
uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_cancel(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_discover(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_PROGRESS
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_PROGRESS
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_progress(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_SETUP
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_SETUP
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_setup(void
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_UNIFFI_CONTRACT_VERSION

View File

@@ -167,6 +167,23 @@ private final class ShellViewController: UITableViewController {
private func apply(_ page: MobilePage) {
self.page = page
title = page.title
if page.state == .empty, shellTab == .home {
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Set Up",
style: .done,
target: self,
action: #selector(setupRequested)
)
} else if page.state == .ready, shellTab == .preferences {
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Update Token",
style: .plain,
target: self,
action: #selector(tokenUpdateRequested)
)
} else {
navigationItem.rightBarButtonItem = nil
}
refreshControl?.endRefreshing()
tableView.reloadData()
@@ -183,6 +200,14 @@ private final class ShellViewController: UITableViewController {
contentUnavailableConfiguration = configuration
}
@objc private func setupRequested() {
navigationController?.pushViewController(OnboardingViewController(), animated: true)
}
@objc private func tokenUpdateRequested() {
navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
}
private func stateImage(_ state: MobileShellState) -> String {
switch state {
case .loading: "hourglass"
@@ -193,3 +218,482 @@ private final class ShellViewController: UITableViewController {
}
}
}
@MainActor
private final class TokenUpdateViewController: UITableViewController {
private let accountField = UITextField()
private let tokenField = UITextField()
private var updateTask: Task<Void, Never>?
private var generation = 0
private var isWorking = false
init() {
super.init(style: .insetGrouped)
title = "Update Token"
navigationItem.largeTitleDisplayMode = .never
for field in [accountField, tokenField] {
field.borderStyle = .none
field.clearButtonMode = .whileEditing
field.autocorrectionType = .no
field.autocapitalizationType = .none
field.adjustsFontForContentSizeCategory = true
field.font = .preferredFont(forTextStyle: .body)
}
accountField.placeholder = "Account name"
tokenField.placeholder = "New application token"
tokenField.textContentType = .oneTimeCode
tokenField.isSecureTextEntry = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
updateTask?.cancel()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
generation += 1
updateTask?.cancel()
}
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 2 : 1
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
section == 0 ? "HTTPS Credential" : nil
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
section == 0
? "The existing token is never displayed. Updating replaces it only in protected system storage."
: nil
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
if indexPath.section == 1 {
cell.textLabel?.text = isWorking ? "Updating…" : "Replace Token"
cell.textLabel?.textColor = isWorking ? .secondaryLabel : view.tintColor
cell.textLabel?.textAlignment = .center
cell.isUserInteractionEnabled = !isWorking
return cell
}
let field = indexPath.row == 0 ? accountField : tokenField
field.translatesAutoresizingMaskIntoConstraints = false
field.isEnabled = !isWorking
cell.contentView.addSubview(field)
NSLayoutConstraint.activate([
field.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
field.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 10),
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -10)
])
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.section == 1, !isWorking else { return }
replaceToken()
}
private func replaceToken() {
generation += 1
let current = generation
isWorking = true
navigationItem.prompt = "Saving in protected system storage."
tableView.reloadData()
let account = accountField.text ?? ""
let token = tokenField.text ?? ""
updateTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try replaceConfiguredMobileApplicationToken(
account: account,
applicationToken: token
)
return Result<Void, OnboardingFailure>.success(())
} catch let error as MobileOnboardingFfiError {
return .failure(OnboardingFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard let self, current == generation else { return }
isWorking = false
navigationItem.prompt = nil
tableView.reloadData()
switch result {
case .success:
tokenField.text = nil
let alert = UIAlertController(
title: "Token Updated",
message: "The new application token is ready for HTTPS Git operations.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
present(alert, animated: true)
case let .failure(failure):
let alert = UIAlertController(
title: failure.title,
message: failure.detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
present(alert, animated: true)
}
}
}
}
@MainActor
private final class OnboardingViewController: UITableViewController {
private let serverField = UITextField()
private let accountField = UITextField()
private let repositoryField = UITextField()
private let tokenField = UITextField()
private var branches: [String] = []
private var selectedBranch = 0
private var operation: MobileOnboardingOperation?
private var workTask: Task<Void, Never>?
private var progressTask: Task<Void, Never>?
private var generation = 0
private var isWorking = false
init() {
super.init(style: .insetGrouped)
title = "Connect Store"
navigationItem.largeTitleDisplayMode = .never
configureFields()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
operation?.cancel()
workTask?.cancel()
progressTask?.cancel()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
cancelWork()
}
override func numberOfSections(in tableView: UITableView) -> Int {
branches.isEmpty ? 2 : 3
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch section {
case 0: 4
case 1: 1
default: 2
}
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
switch section {
case 0: "HTTPS Repository"
case 1: branches.isEmpty ? nil : "Remote"
default: "Local Clone"
}
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
section == 0 ? "The application token is stored in protected system storage, never in configuration." : nil
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
if indexPath.section == 0 {
return fieldCell(indexPath.row)
}
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
if branches.isEmpty {
cell.textLabel?.text = "Discover Branches"
cell.textLabel?.textColor = view.tintColor
cell.accessoryType = .disclosureIndicator
} else if indexPath.section == 1 {
cell.textLabel?.text = "Branch"
cell.detailTextLabel?.text = branches[selectedBranch]
cell.accessoryType = .disclosureIndicator
} else if indexPath.row == 0 {
cell.textLabel?.text = "Clone Password Store"
cell.textLabel?.textColor = view.tintColor
} else {
cell.textLabel?.text = "Use Existing Clone"
cell.textLabel?.textColor = .secondaryLabel
}
cell.isUserInteractionEnabled = !isWorking
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard !isWorking else { return }
if branches.isEmpty, indexPath.section == 1 {
discoverBranches()
} else if indexPath.section == 1 {
chooseBranch(from: tableView.cellForRow(at: indexPath))
} else if indexPath.section == 2 {
runSetup(useExisting: indexPath.row == 1)
}
}
private func configureFields() {
for field in [serverField, accountField, repositoryField, tokenField] {
field.borderStyle = .none
field.clearButtonMode = .whileEditing
field.autocorrectionType = .no
field.returnKeyType = .next
field.adjustsFontForContentSizeCategory = true
field.font = .preferredFont(forTextStyle: .body)
}
serverField.placeholder = "https://git.example.com"
serverField.textContentType = .URL
serverField.keyboardType = .URL
serverField.autocapitalizationType = .none
accountField.placeholder = "Account name"
accountField.autocapitalizationType = .none
repositoryField.placeholder = "owner/password-store"
repositoryField.autocapitalizationType = .none
tokenField.placeholder = "Application token"
tokenField.textContentType = .oneTimeCode
tokenField.isSecureTextEntry = true
}
private func fieldCell(_ row: Int) -> UITableViewCell {
let field = [serverField, accountField, repositoryField, tokenField][row]
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
field.translatesAutoresizingMaskIntoConstraints = false
cell.contentView.addSubview(field)
NSLayoutConstraint.activate([
field.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
field.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 10),
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -10)
])
return cell
}
private func makeOperation() throws -> MobileOnboardingOperation {
try mobileOnboardingOperation(
serverUrl: serverField.text ?? "",
account: accountField.text ?? "",
repositoryPath: repositoryField.text ?? "",
applicationToken: tokenField.text ?? ""
)
}
private func discoverBranches() {
do {
let operation = try makeOperation()
begin(operation, title: "Checking Repository") { operation in
let discovery = try operation.discover()
return .discovered(discovery)
}
} catch {
present(error)
}
}
private func runSetup(useExisting: Bool) {
guard branches.indices.contains(selectedBranch) else { return }
do {
let operation = try makeOperation()
let branch = branches[selectedBranch]
begin(operation, title: useExisting ? "Opening Store" : "Cloning Store") { operation in
let outcome = try operation.setup(branch: branch, useExisting: useExisting)
return .completed(outcome)
}
} catch {
present(error)
}
}
private func begin(
_ operation: MobileOnboardingOperation,
title: String,
work: @escaping @Sendable (MobileOnboardingOperation) throws -> WorkResult
) {
cancelWork()
generation += 1
let current = generation
self.operation = operation
isWorking = true
showProgress(title: title, detail: "Preparing secure storage.")
tableView.reloadData()
progressTask = Task { [weak self] in
while !Task.isCancelled {
do {
try await Task.sleep(for: .milliseconds(150))
} catch {
return
}
guard !Task.isCancelled, let self, current == generation else { return }
let progress = operation.progress()
showProgress(title: progress.title, detail: progress.detail)
}
}
workTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<WorkResult, OnboardingFailure>.success(try work(operation))
} catch let error as MobileOnboardingFfiError {
return .failure(OnboardingFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard let self, current == generation else { return }
finish(result)
}
}
private func finish(_ result: Result<WorkResult, OnboardingFailure>) {
progressTask?.cancel()
progressTask = nil
operation = nil
isWorking = false
navigationItem.titleView = nil
navigationItem.prompt = nil
switch result {
case let .success(.discovered(discovery)):
branches = discovery.branches
selectedBranch = min(Int(discovery.selectedBranch), max(branches.count - 1, 0))
tableView.reloadData()
case let .success(.completed(outcome)):
tokenField.text = nil
let alert = UIAlertController(title: outcome.title, message: outcome.detail, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
present(alert, animated: true)
case let .failure(failure):
tableView.reloadData()
present(failure)
}
}
private func chooseBranch(from source: UIView?) {
let sheet = UIAlertController(title: "Branch", message: nil, preferredStyle: .actionSheet)
for (index, branch) in branches.enumerated() {
sheet.addAction(UIAlertAction(title: branch, style: .default) { [weak self] _ in
self?.selectedBranch = index
self?.tableView.reloadSections(IndexSet(integer: 1), with: .automatic)
})
}
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
sheet.popoverPresentationController?.sourceView = source
sheet.popoverPresentationController?.sourceRect = source?.bounds ?? .zero
present(sheet, animated: true)
}
private func present(_ error: Error) {
if let error = error as? MobileOnboardingFfiError {
present(OnboardingFailure(error))
} else {
present(.unexpected)
}
}
private func present(_ failure: OnboardingFailure) {
let alert = UIAlertController(title: failure.title, message: failure.detail, preferredStyle: .alert)
if failure.kind == .existingClone, !branches.isEmpty {
alert.addAction(UIAlertAction(title: "Use Existing", style: .default) { [weak self] _ in
self?.runSetup(useExisting: true)
})
}
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
present(alert, animated: true)
}
private func showProgress(title: String, detail: String) {
let spinner = UIActivityIndicatorView(style: .medium)
spinner.startAnimating()
spinner.accessibilityLabel = "In progress"
let label = UILabel()
label.text = title
label.font = .preferredFont(forTextStyle: .headline)
label.adjustsFontForContentSizeCategory = true
let stack = UIStackView(arrangedSubviews: [spinner, label])
stack.spacing = 8
navigationItem.titleView = stack
navigationItem.prompt = detail
}
private func cancelWork() {
generation += 1
operation?.cancel()
operation = nil
workTask?.cancel()
workTask = nil
progressTask?.cancel()
progressTask = nil
navigationItem.prompt = nil
navigationItem.titleView = nil
isWorking = false
}
}
private enum WorkResult: Sendable {
case discovered(MobileOnboardingDiscovery)
case completed(MobileOnboardingOutcome)
}
private struct OnboardingFailure: Error, Sendable {
let kind: MobileOnboardingErrorKind?
let title: String
let detail: String
init(_ error: MobileOnboardingFfiError) {
switch error {
case let .Failed(kind, title, detail):
self.kind = kind
self.title = title
self.detail = detail
}
}
static let unexpected = OnboardingFailure(
kind: nil,
title: "Setup Failed",
detail: "IronStorage could not complete setup."
)
private init(kind: MobileOnboardingErrorKind?, title: String, detail: String) {
self.kind = kind
self.title = title
self.detail = detail
}
}

View File

@@ -3,11 +3,16 @@
//! Mechanical UniFFI exports for Apple presentation code.
use std::{error::Error, fmt};
use std::{error::Error, fmt, sync::Arc};
use ironstorage::{
config::ConfigError,
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
mobile_onboarding::{
self, MobileOnboardingError as StorageOnboardingError,
MobileOnboardingErrorKind as StorageOnboardingErrorKind,
MobileOnboardingPhase as StorageOnboardingPhase,
},
};
uniffi::setup_scaffolding!();
@@ -92,6 +97,146 @@ pub struct MobileShell {
pub pages: Vec<MobilePage>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileOnboardingPhase {
Validating,
Authenticating,
Receiving,
Integrating,
Finishing,
}
impl From<StorageOnboardingPhase> for MobileOnboardingPhase {
fn from(phase: StorageOnboardingPhase) -> Self {
match phase {
StorageOnboardingPhase::Validating => Self::Validating,
StorageOnboardingPhase::Authenticating => Self::Authenticating,
StorageOnboardingPhase::Receiving => Self::Receiving,
StorageOnboardingPhase::Integrating => Self::Integrating,
StorageOnboardingPhase::Finishing => Self::Finishing,
}
}
}
#[derive(Clone, uniffi::Record)]
pub struct MobileOnboardingProgress {
pub phase: MobileOnboardingPhase,
pub title: String,
pub detail: String,
}
#[derive(Clone, uniffi::Record)]
pub struct MobileOnboardingDiscovery {
pub branches: Vec<String>,
pub selected_branch: u32,
}
#[derive(Clone, uniffi::Record)]
pub struct MobileOnboardingOutcome {
pub title: String,
pub detail: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileOnboardingErrorKind {
InvalidInput,
UnsupportedRemote,
Authentication,
Repository,
ExistingClone,
Interrupted,
SecureStorage,
Configuration,
AlreadyConfigured,
}
impl From<StorageOnboardingErrorKind> for MobileOnboardingErrorKind {
fn from(kind: StorageOnboardingErrorKind) -> Self {
match kind {
StorageOnboardingErrorKind::InvalidInput => Self::InvalidInput,
StorageOnboardingErrorKind::UnsupportedRemote => Self::UnsupportedRemote,
StorageOnboardingErrorKind::Authentication => Self::Authentication,
StorageOnboardingErrorKind::Repository => Self::Repository,
StorageOnboardingErrorKind::ExistingClone => Self::ExistingClone,
StorageOnboardingErrorKind::Interrupted => Self::Interrupted,
StorageOnboardingErrorKind::SecureStorage => Self::SecureStorage,
StorageOnboardingErrorKind::Configuration => Self::Configuration,
StorageOnboardingErrorKind::AlreadyConfigured => Self::AlreadyConfigured,
}
}
}
#[derive(Debug, uniffi::Error)]
pub enum MobileOnboardingFfiError {
Failed {
kind: MobileOnboardingErrorKind,
title: String,
detail: String,
},
}
impl fmt::Display for MobileOnboardingFfiError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Failed { title, detail, .. } => write!(formatter, "{title}: {detail}"),
}
}
}
impl Error for MobileOnboardingFfiError {}
impl From<StorageOnboardingError> for MobileOnboardingFfiError {
fn from(error: StorageOnboardingError) -> Self {
Self::Failed {
kind: error.kind().into(),
title: error.title().to_owned(),
detail: error.detail().to_owned(),
}
}
}
#[derive(uniffi::Object)]
pub struct MobileOnboardingOperation {
operation: mobile_onboarding::MobileOnboardingOperation,
request: mobile_onboarding::MobileOnboardingRequest,
}
#[uniffi::export]
impl MobileOnboardingOperation {
pub fn progress(&self) -> MobileOnboardingProgress {
let progress = self.operation.progress();
MobileOnboardingProgress {
phase: progress.phase().into(),
title: progress.title().to_owned(),
detail: progress.detail().to_owned(),
}
}
pub fn discover(&self) -> Result<MobileOnboardingDiscovery, MobileOnboardingFfiError> {
let discovery = self.operation.discover(&self.request)?;
Ok(MobileOnboardingDiscovery {
branches: discovery.branches().to_vec(),
selected_branch: u32::try_from(discovery.selected_branch()).unwrap_or_default(),
})
}
pub fn setup(
&self,
branch: String,
use_existing: bool,
) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
let outcome = self.operation.setup(&self.request, &branch, use_existing)?;
Ok(MobileOnboardingOutcome {
title: outcome.title().to_owned(),
detail: outcome.detail().to_owned(),
})
}
pub fn cancel(&self) {
self.operation.cancel();
}
}
impl From<mobile::MobileShell> for MobileShell {
fn from(shell: mobile::MobileShell) -> Self {
Self {
@@ -156,9 +301,39 @@ pub fn set_selected_mobile_tab(tab: MobileTab) -> Result<(), MobilePreferenceErr
mobile::store_selected_tab(tab.into()).map_err(Into::into)
}
#[uniffi::export]
pub fn mobile_onboarding_operation(
server_url: String,
account: String,
repository_path: String,
application_token: String,
) -> Result<Arc<MobileOnboardingOperation>, MobileOnboardingFfiError> {
Ok(Arc::new(MobileOnboardingOperation {
operation: mobile_onboarding::MobileOnboardingOperation::default(),
request: mobile_onboarding::MobileOnboardingRequest::new(
server_url,
account,
repository_path,
application_token.into_bytes(),
)?,
}))
}
#[uniffi::export]
pub fn replace_configured_mobile_application_token(
account: String,
application_token: String,
) -> Result<(), MobileOnboardingFfiError> {
mobile_onboarding::replace_configured_application_token(
account,
application_token.into_bytes(),
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{MobileShellState, MobileTab};
use super::{MobileOnboardingErrorKind, MobileOnboardingFfiError, MobileShellState, MobileTab};
#[test]
fn bridge_reads_product_name_from_storage_crate() {
@@ -180,4 +355,23 @@ mod tests {
assert!(shell.pages.iter().all(|page| page.state == state));
}
}
#[test]
fn bridge_rejects_non_https_before_creating_an_operation() {
let error = match super::mobile_onboarding_operation(
"ssh://example.test".to_owned(),
"alice".to_owned(),
"team/passwords".to_owned(),
"DO-NOT-RENDER".to_owned(),
) {
Ok(_) => panic!("SSH must be rejected"),
Err(error) => error,
};
match error {
MobileOnboardingFfiError::Failed { kind, detail, .. } => {
assert_eq!(kind, MobileOnboardingErrorKind::UnsupportedRemote);
assert!(!detail.contains("DO-NOT-RENDER"));
}
}
}
}

View File

@@ -5,6 +5,7 @@ use std::{
env,
error::Error,
fmt, fs,
fs::OpenOptions,
io::Write,
path::{Component, Path, PathBuf},
time::Duration,
@@ -167,6 +168,73 @@ impl Config {
validate_config(self.source.clone(), document, raw)?.persist()
}
pub(crate) fn create_mobile_clone(
source: PathBuf,
vault: &Path,
key_material: &Path,
default_key: &str,
remote: &GitRemote,
) -> Result<Self, ConfigError> {
let base = source
.parent()
.ok_or(ConfigError::InvalidField { field: "source" })?;
let vault = vault
.strip_prefix(base)
.map_err(|_| ConfigError::InvalidField { field: "vault" })?;
let key_material =
key_material
.strip_prefix(base)
.map_err(|_| ConfigError::InvalidField {
field: "key_material",
})?;
let mut root = toml::Table::new();
root.insert(
"vault".to_owned(),
toml::Value::String(path_text(vault, "vault")?),
);
root.insert(
"default_key".to_owned(),
toml::Value::String(default_key.to_owned()),
);
root.insert(
"key_material".to_owned(),
toml::Value::String(path_text(key_material, "key_material")?),
);
let mut configured = toml::Table::new();
configured.insert(
"name".to_owned(),
toml::Value::String(remote.name().as_str().to_owned()),
);
configured.insert(
"url".to_owned(),
toml::Value::String(remote.url().to_string()),
);
configured.insert(
"server_id".to_owned(),
toml::Value::String(remote.server_id().as_str().to_owned()),
);
configured.insert(
"application_id".to_owned(),
toml::Value::String(remote.application_id().as_str().to_owned()),
);
let mut git = toml::Table::new();
git.insert(
"remotes".to_owned(),
toml::Value::Array(vec![toml::Value::Table(configured)]),
);
root.insert("git".to_owned(), toml::Value::Table(git));
let document = toml::Value::Table(root);
let raw = document
.clone()
.try_into::<RawConfig>()
.map_err(|_| ConfigError::Malformed {
path: source.clone(),
})?;
let config = validate_config(source, document, raw)?;
config.persist_new()?;
Ok(config)
}
/// Select a configured remote by name, or the configured default (first
/// remote) when no name was requested.
pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> {
@@ -313,6 +381,72 @@ impl Config {
path: self.source.clone(),
})
}
fn persist_new(&self) -> Result<(), ConfigError> {
let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
path: self.source.clone(),
})?;
let name = self.source.file_name().ok_or_else(|| ConfigError::Write {
path: self.source.clone(),
})?;
if self.source.exists() {
return Err(ConfigError::AlreadyConfigured {
path: self.source.clone(),
});
}
fs::create_dir_all(parent).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
set_private_directory(parent).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
let temporary = (0..128_u8)
.find_map(|attempt| {
let path = parent.join(format!(
".ironstorage-config-{}-{attempt}",
rand::random::<u64>()
));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
match options.open(&path) {
Ok(file) => Some(Ok((path, file))),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
Err(_) => Some(Err(ConfigError::Write {
path: self.source.clone(),
})),
}
})
.transpose()?
.ok_or_else(|| ConfigError::Write {
path: self.source.clone(),
})?;
let (temporary_path, mut temporary_file) = temporary;
let installed = temporary_file
.write_all(contents.as_bytes())
.and_then(|()| temporary_file.sync_all())
.and_then(|()| fs::hard_link(&temporary_path, parent.join(name)));
drop(temporary_file);
let _ = fs::remove_file(&temporary_path);
match installed {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
Err(ConfigError::AlreadyConfigured {
path: self.source.clone(),
})
}
Err(_) => Err(ConfigError::Write {
path: self.source.clone(),
}),
}
}
}
/// Deterministic path context for configuration loading.
@@ -432,6 +566,21 @@ pub struct GitRemote {
}
impl GitRemote {
pub fn https(
name: impl Into<String>,
url: impl Into<String>,
server_id: impl Into<String>,
application_id: impl Into<String>,
) -> Result<Self, ConfigError> {
let mut remotes = validate_remotes(vec![RawGitRemote {
name: name.into(),
url: url.into(),
server_id: server_id.into(),
application_id: application_id.into(),
}])?;
Ok(remotes.remove(0))
}
pub fn name(&self) -> &RemoteName {
&self.name
}
@@ -505,6 +654,7 @@ pub enum ConfigError {
VaultUnavailable { path: PathBuf },
VaultIsNotDirectory { path: PathBuf },
Write { path: PathBuf },
AlreadyConfigured { path: PathBuf },
KeyMaterialNotFound { path: PathBuf },
InvalidKeyMaterial { path: PathBuf },
DuplicateRemote { name: String },
@@ -575,6 +725,11 @@ impl fmt::Display for ConfigError {
path.display()
)
}
Self::AlreadyConfigured { path } => write!(
formatter,
"configuration already exists and was not replaced: {}",
path.display()
),
Self::KeyMaterialNotFound { path } => write!(
formatter,
"exported key material does not exist: {}",
@@ -760,6 +915,23 @@ fn resolve_required_path(
Ok(resolve_path(base, &value))
}
fn path_text(path: &Path, field: &'static str) -> Result<String, ConfigError> {
path.to_str()
.map(str::to_owned)
.ok_or(ConfigError::InvalidField { field })
}
fn set_private_directory(path: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
fn validate_key_identity(value: String) -> Result<KeyIdentity, ConfigError> {
let trimmed = value.trim();
if trimmed.is_empty()
@@ -1014,7 +1186,14 @@ fn native_config_directory() -> Option<PathBuf> {
.map(|home| home.join("Library/Application Support"))
}
#[cfg(all(unix, not(target_os = "macos")))]
#[cfg(target_os = "ios")]
fn native_config_directory() -> Option<PathBuf> {
env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join("Library/Application Support"))
}
#[cfg(all(unix, not(any(target_os = "ios", target_os = "macos"))))]
fn native_config_directory() -> Option<PathBuf> {
match env::var_os("XDG_CONFIG_HOME") {
Some(path) if !path.is_empty() && Path::new(&path).is_absolute() => {
@@ -1031,3 +1210,53 @@ fn native_config_directory() -> Option<PathBuf> {
fn native_config_directory() -> Option<PathBuf> {
None
}
#[cfg(test)]
mod tests {
use std::fs;
use super::{Config, ConfigError, GitRemote};
#[test]
fn mobile_clone_configuration_is_secret_free_and_never_replaced() {
let temporary = tempfile::tempdir().expect("temporary directory");
let original = temporary.path().join("original");
let relocated = temporary.path().join("relocated");
let vault = original.join("vault");
let keys = original.join("keys");
let source = original.join("config.toml");
fs::create_dir_all(&vault).expect("vault");
fs::create_dir(&keys).expect("keys");
let remote = GitRemote::https(
"origin",
"https://example.test/team/passwords.git",
"server-example",
"repository-example",
)
.expect("remote");
Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote)
.expect("create config");
let contents = fs::read_to_string(&source).expect("read config");
assert!(!contents.contains("token ="));
assert!(!contents.contains("password ="));
assert!(!contents.contains(&original.to_string_lossy().into_owned()));
assert!(contents.contains("vault = \"vault\""));
assert!(contents.contains("key_material = \"keys\""));
assert!(contents.contains("https://example.test/team/passwords.git"));
assert_eq!(
Config::create_mobile_clone(source.clone(), &vault, &keys, "BOB", &remote)
.expect_err("must not replace existing config"),
ConfigError::AlreadyConfigured {
path: source.clone()
}
);
fs::rename(&original, &relocated).expect("relocate app container");
assert_eq!(
Config::load(Some(&relocated.join("config.toml")))
.expect("reload config")
.default_key()
.as_str(),
"ALICE"
);
}
}

View File

@@ -788,18 +788,91 @@ impl FetchOutcome {
}
impl GitRepository {
pub fn discover_remote_branches(
parent: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
control: &GitOperationControl,
) -> Result<Vec<String>, GitError> {
Self::discover_remote_branches_with_transport(
parent,
identity,
configured,
credentials,
&EmbeddedFetchTransport,
control,
)
}
pub fn discover_remote_branches_with_transport(
parent: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<Vec<String>, GitError> {
control.report(GitProgressPhase::Validating)?;
validate_https_remote(configured.url().as_str())?;
ensure_clone_parent(parent)?;
let temporary = private_temporary_directory(parent, "probe")?;
let result = (|| {
let store = Repository::open(&temporary).map_err(invalid)?;
gix::ThreadSafeRepository::init_opts(
store.root_path(),
gix::create::Kind::WithWorktree,
gix::create::Options::default(),
isolated_options(),
)
.map_err(invalid)?;
let mut repository = Self::open(&store, identity)?;
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
repository.fetch_with_transport_controlled(
configured,
credentials,
transport,
control,
)?;
repository.remote_branch_names(configured.name().as_str())
})();
let _ = fs::remove_dir_all(&temporary);
result
}
pub fn clone_into(
destination: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
) -> Result<Self, GitError> {
Self::clone_into_with_transport(
Self::clone_into_with_transport_controlled(
destination,
identity,
configured,
None,
credentials,
&EmbeddedFetchTransport,
&GitOperationControl::default(),
)
}
pub fn clone_into_controlled(
destination: &Path,
identity: GitIdentity,
configured: &GitRemote,
branch: &str,
credentials: &impl GitCredentialProvider,
control: &GitOperationControl,
) -> Result<Self, GitError> {
Self::clone_into_with_transport_controlled(
destination,
identity,
configured,
Some(branch),
credentials,
&EmbeddedFetchTransport,
control,
)
}
@@ -810,17 +883,35 @@ impl GitRepository {
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
) -> Result<Self, GitError> {
Self::clone_into_with_transport_controlled(
destination,
identity,
configured,
None,
credentials,
transport,
&GitOperationControl::default(),
)
}
pub fn clone_into_with_transport_controlled(
destination: &Path,
identity: GitIdentity,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<Self, GitError> {
control.report(GitProgressPhase::Validating)?;
validate_https_remote(configured.url().as_str())?;
if let Some(branch) = branch {
validate_remote_name(branch)?;
}
let parent = destination.parent().ok_or_else(|| GitError::InvalidPath {
path: destination.to_owned(),
})?;
let metadata =
fs::symlink_metadata(parent).map_err(|_| io("inspect clone parent", parent))?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(GitError::UnsafeWorktreeObject {
path: parent.to_owned(),
});
}
ensure_clone_parent(parent)?;
if let Ok(metadata) = fs::symlink_metadata(destination) {
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(GitError::UnsafeWorktreeObject {
@@ -835,18 +926,7 @@ impl GitRepository {
return Err(GitError::DirtyWorktree);
}
}
let temporary = (0..128_u8)
.find_map(|attempt| {
let name = format!(".ironstorage-clone-{}-{attempt}", rand::random::<u64>());
let candidate = parent.join(name);
match fs::create_dir(&candidate) {
Ok(()) => Some(Ok(candidate)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
Err(_) => Some(Err(io("create clone directory", parent))),
}
})
.transpose()?
.ok_or_else(|| io("create clone directory", parent))?;
let temporary = private_temporary_directory(parent, "clone")?;
let cloned = (|| {
let store = Repository::open(&temporary).map_err(invalid)?;
gix::ThreadSafeRepository::init_opts(
@@ -858,17 +938,23 @@ impl GitRepository {
.map_err(invalid)?;
let mut repository = Self::open(&store, identity.clone())?;
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
repository.pull_with_transport(configured, None, credentials, transport)?;
Ok(repository)
repository.pull_with_transport_controlled(
configured,
branch,
credentials,
transport,
control,
)?;
control.report(GitProgressPhase::Refreshing)?;
Ok(())
})();
if let Err(error) = cloned {
let _ = fs::remove_dir_all(&temporary);
return Err(error);
}
drop(cloned);
if destination.exists() {
fs::remove_dir(destination)
.map_err(|_| io("prepare clone destination", destination))?;
if destination.exists() && fs::remove_dir(destination).is_err() {
let _ = fs::remove_dir_all(&temporary);
return Err(io("prepare clone destination", destination));
}
if fs::rename(&temporary, destination).is_err() {
let _ = fs::remove_dir_all(&temporary);
@@ -880,6 +966,42 @@ impl GitRepository {
Self::open_at(destination.to_owned(), identity)
}
fn remote_branch_names(&self, remote: &str) -> Result<Vec<String>, GitError> {
validate_remote_name(remote)?;
let prefix = format!("refs/remotes/{remote}/");
let mut branches = self
.repository
.references()
.map_err(invalid)?
.remote_branches()
.map_err(invalid)?
.filter_map(|reference| {
let reference = match reference {
Ok(reference) => reference,
Err(error) => return Some(Err(invalid(error))),
};
let name = match reference.name().as_bstr().to_str() {
Ok(name) => name,
Err(error) => return Some(Err(invalid(error))),
};
name.strip_prefix(&prefix)
.filter(|name| *name != "HEAD")
.map(|name| {
validate_remote_name(name)?;
Ok(name.to_owned())
})
})
.collect::<Result<Vec<_>, GitError>>()?;
branches.sort();
branches.dedup();
if branches.is_empty() {
return Err(GitError::InvalidRepository(
"the remote has no branches".to_owned(),
));
}
Ok(branches)
}
pub fn init(store: &Repository, identity: GitIdentity) -> Result<Self, GitError> {
let root = store.root_path().to_owned();
if root.join(".git").exists() {
@@ -957,12 +1079,13 @@ impl GitRepository {
}
fn open_at(root: PathBuf, identity: GitIdentity) -> Result<Self, GitError> {
let repository =
let mut repository =
gix::open_opts(&root, isolated_options()).map_err(|_| GitError::NotRepository)?;
if repository.is_bare() {
return Err(GitError::BareRepository);
}
validate_local_config_security(&load_local_config(&repository)?)?;
apply_in_memory_identity(&mut repository, &identity)?;
Ok(Self {
root,
repository,
@@ -1204,18 +1327,18 @@ impl GitRepository {
return GitError::Cancelled;
}
let text = error.to_string();
let lower = text.to_ascii_lowercase();
if text.contains("401")
|| text.contains("403")
|| text.to_ascii_lowercase().contains("authentication")
|| lower.contains("authentication")
|| (lower.contains("credential") && lower.contains("not accepted"))
{
GitError::AuthenticationFailed
} else if text.to_ascii_lowercase().contains("certificate")
|| text.to_ascii_lowercase().contains("tls")
{
} else if lower.contains("certificate") || lower.contains("tls") {
GitError::TlsFailed
} else if text.to_ascii_lowercase().contains("network")
|| text.to_ascii_lowercase().contains("connect")
|| text.to_ascii_lowercase().contains("dns")
} else if lower.contains("network")
|| lower.contains("connect")
|| lower.contains("dns")
{
GitError::NetworkUnavailable
} else {
@@ -1878,6 +2001,7 @@ impl GitRepository {
validate_local_config_security(&config)?;
write_local_config(&self.repository, &config)?;
self.repository = gix::open_opts(&self.root, isolated_options()).map_err(invalid)?;
apply_in_memory_identity(&mut self.repository, &self.identity)?;
Ok(())
}
@@ -3032,6 +3156,43 @@ fn validate_remote_name(name: &str) -> Result<(), GitError> {
Ok(())
}
fn ensure_clone_parent(parent: &Path) -> Result<(), GitError> {
let metadata = fs::symlink_metadata(parent).map_err(|_| io("inspect clone parent", parent))?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(GitError::UnsafeWorktreeObject {
path: parent.to_owned(),
});
}
Ok(())
}
fn private_temporary_directory(parent: &Path, purpose: &str) -> Result<PathBuf, GitError> {
(0..128_u8)
.find_map(|attempt| {
let name = format!(".ironstorage-{purpose}-{}-{attempt}", rand::random::<u64>());
let candidate = parent.join(name);
match fs::create_dir(&candidate) {
Ok(()) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
if fs::set_permissions(&candidate, fs::Permissions::from_mode(0o700))
.is_err()
{
let _ = fs::remove_dir(&candidate);
return Some(Err(io("secure private Git directory", parent)));
}
}
Some(Ok(candidate))
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
Err(_) => Some(Err(io("create private Git directory", parent))),
}
})
.transpose()?
.ok_or_else(|| io("create private Git directory", parent))
}
fn validate_https_remote(value: &str) -> Result<url::Url, GitError> {
let parsed = url::Url::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?;
if parsed.scheme() != "https"
@@ -3215,9 +3376,45 @@ fn invalid(error: impl fmt::Display) -> GitError {
GitError::InvalidRepository(error.to_string())
}
fn apply_in_memory_identity(
repository: &mut gix::Repository,
identity: &GitIdentity,
) -> Result<(), GitError> {
let mut config = repository.config_snapshot_mut();
config
.set_raw_value("user.name", identity.name())
.map_err(invalid)?;
config
.set_raw_value("user.email", identity.email())
.map_err(invalid)?;
drop(config);
if repository.committer().is_none() {
return Err(GitError::InvalidIdentity);
}
Ok(())
}
fn io(operation: &'static str, path: &Path) -> GitError {
GitError::Io {
operation,
path: path.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::{GitIdentity, GitRepository};
use crate::repository::Repository;
#[test]
fn embedded_identity_survives_local_config_updates() {
let temporary = tempfile::tempdir().expect("temporary repository");
let store = Repository::open(temporary.path()).expect("store");
let mut repository =
GitRepository::init(&store, GitIdentity::ironstorage()).expect("initialize Git");
repository
.add_remote("origin", "https://example.test/password-store.git")
.expect("add remote");
assert!(repository.repository.committer().is_some());
}
}

View File

@@ -15,6 +15,7 @@ pub mod generate;
pub mod git;
pub mod kdbx;
pub mod mobile;
pub mod mobile_onboarding;
pub mod mutation;
pub mod otp;
pub mod presentation;

View File

@@ -0,0 +1,665 @@
//! Storage-owned first-run setup for the native iPhone application.
use std::{
error::Error,
fmt, fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use sha2::{Digest as _, Sha256};
use url::Url;
use crate::{
config::{Config, ConfigError, ConfigLoader, GitRemote},
git::{
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
GitProgressPhase, GitRepository,
},
repository::{DirectoryPath, Repository, SecretBytes},
secret_store::{
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
},
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileOnboardingPhase {
Validating,
Authenticating,
Receiving,
Integrating,
Finishing,
}
impl From<GitProgressPhase> for MobileOnboardingPhase {
fn from(phase: GitProgressPhase) -> Self {
match phase {
GitProgressPhase::Validating => Self::Validating,
GitProgressPhase::Authenticating => Self::Authenticating,
GitProgressPhase::Receiving => Self::Receiving,
GitProgressPhase::Integrating | GitProgressPhase::Sending => Self::Integrating,
GitProgressPhase::Refreshing => Self::Finishing,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileOnboardingProgress {
phase: MobileOnboardingPhase,
title: String,
detail: String,
}
impl MobileOnboardingProgress {
pub fn phase(&self) -> MobileOnboardingPhase {
self.phase
}
pub fn title(&self) -> &str {
&self.title
}
pub fn detail(&self) -> &str {
&self.detail
}
}
pub struct MobileOnboardingOperation {
control: GitOperationControl,
phase: Arc<Mutex<MobileOnboardingPhase>>,
}
impl Default for MobileOnboardingOperation {
fn default() -> Self {
let phase = Arc::new(Mutex::new(MobileOnboardingPhase::Validating));
let observed = Arc::clone(&phase);
Self {
control: GitOperationControl::new(move |phase| {
if let Ok(mut current) = observed.lock() {
*current = phase.into();
}
}),
phase,
}
}
}
impl MobileOnboardingOperation {
pub fn cancel(&self) {
self.control.cancel();
}
pub fn progress(&self) -> MobileOnboardingProgress {
progress_copy(
self.phase
.lock()
.map_or(MobileOnboardingPhase::Validating, |phase| *phase),
)
}
pub fn discover(
&self,
request: &MobileOnboardingRequest,
) -> Result<MobileOnboardingDiscovery, MobileOnboardingError> {
let paths = MobileOnboardingPaths::system()?;
paths.prepare_root()?;
let branches = GitRepository::discover_remote_branches(
&paths.root,
GitIdentity::ironstorage(),
&request.remote,
request,
&self.control,
)
.map_err(MobileOnboardingError::from_git)?;
let selected_branch = branches
.iter()
.position(|branch| branch == "main")
.unwrap_or_default();
Ok(MobileOnboardingDiscovery {
branches,
selected_branch,
})
}
pub fn setup(
&self,
request: &MobileOnboardingRequest,
branch: &str,
use_existing: bool,
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
match Config::load(None) {
Ok(_) => return Err(MobileOnboardingError::already_configured()),
Err(ConfigError::NotFound { .. }) => {}
Err(error) => return Err(MobileOnboardingError::from_config(error)),
}
let paths = MobileOnboardingPaths::system()?;
paths.prepare_root()?;
let repository = if use_existing {
let repository = Repository::open(&paths.vault)
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
let git = GitRepository::open(&repository, GitIdentity::ironstorage())
.map_err(MobileOnboardingError::from_git)?;
let actual = git
.remote_url(request.remote.name().as_str())
.map_err(MobileOnboardingError::from_git)?;
if actual != request.remote.url().as_str()
|| git.current_branch().ok().as_deref() != Some(branch)
{
return Err(MobileOnboardingError::different_existing_clone());
}
repository
} else {
if paths
.vault
.read_dir()
.ok()
.is_some_and(|mut entries| entries.next().is_some())
{
return Err(MobileOnboardingError::existing_clone());
}
GitRepository::clone_into_controlled(
&paths.vault,
GitIdentity::ironstorage(),
&request.remote,
branch,
request,
&self.control,
)
.map_err(MobileOnboardingError::from_git)?;
Repository::open(&paths.vault)
.map_err(|_| MobileOnboardingError::inaccessible_repository())?
};
self.control
.report(GitProgressPhase::Refreshing)
.map_err(MobileOnboardingError::from_git)?;
fs::create_dir_all(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?;
set_private_directory(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?;
let default_key = default_key(&repository)?;
store_application_token(request)?;
Config::create_mobile_clone(
paths.config,
&paths.vault,
&paths.keys,
&default_key,
&request.remote,
)
.map_err(MobileOnboardingError::from_config)?;
Ok(MobileOnboardingOutcome {
title: "Password Store Ready".to_owned(),
detail: format!("The {} branch is available locally.", branch),
})
}
}
pub struct MobileOnboardingRequest {
account: String,
token: SecretBytes,
remote: GitRemote,
}
impl MobileOnboardingRequest {
pub fn new(
server_url: String,
account: String,
repository_path: String,
token: Vec<u8>,
) -> Result<Self, MobileOnboardingError> {
let token = SecretBytes::new(token);
GitCredential::new(account.clone(), token.expose().to_vec())
.map_err(|_| MobileOnboardingError::invalid("Account or application token"))?;
let remote_url = repository_url(&server_url, &repository_path)?;
let server_id = stable_identifier("server", remote_origin(&remote_url).as_bytes());
let application_id = stable_identifier("repository", remote_url.as_str().as_bytes());
let remote = GitRemote::https("origin", remote_url.to_string(), server_id, application_id)
.map_err(MobileOnboardingError::from_config)?;
Ok(Self {
account,
token,
remote,
})
}
pub fn remote(&self) -> &GitRemote {
&self.remote
}
}
pub fn replace_configured_application_token(
account: String,
token: Vec<u8>,
) -> Result<(), MobileOnboardingError> {
let credential = GitCredential::new(account, token)
.map_err(|_| MobileOnboardingError::invalid("Account or application token"))?;
let config = Config::load(None).map_err(MobileOnboardingError::for_token_update)?;
let remote = config
.git_remotes()
.iter()
.find(|remote| remote.name().as_str() == "origin")
.ok_or_else(MobileOnboardingError::token_configuration)?;
store_git_credential(remote, &credential)
}
impl fmt::Debug for MobileOnboardingRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("MobileOnboardingRequest")
.field("account", &self.account)
.field("remote", &self.remote)
.field("token", &"[REDACTED]")
.finish()
}
}
impl GitCredentialProvider for MobileOnboardingRequest {
fn credential(
&self,
server: &crate::config::ServerId,
application: &crate::config::ApplicationId,
) -> Result<GitCredential, GitError> {
if server != self.remote.server_id() || application != self.remote.application_id() {
return Err(GitError::CredentialsUnavailable);
}
GitCredential::new(self.account.clone(), self.token.expose().to_vec())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileOnboardingDiscovery {
branches: Vec<String>,
selected_branch: usize,
}
impl MobileOnboardingDiscovery {
pub fn branches(&self) -> &[String] {
&self.branches
}
pub fn selected_branch(&self) -> usize {
self.selected_branch
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileOnboardingOutcome {
title: String,
detail: String,
}
impl MobileOnboardingOutcome {
pub fn title(&self) -> &str {
&self.title
}
pub fn detail(&self) -> &str {
&self.detail
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileOnboardingErrorKind {
InvalidInput,
UnsupportedRemote,
Authentication,
Repository,
ExistingClone,
Interrupted,
SecureStorage,
Configuration,
AlreadyConfigured,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileOnboardingError {
kind: MobileOnboardingErrorKind,
title: &'static str,
detail: String,
}
impl MobileOnboardingError {
pub fn kind(&self) -> MobileOnboardingErrorKind {
self.kind
}
pub fn title(&self) -> &str {
self.title
}
pub fn detail(&self) -> &str {
&self.detail
}
fn invalid(field: &'static str) -> Self {
Self {
kind: MobileOnboardingErrorKind::InvalidInput,
title: "Check Setup Details",
detail: format!("{field} is invalid."),
}
}
fn existing_clone() -> Self {
Self {
kind: MobileOnboardingErrorKind::ExistingClone,
title: "Local Clone Already Exists",
detail: "Choose Use Existing to open it without replacing any files.".to_owned(),
}
}
fn different_existing_clone() -> Self {
Self {
kind: MobileOnboardingErrorKind::ExistingClone,
title: "Different Local Clone",
detail: "The existing clone uses a different remote or branch and was not changed."
.to_owned(),
}
}
fn inaccessible_repository() -> Self {
Self {
kind: MobileOnboardingErrorKind::Repository,
title: "Password Store Is Inaccessible",
detail: "The local password-store repository could not be opened.".to_owned(),
}
}
fn configuration() -> Self {
Self {
kind: MobileOnboardingErrorKind::Configuration,
title: "Setup Was Not Saved",
detail: "IronStorage could not save its local configuration.".to_owned(),
}
}
fn token_configuration() -> Self {
Self {
kind: MobileOnboardingErrorKind::Configuration,
title: "Token Was Not Updated",
detail: "Set up the password store before replacing its application token.".to_owned(),
}
}
fn for_token_update(_error: ConfigError) -> Self {
Self::token_configuration()
}
fn already_configured() -> Self {
Self {
kind: MobileOnboardingErrorKind::AlreadyConfigured,
title: "IronStorage Is Already Configured",
detail: "The existing configuration and local clone were not replaced.".to_owned(),
}
}
fn from_git(error: GitError) -> Self {
match error {
GitError::ForbiddenRemoteUrl => Self {
kind: MobileOnboardingErrorKind::UnsupportedRemote,
title: "HTTPS Required",
detail: "Use a credential-free HTTPS server URL. SSH and helper transports are not supported."
.to_owned(),
},
GitError::AuthenticationFailed
| GitError::CredentialsUnavailable
| GitError::CredentialAccessDenied
| GitError::CredentialCancelled => Self {
kind: MobileOnboardingErrorKind::Authentication,
title: "Authentication Failed",
detail: "Check the account and application token, then try again.".to_owned(),
},
GitError::Cancelled => Self {
kind: MobileOnboardingErrorKind::Interrupted,
title: "Setup Interrupted",
detail: "No existing local clone or configuration was replaced.".to_owned(),
},
GitError::DirtyWorktree => Self::existing_clone(),
GitError::NetworkUnavailable | GitError::TlsFailed => Self {
kind: MobileOnboardingErrorKind::Repository,
title: "Server Unavailable",
detail: "Check the HTTPS server and network connection, then try again.".to_owned(),
},
_ => Self {
kind: MobileOnboardingErrorKind::Repository,
title: "Repository Could Not Be Opened",
detail: "Check the repository path and selected branch, then try again.".to_owned(),
},
}
}
fn from_config(error: ConfigError) -> Self {
match error {
ConfigError::InvalidRemoteUrl { .. } => Self::from_git(GitError::ForbiddenRemoteUrl),
ConfigError::AlreadyConfigured { .. } => Self::already_configured(),
_ => Self::configuration(),
}
}
fn from_secret(error: SecretStoreError) -> Self {
let detail = match error {
SecretStoreError::Denied => "Access to secure token storage was denied.",
SecretStoreError::Cancelled => "Secure token storage was cancelled.",
_ => "Secure application-token storage is unavailable.",
};
Self {
kind: MobileOnboardingErrorKind::SecureStorage,
title: "Token Was Not Saved",
detail: detail.to_owned(),
}
}
}
impl fmt::Display for MobileOnboardingError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.title, self.detail)
}
}
impl Error for MobileOnboardingError {}
struct MobileOnboardingPaths {
root: PathBuf,
config: PathBuf,
vault: PathBuf,
keys: PathBuf,
}
impl MobileOnboardingPaths {
fn system() -> Result<Self, MobileOnboardingError> {
let config = ConfigLoader::system()
.map_err(MobileOnboardingError::from_config)?
.default_path();
let root = config
.parent()
.map(Path::to_owned)
.ok_or_else(MobileOnboardingError::configuration)?;
Ok(Self {
vault: root.join("vault"),
keys: root.join("keys"),
root,
config,
})
}
fn prepare_root(&self) -> Result<(), MobileOnboardingError> {
fs::create_dir_all(&self.root).map_err(|_| MobileOnboardingError::configuration())?;
set_private_directory(&self.root).map_err(|_| MobileOnboardingError::configuration())
}
}
fn repository_url(server_url: &str, repository_path: &str) -> Result<Url, MobileOnboardingError> {
let mut server =
Url::parse(server_url).map_err(|_| MobileOnboardingError::invalid("HTTPS server URL"))?;
if server.scheme() != "https"
|| server.host_str().is_none()
|| !server.username().is_empty()
|| server.password().is_some()
|| server.query().is_some()
|| server.fragment().is_some()
{
return Err(MobileOnboardingError::from_git(
GitError::ForbiddenRemoteUrl,
));
}
let parts = repository_path.split('/').collect::<Vec<_>>();
if parts.len() < 2
|| parts.iter().any(|part| {
part.is_empty()
|| matches!(*part, "." | "..")
|| !part
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
})
{
return Err(MobileOnboardingError::invalid("Repository path"));
}
let mut path = server.path().trim_end_matches('/').to_owned();
path.push('/');
path.push_str(&parts.join("/"));
if !path.ends_with(".git") {
path.push_str(".git");
}
server.set_path(&path);
Ok(server)
}
fn remote_origin(url: &Url) -> String {
format!(
"{}://{}:{}",
url.scheme(),
url.host_str().unwrap_or_default(),
url.port_or_known_default().unwrap_or(443)
)
}
fn stable_identifier(prefix: &str, value: &[u8]) -> String {
let digest = Sha256::digest(value);
format!("{prefix}-{}", hex_prefix(&digest, 16))
}
fn hex_prefix(bytes: &[u8], length: usize) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
bytes
.iter()
.flat_map(|byte| [HEX[(byte >> 4) as usize], HEX[(byte & 0x0f) as usize]])
.take(length)
.map(char::from)
.collect()
}
fn default_key(repository: &Repository) -> Result<String, MobileOnboardingError> {
let root =
DirectoryPath::parse("").map_err(|_| MobileOnboardingError::inaccessible_repository())?;
let contents = repository
.read_policy_file(&root, false)
.map_err(|_| MobileOnboardingError::inaccessible_repository())?
.ok_or_else(MobileOnboardingError::inaccessible_repository)?;
let text = std::str::from_utf8(&contents)
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
text.lines()
.map(|line| line.split('#').next().unwrap_or_default().trim())
.find(|identity| !identity.is_empty())
.filter(|identity| identity.len() <= 512 && !identity.chars().any(char::is_control))
.map(str::to_owned)
.ok_or_else(MobileOnboardingError::inaccessible_repository)
}
fn store_application_token(request: &MobileOnboardingRequest) -> Result<(), MobileOnboardingError> {
let credential = request
.credential(request.remote.server_id(), request.remote.application_id())
.map_err(MobileOnboardingError::from_git)?;
store_git_credential(&request.remote, &credential)
}
fn store_git_credential(
remote: &GitRemote,
credential: &GitCredential,
) -> Result<(), MobileOnboardingError> {
let store = NativeSecretStore::system(
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
)
.map_err(MobileOnboardingError::from_secret)?;
store.unlock().map_err(MobileOnboardingError::from_secret)?;
store
.store_https_git_credential(
remote.server_id(),
remote.application_id(),
credential.username(),
SecretBytes::new(credential.password().to_vec()),
)
.map_err(MobileOnboardingError::from_secret)?;
store.lock().map_err(MobileOnboardingError::from_secret)
}
fn progress_copy(phase: MobileOnboardingPhase) -> MobileOnboardingProgress {
let (title, detail) = match phase {
MobileOnboardingPhase::Validating => ("Checking Setup", "Validating the HTTPS repository."),
MobileOnboardingPhase::Authenticating => (
"Authenticating",
"Using the application token from protected memory.",
),
MobileOnboardingPhase::Receiving => {
("Downloading Store", "Receiving Git objects securely.")
}
MobileOnboardingPhase::Integrating => ("Opening Store", "Preparing the selected branch."),
MobileOnboardingPhase::Finishing => ("Finishing Setup", "Saving protected local state."),
};
MobileOnboardingProgress {
phase,
title: title.to_owned(),
detail: detail.to_owned(),
}
}
fn set_private_directory(path: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
MobileOnboardingErrorKind, MobileOnboardingOperation, MobileOnboardingPhase,
MobileOnboardingRequest,
};
#[test]
fn request_is_https_only_stable_and_secret_safe() {
let request = MobileOnboardingRequest::new(
"https://example.test/gitea".to_owned(),
"alice".to_owned(),
"team/passwords".to_owned(),
b"DO-NOT-RENDER".to_vec(),
)
.expect("valid request");
assert_eq!(
request.remote().url().as_str(),
"https://example.test/gitea/team/passwords.git"
);
assert!(!format!("{request:?}").contains("DO-NOT-RENDER"));
let error = MobileOnboardingRequest::new(
"ssh://example.test".to_owned(),
"alice".to_owned(),
"team/passwords".to_owned(),
b"DO-NOT-RENDER".to_vec(),
)
.expect_err("SSH must be rejected");
assert_eq!(error.kind(), MobileOnboardingErrorKind::UnsupportedRemote);
assert!(!error.to_string().contains("DO-NOT-RENDER"));
}
#[test]
fn operation_cancellation_is_visible_before_transport_work() {
let operation = MobileOnboardingOperation::default();
assert_eq!(
operation.progress().phase(),
MobileOnboardingPhase::Validating
);
operation.cancel();
assert!(operation.control.is_cancelled());
}
}

View File

@@ -474,6 +474,47 @@ impl<B: SecretStoreBackend> SecretStore<B> {
Ok(())
}
/// Create or replace one HTTPS Git account/token record without exposing
/// the previously stored account or token to a frontend.
pub fn store_https_git_credential(
&self,
server: &ServerId,
application: &ApplicationId,
account: impl Into<String>,
value: SecretBytes,
) -> Result<(), SecretStoreError> {
validate_secret(&value)?;
let reference =
SecretReference::https_git_credential(server.as_str(), application.as_str(), account)?;
let locator = reference.locator();
let encoded = encode_record(&reference, &value)?;
let mut state = self.unlocked_state()?;
match self
.backend
.retrieve(&locator, self.protections.for_reference(&reference))
{
Ok(existing) => {
let existing = decode_record(existing)?;
if existing.reference.locator() != locator {
return Err(SecretStoreError::Corrupted);
}
self.backend.replace(
&locator,
self.protections.for_reference(&reference),
encoded.expose(),
)?;
}
Err(SecretStoreError::Missing) => self.backend.create(
&locator,
self.protections.for_reference(&reference),
encoded.expose(),
)?,
Err(error) => return Err(error),
}
self.cache_insert(&mut state, locator, encoded);
Ok(())
}
pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> {
let locator = reference.locator();
let mut state = self.unlocked_state()?;

View File

@@ -6,7 +6,7 @@ use std::{
error::Error,
fs,
io::Cursor,
path::Path,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
@@ -204,6 +204,22 @@ impl GitFetchTransport for CloningFetch {
}
}
struct OccupyingFetch(PathBuf);
impl GitFetchTransport for OccupyingFetch {
fn fetch(
&self,
repository: &GitRepository,
configured: &GitRemote,
credential: &GitCredential,
) -> Result<bool, GitError> {
let fetched = CloningFetch.fetch(repository, configured, credential)?;
fs::write(self.0.join("keep"), b"concurrent contents")
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
Ok(fetched)
}
}
struct NoopFetch;
impl GitFetchTransport for NoopFetch {
@@ -547,6 +563,102 @@ fn clone_uses_a_private_directory_and_injected_fetch_transport() -> TestResult {
Ok(())
}
#[test]
fn branch_discovery_and_controlled_clone_stay_inside_rust() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
let remote = &config.git_remotes()[0];
let control = GitOperationControl::default();
let branches = GitRepository::discover_remote_branches_with_transport(
temporary.path(),
identity(),
remote,
&Credentials,
&CloningFetch,
&control,
)?;
assert_eq!(branches, ["main"]);
assert!(
!temporary
.path()
.read_dir()?
.filter_map(Result::ok)
.any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(".ironstorage-probe-")
})
);
let destination = temporary.path().join("selected-branch");
let cloned = GitRepository::clone_into_with_transport_controlled(
&destination,
identity(),
remote,
Some(&branches[0]),
&Credentials,
&CloningFetch,
&control,
)?;
assert_eq!(cloned.current_branch()?, "main");
assert_eq!(fs::read(destination.join(".gpg-id"))?, b"ALICE\n");
Ok(())
}
#[test]
fn cancelled_clone_never_touches_an_existing_destination() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
let destination = temporary.path().join("existing");
fs::create_dir(&destination)?;
fs::write(destination.join("keep"), b"unchanged")?;
let control = GitOperationControl::default();
control.cancel();
let result = GitRepository::clone_into_with_transport_controlled(
&destination,
identity(),
&config.git_remotes()[0],
Some("main"),
&Credentials,
&CloningFetch,
&control,
);
assert!(matches!(result, Err(GitError::Cancelled)));
assert_eq!(fs::read(destination.join("keep"))?, b"unchanged");
Ok(())
}
#[test]
fn clone_race_preserves_the_destination_and_removes_private_work() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
let destination = temporary.path().join("concurrent");
fs::create_dir(&destination)?;
let result = GitRepository::clone_into_with_transport(
&destination,
identity(),
&config.git_remotes()[0],
&Credentials,
&OccupyingFetch(destination.clone()),
);
assert!(matches!(result, Err(GitError::Io { .. })));
assert_eq!(fs::read(destination.join("keep"))?, b"concurrent contents");
assert!(
!temporary
.path()
.read_dir()?
.filter_map(Result::ok)
.any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(".ironstorage-clone-")
})
);
Ok(())
}
#[test]
fn signed_commits_have_a_verifiable_ascii_armored_gpgsig() -> TestResult {
let fixture = FixtureSet::load()?;

View File

@@ -381,6 +381,19 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult
let credential = store.credential(remote.server_id(), remote.application_id())?;
assert_eq!(credential.username(), "alice");
assert_eq!(credential.password(), b"https-token");
store.store_https_git_credential(
remote.server_id(),
remote.application_id(),
"bob",
SecretBytes::new(b"replacement-token".to_vec()),
)?;
assert!(matches!(
store.retrieve(&git),
Err(SecretStoreError::Missing)
));
let credential = store.credential(remote.server_id(), remote.application_id())?;
assert_eq!(credential.username(), "bob");
assert_eq!(credential.password(), b"replacement-token");
assert!(
backend
.protections()