diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index 668a1c5..402c61c 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -462,6 +462,46 @@ fileprivate final class UniffiHandleMap: @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 }() diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index 764485b..84ca700 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -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 diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index e95b511..e0bf98f 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -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? + 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.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? + private var progressTask: Task? + 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.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) { + 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 + } +} diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index 422922d..dbc0312 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -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, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileOnboardingPhase { + Validating, + Authenticating, + Receiving, + Integrating, + Finishing, +} + +impl From 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, + 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 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 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 { + 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 { + 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 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, 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")); + } + } + } } diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index ef9b744..929ec34 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -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 { + 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::() + .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::() + )); + 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, + url: impl Into, + server_id: impl Into, + application_id: impl Into, + ) -> Result { + 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 { + 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 { let trimmed = value.trim(); if trimmed.is_empty() @@ -1014,7 +1186,14 @@ fn native_config_directory() -> Option { .map(|home| home.join("Library/Application Support")) } -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(target_os = "ios")] +fn native_config_directory() -> Option { + 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 { 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 { fn native_config_directory() -> Option { 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" + ); + } +} diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index e045eee..b6df1b8 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -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, 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, 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::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::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::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 { + 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::()); - 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, 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::, 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 { 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 { - 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 { + (0..128_u8) + .find_map(|attempt| { + let name = format!(".ironstorage-{purpose}-{}-{attempt}", rand::random::()); + 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 { 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()); + } +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index dd6b601..dc2c0b1 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -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; diff --git a/crates/storage/src/mobile_onboarding.rs b/crates/storage/src/mobile_onboarding.rs new file mode 100644 index 0000000..0541297 --- /dev/null +++ b/crates/storage/src/mobile_onboarding.rs @@ -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 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>, +} + +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 { + 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 { + 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, + ) -> Result { + 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, +) -> 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 { + 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, + 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 { + 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 { + 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::>(); + 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 { + 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()); + } +} diff --git a/crates/storage/src/secret_store.rs b/crates/storage/src/secret_store.rs index 581ff85..0f3756d 100644 --- a/crates/storage/src/secret_store.rs +++ b/crates/storage/src/secret_store.rs @@ -474,6 +474,47 @@ impl SecretStore { 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, + 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()?; diff --git a/crates/storage/tests/git_embedded.rs b/crates/storage/tests/git_embedded.rs index 778c074..168a3a2 100644 --- a/crates/storage/tests/git_embedded.rs +++ b/crates/storage/tests/git_embedded.rs @@ -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 { + 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()?; diff --git a/crates/storage/tests/secret_store.rs b/crates/storage/tests/secret_store.rs index 3d916ad..d66d6a7 100644 --- a/crates/storage/tests/secret_store.rs +++ b/crates/storage/tests/secret_store.rs @@ -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()