Onboard iPhone password-store clone
This commit is contained in:
@@ -462,6 +462,46 @@ fileprivate final class UniffiHandleMap<T>: @unchecked Sendable {
|
|||||||
// Public interface members begin here.
|
// 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)
|
#if swift(>=5.8)
|
||||||
@_documentation(visibility: private)
|
@_documentation(visibility: private)
|
||||||
#endif
|
#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 struct MobilePage: Equatable, Hashable {
|
||||||
public var tab: MobileTab
|
public var tab: MobileTab
|
||||||
public var title: String
|
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
|
public
|
||||||
enum MobilePreferenceError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError {
|
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)
|
#if swift(>=5.8)
|
||||||
@_documentation(visibility: private)
|
@_documentation(visibility: private)
|
||||||
#endif
|
#endif
|
||||||
@@ -902,6 +1565,17 @@ fileprivate struct FfiConverterSequenceTypeMobilePage: FfiConverterRustBuffer {
|
|||||||
return seq
|
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 {
|
public func mobileShell() -> MobileShell {
|
||||||
return try! FfiConverterTypeMobileShell_lift(try! rustCall() {
|
return try! FfiConverterTypeMobileShell_lift(try! rustCall() {
|
||||||
uniffiCallStatus in
|
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) {
|
public func setSelectedMobileTab(tab: MobileTab)throws {try rustCallWithError(FfiConverterTypeMobilePreferenceError_lift) {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
uniffi_ironstorage_apple_fn_func_set_selected_mobile_tab(
|
uniffi_ironstorage_apple_fn_func_set_selected_mobile_tab(
|
||||||
@@ -947,6 +1629,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if bindings_contract_version != scaffolding_contract_version {
|
if bindings_contract_version != scaffolding_contract_version {
|
||||||
return InitializationResult.contractVersionMismatch
|
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) {
|
if (uniffi_ironstorage_apple_checksum_func_mobile_shell() != 42687) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -956,9 +1641,24 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_apple_checksum_func_product_name() != 43533) {
|
if (uniffi_ironstorage_apple_checksum_func_product_name() != 43533) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
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) {
|
if (uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab() != 65280) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
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
|
return InitializationResult.ok
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -242,6 +242,41 @@ typedef struct UniffiForeignFutureResultVoid {
|
|||||||
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, 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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL
|
||||||
#define 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
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_PRODUCT_NAME
|
||||||
RustBuffer uniffi_ironstorage_apple_fn_func_product_name(RustCallStatus *_Nonnull out_status
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_SET_SELECTED_MOBILE_TAB
|
#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
|
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
|
||||||
#define 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
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_PRODUCT_NAME
|
||||||
uint16_t uniffi_ironstorage_apple_checksum_func_product_name(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB
|
||||||
#define 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
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_UNIFFI_CONTRACT_VERSION
|
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_UNIFFI_CONTRACT_VERSION
|
||||||
|
|||||||
@@ -167,6 +167,23 @@ private final class ShellViewController: UITableViewController {
|
|||||||
private func apply(_ page: MobilePage) {
|
private func apply(_ page: MobilePage) {
|
||||||
self.page = page
|
self.page = page
|
||||||
title = page.title
|
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()
|
refreshControl?.endRefreshing()
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
|
|
||||||
@@ -183,6 +200,14 @@ private final class ShellViewController: UITableViewController {
|
|||||||
contentUnavailableConfiguration = configuration
|
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 {
|
private func stateImage(_ state: MobileShellState) -> String {
|
||||||
switch state {
|
switch state {
|
||||||
case .loading: "hourglass"
|
case .loading: "hourglass"
|
||||||
@@ -193,3 +218,482 @@ private final class ShellViewController: UITableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class TokenUpdateViewController: UITableViewController {
|
||||||
|
private let accountField = UITextField()
|
||||||
|
private let tokenField = UITextField()
|
||||||
|
private var updateTask: Task<Void, Never>?
|
||||||
|
private var generation = 0
|
||||||
|
private var isWorking = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(style: .insetGrouped)
|
||||||
|
title = "Update Token"
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
for field in [accountField, tokenField] {
|
||||||
|
field.borderStyle = .none
|
||||||
|
field.clearButtonMode = .whileEditing
|
||||||
|
field.autocorrectionType = .no
|
||||||
|
field.autocapitalizationType = .none
|
||||||
|
field.adjustsFontForContentSizeCategory = true
|
||||||
|
field.font = .preferredFont(forTextStyle: .body)
|
||||||
|
}
|
||||||
|
accountField.placeholder = "Account name"
|
||||||
|
tokenField.placeholder = "New application token"
|
||||||
|
tokenField.textContentType = .oneTimeCode
|
||||||
|
tokenField.isSecureTextEntry = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
updateTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
generation += 1
|
||||||
|
updateTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
section == 0 ? 2 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
titleForHeaderInSection section: Int
|
||||||
|
) -> String? {
|
||||||
|
section == 0 ? "HTTPS Credential" : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
titleForFooterInSection section: Int
|
||||||
|
) -> String? {
|
||||||
|
section == 0
|
||||||
|
? "The existing token is never displayed. Updating replaces it only in protected system storage."
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||||
|
if indexPath.section == 1 {
|
||||||
|
cell.textLabel?.text = isWorking ? "Updating…" : "Replace Token"
|
||||||
|
cell.textLabel?.textColor = isWorking ? .secondaryLabel : view.tintColor
|
||||||
|
cell.textLabel?.textAlignment = .center
|
||||||
|
cell.isUserInteractionEnabled = !isWorking
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
let field = indexPath.row == 0 ? accountField : tokenField
|
||||||
|
field.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
field.isEnabled = !isWorking
|
||||||
|
cell.contentView.addSubview(field)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
field.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
|
||||||
|
field.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
|
||||||
|
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 10),
|
||||||
|
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -10)
|
||||||
|
])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
guard indexPath.section == 1, !isWorking else { return }
|
||||||
|
replaceToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replaceToken() {
|
||||||
|
generation += 1
|
||||||
|
let current = generation
|
||||||
|
isWorking = true
|
||||||
|
navigationItem.prompt = "Saving in protected system storage."
|
||||||
|
tableView.reloadData()
|
||||||
|
let account = accountField.text ?? ""
|
||||||
|
let token = tokenField.text ?? ""
|
||||||
|
updateTask = Task { [weak self] in
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
try replaceConfiguredMobileApplicationToken(
|
||||||
|
account: account,
|
||||||
|
applicationToken: token
|
||||||
|
)
|
||||||
|
return Result<Void, OnboardingFailure>.success(())
|
||||||
|
} catch let error as MobileOnboardingFfiError {
|
||||||
|
return .failure(OnboardingFailure(error))
|
||||||
|
} catch {
|
||||||
|
return .failure(.unexpected)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
guard let self, current == generation else { return }
|
||||||
|
isWorking = false
|
||||||
|
navigationItem.prompt = nil
|
||||||
|
tableView.reloadData()
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
tokenField.text = nil
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "Token Updated",
|
||||||
|
message: "The new application token is ready for HTTPS Git operations.",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
|
||||||
|
self?.navigationController?.popViewController(animated: true)
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
case let .failure(failure):
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: failure.title,
|
||||||
|
message: failure.detail,
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class OnboardingViewController: UITableViewController {
|
||||||
|
private let serverField = UITextField()
|
||||||
|
private let accountField = UITextField()
|
||||||
|
private let repositoryField = UITextField()
|
||||||
|
private let tokenField = UITextField()
|
||||||
|
private var branches: [String] = []
|
||||||
|
private var selectedBranch = 0
|
||||||
|
private var operation: MobileOnboardingOperation?
|
||||||
|
private var workTask: Task<Void, Never>?
|
||||||
|
private var progressTask: Task<Void, Never>?
|
||||||
|
private var generation = 0
|
||||||
|
private var isWorking = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(style: .insetGrouped)
|
||||||
|
title = "Connect Store"
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
configureFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
operation?.cancel()
|
||||||
|
workTask?.cancel()
|
||||||
|
progressTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
cancelWork()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
|
branches.isEmpty ? 2 : 3
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
switch section {
|
||||||
|
case 0: 4
|
||||||
|
case 1: 1
|
||||||
|
default: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
titleForHeaderInSection section: Int
|
||||||
|
) -> String? {
|
||||||
|
switch section {
|
||||||
|
case 0: "HTTPS Repository"
|
||||||
|
case 1: branches.isEmpty ? nil : "Remote"
|
||||||
|
default: "Local Clone"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
titleForFooterInSection section: Int
|
||||||
|
) -> String? {
|
||||||
|
section == 0 ? "The application token is stored in protected system storage, never in configuration." : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
if indexPath.section == 0 {
|
||||||
|
return fieldCell(indexPath.row)
|
||||||
|
}
|
||||||
|
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||||
|
if branches.isEmpty {
|
||||||
|
cell.textLabel?.text = "Discover Branches"
|
||||||
|
cell.textLabel?.textColor = view.tintColor
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
} else if indexPath.section == 1 {
|
||||||
|
cell.textLabel?.text = "Branch"
|
||||||
|
cell.detailTextLabel?.text = branches[selectedBranch]
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
} else if indexPath.row == 0 {
|
||||||
|
cell.textLabel?.text = "Clone Password Store"
|
||||||
|
cell.textLabel?.textColor = view.tintColor
|
||||||
|
} else {
|
||||||
|
cell.textLabel?.text = "Use Existing Clone"
|
||||||
|
cell.textLabel?.textColor = .secondaryLabel
|
||||||
|
}
|
||||||
|
cell.isUserInteractionEnabled = !isWorking
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
guard !isWorking else { return }
|
||||||
|
if branches.isEmpty, indexPath.section == 1 {
|
||||||
|
discoverBranches()
|
||||||
|
} else if indexPath.section == 1 {
|
||||||
|
chooseBranch(from: tableView.cellForRow(at: indexPath))
|
||||||
|
} else if indexPath.section == 2 {
|
||||||
|
runSetup(useExisting: indexPath.row == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureFields() {
|
||||||
|
for field in [serverField, accountField, repositoryField, tokenField] {
|
||||||
|
field.borderStyle = .none
|
||||||
|
field.clearButtonMode = .whileEditing
|
||||||
|
field.autocorrectionType = .no
|
||||||
|
field.returnKeyType = .next
|
||||||
|
field.adjustsFontForContentSizeCategory = true
|
||||||
|
field.font = .preferredFont(forTextStyle: .body)
|
||||||
|
}
|
||||||
|
serverField.placeholder = "https://git.example.com"
|
||||||
|
serverField.textContentType = .URL
|
||||||
|
serverField.keyboardType = .URL
|
||||||
|
serverField.autocapitalizationType = .none
|
||||||
|
accountField.placeholder = "Account name"
|
||||||
|
accountField.autocapitalizationType = .none
|
||||||
|
repositoryField.placeholder = "owner/password-store"
|
||||||
|
repositoryField.autocapitalizationType = .none
|
||||||
|
tokenField.placeholder = "Application token"
|
||||||
|
tokenField.textContentType = .oneTimeCode
|
||||||
|
tokenField.isSecureTextEntry = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fieldCell(_ row: Int) -> UITableViewCell {
|
||||||
|
let field = [serverField, accountField, repositoryField, tokenField][row]
|
||||||
|
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||||
|
field.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
cell.contentView.addSubview(field)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
field.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
|
||||||
|
field.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
|
||||||
|
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 10),
|
||||||
|
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -10)
|
||||||
|
])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeOperation() throws -> MobileOnboardingOperation {
|
||||||
|
try mobileOnboardingOperation(
|
||||||
|
serverUrl: serverField.text ?? "",
|
||||||
|
account: accountField.text ?? "",
|
||||||
|
repositoryPath: repositoryField.text ?? "",
|
||||||
|
applicationToken: tokenField.text ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func discoverBranches() {
|
||||||
|
do {
|
||||||
|
let operation = try makeOperation()
|
||||||
|
begin(operation, title: "Checking Repository") { operation in
|
||||||
|
let discovery = try operation.discover()
|
||||||
|
return .discovered(discovery)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
present(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runSetup(useExisting: Bool) {
|
||||||
|
guard branches.indices.contains(selectedBranch) else { return }
|
||||||
|
do {
|
||||||
|
let operation = try makeOperation()
|
||||||
|
let branch = branches[selectedBranch]
|
||||||
|
begin(operation, title: useExisting ? "Opening Store" : "Cloning Store") { operation in
|
||||||
|
let outcome = try operation.setup(branch: branch, useExisting: useExisting)
|
||||||
|
return .completed(outcome)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
present(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func begin(
|
||||||
|
_ operation: MobileOnboardingOperation,
|
||||||
|
title: String,
|
||||||
|
work: @escaping @Sendable (MobileOnboardingOperation) throws -> WorkResult
|
||||||
|
) {
|
||||||
|
cancelWork()
|
||||||
|
generation += 1
|
||||||
|
let current = generation
|
||||||
|
self.operation = operation
|
||||||
|
isWorking = true
|
||||||
|
showProgress(title: title, detail: "Preparing secure storage.")
|
||||||
|
tableView.reloadData()
|
||||||
|
progressTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .milliseconds(150))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !Task.isCancelled, let self, current == generation else { return }
|
||||||
|
let progress = operation.progress()
|
||||||
|
showProgress(title: progress.title, detail: progress.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workTask = Task { [weak self] in
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
return Result<WorkResult, OnboardingFailure>.success(try work(operation))
|
||||||
|
} catch let error as MobileOnboardingFfiError {
|
||||||
|
return .failure(OnboardingFailure(error))
|
||||||
|
} catch {
|
||||||
|
return .failure(.unexpected)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
guard let self, current == generation else { return }
|
||||||
|
finish(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(_ result: Result<WorkResult, OnboardingFailure>) {
|
||||||
|
progressTask?.cancel()
|
||||||
|
progressTask = nil
|
||||||
|
operation = nil
|
||||||
|
isWorking = false
|
||||||
|
navigationItem.titleView = nil
|
||||||
|
navigationItem.prompt = nil
|
||||||
|
switch result {
|
||||||
|
case let .success(.discovered(discovery)):
|
||||||
|
branches = discovery.branches
|
||||||
|
selectedBranch = min(Int(discovery.selectedBranch), max(branches.count - 1, 0))
|
||||||
|
tableView.reloadData()
|
||||||
|
case let .success(.completed(outcome)):
|
||||||
|
tokenField.text = nil
|
||||||
|
let alert = UIAlertController(title: outcome.title, message: outcome.detail, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
|
||||||
|
self?.navigationController?.popViewController(animated: true)
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
case let .failure(failure):
|
||||||
|
tableView.reloadData()
|
||||||
|
present(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chooseBranch(from source: UIView?) {
|
||||||
|
let sheet = UIAlertController(title: "Branch", message: nil, preferredStyle: .actionSheet)
|
||||||
|
for (index, branch) in branches.enumerated() {
|
||||||
|
sheet.addAction(UIAlertAction(title: branch, style: .default) { [weak self] _ in
|
||||||
|
self?.selectedBranch = index
|
||||||
|
self?.tableView.reloadSections(IndexSet(integer: 1), with: .automatic)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
sheet.popoverPresentationController?.sourceView = source
|
||||||
|
sheet.popoverPresentationController?.sourceRect = source?.bounds ?? .zero
|
||||||
|
present(sheet, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func present(_ error: Error) {
|
||||||
|
if let error = error as? MobileOnboardingFfiError {
|
||||||
|
present(OnboardingFailure(error))
|
||||||
|
} else {
|
||||||
|
present(.unexpected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func present(_ failure: OnboardingFailure) {
|
||||||
|
let alert = UIAlertController(title: failure.title, message: failure.detail, preferredStyle: .alert)
|
||||||
|
if failure.kind == .existingClone, !branches.isEmpty {
|
||||||
|
alert.addAction(UIAlertAction(title: "Use Existing", style: .default) { [weak self] _ in
|
||||||
|
self?.runSetup(useExisting: true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showProgress(title: String, detail: String) {
|
||||||
|
let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
spinner.startAnimating()
|
||||||
|
spinner.accessibilityLabel = "In progress"
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = title
|
||||||
|
label.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
let stack = UIStackView(arrangedSubviews: [spinner, label])
|
||||||
|
stack.spacing = 8
|
||||||
|
navigationItem.titleView = stack
|
||||||
|
navigationItem.prompt = detail
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelWork() {
|
||||||
|
generation += 1
|
||||||
|
operation?.cancel()
|
||||||
|
operation = nil
|
||||||
|
workTask?.cancel()
|
||||||
|
workTask = nil
|
||||||
|
progressTask?.cancel()
|
||||||
|
progressTask = nil
|
||||||
|
navigationItem.prompt = nil
|
||||||
|
navigationItem.titleView = nil
|
||||||
|
isWorking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WorkResult: Sendable {
|
||||||
|
case discovered(MobileOnboardingDiscovery)
|
||||||
|
case completed(MobileOnboardingOutcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OnboardingFailure: Error, Sendable {
|
||||||
|
let kind: MobileOnboardingErrorKind?
|
||||||
|
let title: String
|
||||||
|
let detail: String
|
||||||
|
|
||||||
|
init(_ error: MobileOnboardingFfiError) {
|
||||||
|
switch error {
|
||||||
|
case let .Failed(kind, title, detail):
|
||||||
|
self.kind = kind
|
||||||
|
self.title = title
|
||||||
|
self.detail = detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let unexpected = OnboardingFailure(
|
||||||
|
kind: nil,
|
||||||
|
title: "Setup Failed",
|
||||||
|
detail: "IronStorage could not complete setup."
|
||||||
|
)
|
||||||
|
|
||||||
|
private init(kind: MobileOnboardingErrorKind?, title: String, detail: String) {
|
||||||
|
self.kind = kind
|
||||||
|
self.title = title
|
||||||
|
self.detail = detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
|
|
||||||
//! Mechanical UniFFI exports for Apple presentation code.
|
//! Mechanical UniFFI exports for Apple presentation code.
|
||||||
|
|
||||||
use std::{error::Error, fmt};
|
use std::{error::Error, fmt, sync::Arc};
|
||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
config::ConfigError,
|
config::ConfigError,
|
||||||
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
|
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
|
||||||
|
mobile_onboarding::{
|
||||||
|
self, MobileOnboardingError as StorageOnboardingError,
|
||||||
|
MobileOnboardingErrorKind as StorageOnboardingErrorKind,
|
||||||
|
MobileOnboardingPhase as StorageOnboardingPhase,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
@@ -92,6 +97,146 @@ pub struct MobileShell {
|
|||||||
pub pages: Vec<MobilePage>,
|
pub pages: Vec<MobilePage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileOnboardingPhase {
|
||||||
|
Validating,
|
||||||
|
Authenticating,
|
||||||
|
Receiving,
|
||||||
|
Integrating,
|
||||||
|
Finishing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageOnboardingPhase> for MobileOnboardingPhase {
|
||||||
|
fn from(phase: StorageOnboardingPhase) -> Self {
|
||||||
|
match phase {
|
||||||
|
StorageOnboardingPhase::Validating => Self::Validating,
|
||||||
|
StorageOnboardingPhase::Authenticating => Self::Authenticating,
|
||||||
|
StorageOnboardingPhase::Receiving => Self::Receiving,
|
||||||
|
StorageOnboardingPhase::Integrating => Self::Integrating,
|
||||||
|
StorageOnboardingPhase::Finishing => Self::Finishing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileOnboardingProgress {
|
||||||
|
pub phase: MobileOnboardingPhase,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileOnboardingDiscovery {
|
||||||
|
pub branches: Vec<String>,
|
||||||
|
pub selected_branch: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileOnboardingOutcome {
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileOnboardingErrorKind {
|
||||||
|
InvalidInput,
|
||||||
|
UnsupportedRemote,
|
||||||
|
Authentication,
|
||||||
|
Repository,
|
||||||
|
ExistingClone,
|
||||||
|
Interrupted,
|
||||||
|
SecureStorage,
|
||||||
|
Configuration,
|
||||||
|
AlreadyConfigured,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageOnboardingErrorKind> for MobileOnboardingErrorKind {
|
||||||
|
fn from(kind: StorageOnboardingErrorKind) -> Self {
|
||||||
|
match kind {
|
||||||
|
StorageOnboardingErrorKind::InvalidInput => Self::InvalidInput,
|
||||||
|
StorageOnboardingErrorKind::UnsupportedRemote => Self::UnsupportedRemote,
|
||||||
|
StorageOnboardingErrorKind::Authentication => Self::Authentication,
|
||||||
|
StorageOnboardingErrorKind::Repository => Self::Repository,
|
||||||
|
StorageOnboardingErrorKind::ExistingClone => Self::ExistingClone,
|
||||||
|
StorageOnboardingErrorKind::Interrupted => Self::Interrupted,
|
||||||
|
StorageOnboardingErrorKind::SecureStorage => Self::SecureStorage,
|
||||||
|
StorageOnboardingErrorKind::Configuration => Self::Configuration,
|
||||||
|
StorageOnboardingErrorKind::AlreadyConfigured => Self::AlreadyConfigured,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, uniffi::Error)]
|
||||||
|
pub enum MobileOnboardingFfiError {
|
||||||
|
Failed {
|
||||||
|
kind: MobileOnboardingErrorKind,
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MobileOnboardingFfiError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Failed { title, detail, .. } => write!(formatter, "{title}: {detail}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for MobileOnboardingFfiError {}
|
||||||
|
|
||||||
|
impl From<StorageOnboardingError> for MobileOnboardingFfiError {
|
||||||
|
fn from(error: StorageOnboardingError) -> Self {
|
||||||
|
Self::Failed {
|
||||||
|
kind: error.kind().into(),
|
||||||
|
title: error.title().to_owned(),
|
||||||
|
detail: error.detail().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(uniffi::Object)]
|
||||||
|
pub struct MobileOnboardingOperation {
|
||||||
|
operation: mobile_onboarding::MobileOnboardingOperation,
|
||||||
|
request: mobile_onboarding::MobileOnboardingRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
impl MobileOnboardingOperation {
|
||||||
|
pub fn progress(&self) -> MobileOnboardingProgress {
|
||||||
|
let progress = self.operation.progress();
|
||||||
|
MobileOnboardingProgress {
|
||||||
|
phase: progress.phase().into(),
|
||||||
|
title: progress.title().to_owned(),
|
||||||
|
detail: progress.detail().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover(&self) -> Result<MobileOnboardingDiscovery, MobileOnboardingFfiError> {
|
||||||
|
let discovery = self.operation.discover(&self.request)?;
|
||||||
|
Ok(MobileOnboardingDiscovery {
|
||||||
|
branches: discovery.branches().to_vec(),
|
||||||
|
selected_branch: u32::try_from(discovery.selected_branch()).unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup(
|
||||||
|
&self,
|
||||||
|
branch: String,
|
||||||
|
use_existing: bool,
|
||||||
|
) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
|
||||||
|
let outcome = self.operation.setup(&self.request, &branch, use_existing)?;
|
||||||
|
Ok(MobileOnboardingOutcome {
|
||||||
|
title: outcome.title().to_owned(),
|
||||||
|
detail: outcome.detail().to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
self.operation.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<mobile::MobileShell> for MobileShell {
|
impl From<mobile::MobileShell> for MobileShell {
|
||||||
fn from(shell: mobile::MobileShell) -> Self {
|
fn from(shell: mobile::MobileShell) -> Self {
|
||||||
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)
|
mobile::store_selected_tab(tab.into()).map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn mobile_onboarding_operation(
|
||||||
|
server_url: String,
|
||||||
|
account: String,
|
||||||
|
repository_path: String,
|
||||||
|
application_token: String,
|
||||||
|
) -> Result<Arc<MobileOnboardingOperation>, MobileOnboardingFfiError> {
|
||||||
|
Ok(Arc::new(MobileOnboardingOperation {
|
||||||
|
operation: mobile_onboarding::MobileOnboardingOperation::default(),
|
||||||
|
request: mobile_onboarding::MobileOnboardingRequest::new(
|
||||||
|
server_url,
|
||||||
|
account,
|
||||||
|
repository_path,
|
||||||
|
application_token.into_bytes(),
|
||||||
|
)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn replace_configured_mobile_application_token(
|
||||||
|
account: String,
|
||||||
|
application_token: String,
|
||||||
|
) -> Result<(), MobileOnboardingFfiError> {
|
||||||
|
mobile_onboarding::replace_configured_application_token(
|
||||||
|
account,
|
||||||
|
application_token.into_bytes(),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{MobileShellState, MobileTab};
|
use super::{MobileOnboardingErrorKind, MobileOnboardingFfiError, MobileShellState, MobileTab};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bridge_reads_product_name_from_storage_crate() {
|
fn bridge_reads_product_name_from_storage_crate() {
|
||||||
@@ -180,4 +355,23 @@ mod tests {
|
|||||||
assert!(shell.pages.iter().all(|page| page.state == state));
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::{
|
|||||||
env,
|
env,
|
||||||
error::Error,
|
error::Error,
|
||||||
fmt, fs,
|
fmt, fs,
|
||||||
|
fs::OpenOptions,
|
||||||
io::Write,
|
io::Write,
|
||||||
path::{Component, Path, PathBuf},
|
path::{Component, Path, PathBuf},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
@@ -167,6 +168,73 @@ impl Config {
|
|||||||
validate_config(self.source.clone(), document, raw)?.persist()
|
validate_config(self.source.clone(), document, raw)?.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn create_mobile_clone(
|
||||||
|
source: PathBuf,
|
||||||
|
vault: &Path,
|
||||||
|
key_material: &Path,
|
||||||
|
default_key: &str,
|
||||||
|
remote: &GitRemote,
|
||||||
|
) -> Result<Self, ConfigError> {
|
||||||
|
let base = source
|
||||||
|
.parent()
|
||||||
|
.ok_or(ConfigError::InvalidField { field: "source" })?;
|
||||||
|
let vault = vault
|
||||||
|
.strip_prefix(base)
|
||||||
|
.map_err(|_| ConfigError::InvalidField { field: "vault" })?;
|
||||||
|
let key_material =
|
||||||
|
key_material
|
||||||
|
.strip_prefix(base)
|
||||||
|
.map_err(|_| ConfigError::InvalidField {
|
||||||
|
field: "key_material",
|
||||||
|
})?;
|
||||||
|
let mut root = toml::Table::new();
|
||||||
|
root.insert(
|
||||||
|
"vault".to_owned(),
|
||||||
|
toml::Value::String(path_text(vault, "vault")?),
|
||||||
|
);
|
||||||
|
root.insert(
|
||||||
|
"default_key".to_owned(),
|
||||||
|
toml::Value::String(default_key.to_owned()),
|
||||||
|
);
|
||||||
|
root.insert(
|
||||||
|
"key_material".to_owned(),
|
||||||
|
toml::Value::String(path_text(key_material, "key_material")?),
|
||||||
|
);
|
||||||
|
let mut configured = toml::Table::new();
|
||||||
|
configured.insert(
|
||||||
|
"name".to_owned(),
|
||||||
|
toml::Value::String(remote.name().as_str().to_owned()),
|
||||||
|
);
|
||||||
|
configured.insert(
|
||||||
|
"url".to_owned(),
|
||||||
|
toml::Value::String(remote.url().to_string()),
|
||||||
|
);
|
||||||
|
configured.insert(
|
||||||
|
"server_id".to_owned(),
|
||||||
|
toml::Value::String(remote.server_id().as_str().to_owned()),
|
||||||
|
);
|
||||||
|
configured.insert(
|
||||||
|
"application_id".to_owned(),
|
||||||
|
toml::Value::String(remote.application_id().as_str().to_owned()),
|
||||||
|
);
|
||||||
|
let mut git = toml::Table::new();
|
||||||
|
git.insert(
|
||||||
|
"remotes".to_owned(),
|
||||||
|
toml::Value::Array(vec![toml::Value::Table(configured)]),
|
||||||
|
);
|
||||||
|
root.insert("git".to_owned(), toml::Value::Table(git));
|
||||||
|
let document = toml::Value::Table(root);
|
||||||
|
let raw = document
|
||||||
|
.clone()
|
||||||
|
.try_into::<RawConfig>()
|
||||||
|
.map_err(|_| ConfigError::Malformed {
|
||||||
|
path: source.clone(),
|
||||||
|
})?;
|
||||||
|
let config = validate_config(source, document, raw)?;
|
||||||
|
config.persist_new()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
/// Select a configured remote by name, or the configured default (first
|
/// Select a configured remote by name, or the configured default (first
|
||||||
/// remote) when no name was requested.
|
/// remote) when no name was requested.
|
||||||
pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> {
|
pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> {
|
||||||
@@ -313,6 +381,72 @@ impl Config {
|
|||||||
path: self.source.clone(),
|
path: self.source.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn persist_new(&self) -> Result<(), ConfigError> {
|
||||||
|
let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
|
||||||
|
path: self.source.clone(),
|
||||||
|
})?;
|
||||||
|
let name = self.source.file_name().ok_or_else(|| ConfigError::Write {
|
||||||
|
path: self.source.clone(),
|
||||||
|
})?;
|
||||||
|
if self.source.exists() {
|
||||||
|
return Err(ConfigError::AlreadyConfigured {
|
||||||
|
path: self.source.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fs::create_dir_all(parent).map_err(|_| ConfigError::Write {
|
||||||
|
path: self.source.clone(),
|
||||||
|
})?;
|
||||||
|
set_private_directory(parent).map_err(|_| ConfigError::Write {
|
||||||
|
path: self.source.clone(),
|
||||||
|
})?;
|
||||||
|
let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write {
|
||||||
|
path: self.source.clone(),
|
||||||
|
})?;
|
||||||
|
let temporary = (0..128_u8)
|
||||||
|
.find_map(|attempt| {
|
||||||
|
let path = parent.join(format!(
|
||||||
|
".ironstorage-config-{}-{attempt}",
|
||||||
|
rand::random::<u64>()
|
||||||
|
));
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.write(true).create_new(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt as _;
|
||||||
|
options.mode(0o600);
|
||||||
|
}
|
||||||
|
match options.open(&path) {
|
||||||
|
Ok(file) => Some(Ok((path, file))),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
|
||||||
|
Err(_) => Some(Err(ConfigError::Write {
|
||||||
|
path: self.source.clone(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.transpose()?
|
||||||
|
.ok_or_else(|| ConfigError::Write {
|
||||||
|
path: self.source.clone(),
|
||||||
|
})?;
|
||||||
|
let (temporary_path, mut temporary_file) = temporary;
|
||||||
|
let installed = temporary_file
|
||||||
|
.write_all(contents.as_bytes())
|
||||||
|
.and_then(|()| temporary_file.sync_all())
|
||||||
|
.and_then(|()| fs::hard_link(&temporary_path, parent.join(name)));
|
||||||
|
drop(temporary_file);
|
||||||
|
let _ = fs::remove_file(&temporary_path);
|
||||||
|
match installed {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||||
|
Err(ConfigError::AlreadyConfigured {
|
||||||
|
path: self.source.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(_) => Err(ConfigError::Write {
|
||||||
|
path: self.source.clone(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deterministic path context for configuration loading.
|
/// Deterministic path context for configuration loading.
|
||||||
@@ -432,6 +566,21 @@ pub struct GitRemote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GitRemote {
|
impl GitRemote {
|
||||||
|
pub fn https(
|
||||||
|
name: impl Into<String>,
|
||||||
|
url: impl Into<String>,
|
||||||
|
server_id: impl Into<String>,
|
||||||
|
application_id: impl Into<String>,
|
||||||
|
) -> Result<Self, ConfigError> {
|
||||||
|
let mut remotes = validate_remotes(vec![RawGitRemote {
|
||||||
|
name: name.into(),
|
||||||
|
url: url.into(),
|
||||||
|
server_id: server_id.into(),
|
||||||
|
application_id: application_id.into(),
|
||||||
|
}])?;
|
||||||
|
Ok(remotes.remove(0))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn name(&self) -> &RemoteName {
|
pub fn name(&self) -> &RemoteName {
|
||||||
&self.name
|
&self.name
|
||||||
}
|
}
|
||||||
@@ -505,6 +654,7 @@ pub enum ConfigError {
|
|||||||
VaultUnavailable { path: PathBuf },
|
VaultUnavailable { path: PathBuf },
|
||||||
VaultIsNotDirectory { path: PathBuf },
|
VaultIsNotDirectory { path: PathBuf },
|
||||||
Write { path: PathBuf },
|
Write { path: PathBuf },
|
||||||
|
AlreadyConfigured { path: PathBuf },
|
||||||
KeyMaterialNotFound { path: PathBuf },
|
KeyMaterialNotFound { path: PathBuf },
|
||||||
InvalidKeyMaterial { path: PathBuf },
|
InvalidKeyMaterial { path: PathBuf },
|
||||||
DuplicateRemote { name: String },
|
DuplicateRemote { name: String },
|
||||||
@@ -575,6 +725,11 @@ impl fmt::Display for ConfigError {
|
|||||||
path.display()
|
path.display()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Self::AlreadyConfigured { path } => write!(
|
||||||
|
formatter,
|
||||||
|
"configuration already exists and was not replaced: {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
Self::KeyMaterialNotFound { path } => write!(
|
Self::KeyMaterialNotFound { path } => write!(
|
||||||
formatter,
|
formatter,
|
||||||
"exported key material does not exist: {}",
|
"exported key material does not exist: {}",
|
||||||
@@ -760,6 +915,23 @@ fn resolve_required_path(
|
|||||||
Ok(resolve_path(base, &value))
|
Ok(resolve_path(base, &value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn path_text(path: &Path, field: &'static str) -> Result<String, ConfigError> {
|
||||||
|
path.to_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or(ConfigError::InvalidField { field })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_private_directory(path: &Path) -> std::io::Result<()> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = path;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_key_identity(value: String) -> Result<KeyIdentity, ConfigError> {
|
fn validate_key_identity(value: String) -> Result<KeyIdentity, ConfigError> {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty()
|
if trimmed.is_empty()
|
||||||
@@ -1014,7 +1186,14 @@ fn native_config_directory() -> Option<PathBuf> {
|
|||||||
.map(|home| home.join("Library/Application Support"))
|
.map(|home| home.join("Library/Application Support"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(unix, not(target_os = "macos")))]
|
#[cfg(target_os = "ios")]
|
||||||
|
fn native_config_directory() -> Option<PathBuf> {
|
||||||
|
env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.map(|home| home.join("Library/Application Support"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(any(target_os = "ios", target_os = "macos"))))]
|
||||||
fn native_config_directory() -> Option<PathBuf> {
|
fn native_config_directory() -> Option<PathBuf> {
|
||||||
match env::var_os("XDG_CONFIG_HOME") {
|
match env::var_os("XDG_CONFIG_HOME") {
|
||||||
Some(path) if !path.is_empty() && Path::new(&path).is_absolute() => {
|
Some(path) if !path.is_empty() && Path::new(&path).is_absolute() => {
|
||||||
@@ -1031,3 +1210,53 @@ fn native_config_directory() -> Option<PathBuf> {
|
|||||||
fn native_config_directory() -> Option<PathBuf> {
|
fn native_config_directory() -> Option<PathBuf> {
|
||||||
None
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -788,18 +788,91 @@ impl FetchOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GitRepository {
|
impl GitRepository {
|
||||||
|
pub fn discover_remote_branches(
|
||||||
|
parent: &Path,
|
||||||
|
identity: GitIdentity,
|
||||||
|
configured: &GitRemote,
|
||||||
|
credentials: &impl GitCredentialProvider,
|
||||||
|
control: &GitOperationControl,
|
||||||
|
) -> Result<Vec<String>, GitError> {
|
||||||
|
Self::discover_remote_branches_with_transport(
|
||||||
|
parent,
|
||||||
|
identity,
|
||||||
|
configured,
|
||||||
|
credentials,
|
||||||
|
&EmbeddedFetchTransport,
|
||||||
|
control,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_remote_branches_with_transport(
|
||||||
|
parent: &Path,
|
||||||
|
identity: GitIdentity,
|
||||||
|
configured: &GitRemote,
|
||||||
|
credentials: &impl GitCredentialProvider,
|
||||||
|
transport: &impl GitFetchTransport,
|
||||||
|
control: &GitOperationControl,
|
||||||
|
) -> Result<Vec<String>, GitError> {
|
||||||
|
control.report(GitProgressPhase::Validating)?;
|
||||||
|
validate_https_remote(configured.url().as_str())?;
|
||||||
|
ensure_clone_parent(parent)?;
|
||||||
|
let temporary = private_temporary_directory(parent, "probe")?;
|
||||||
|
let result = (|| {
|
||||||
|
let store = Repository::open(&temporary).map_err(invalid)?;
|
||||||
|
gix::ThreadSafeRepository::init_opts(
|
||||||
|
store.root_path(),
|
||||||
|
gix::create::Kind::WithWorktree,
|
||||||
|
gix::create::Options::default(),
|
||||||
|
isolated_options(),
|
||||||
|
)
|
||||||
|
.map_err(invalid)?;
|
||||||
|
let mut repository = Self::open(&store, identity)?;
|
||||||
|
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
|
||||||
|
repository.fetch_with_transport_controlled(
|
||||||
|
configured,
|
||||||
|
credentials,
|
||||||
|
transport,
|
||||||
|
control,
|
||||||
|
)?;
|
||||||
|
repository.remote_branch_names(configured.name().as_str())
|
||||||
|
})();
|
||||||
|
let _ = fs::remove_dir_all(&temporary);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clone_into(
|
pub fn clone_into(
|
||||||
destination: &Path,
|
destination: &Path,
|
||||||
identity: GitIdentity,
|
identity: GitIdentity,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitCredentialProvider,
|
||||||
) -> Result<Self, GitError> {
|
) -> Result<Self, GitError> {
|
||||||
Self::clone_into_with_transport(
|
Self::clone_into_with_transport_controlled(
|
||||||
destination,
|
destination,
|
||||||
identity,
|
identity,
|
||||||
configured,
|
configured,
|
||||||
|
None,
|
||||||
credentials,
|
credentials,
|
||||||
&EmbeddedFetchTransport,
|
&EmbeddedFetchTransport,
|
||||||
|
&GitOperationControl::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clone_into_controlled(
|
||||||
|
destination: &Path,
|
||||||
|
identity: GitIdentity,
|
||||||
|
configured: &GitRemote,
|
||||||
|
branch: &str,
|
||||||
|
credentials: &impl GitCredentialProvider,
|
||||||
|
control: &GitOperationControl,
|
||||||
|
) -> Result<Self, GitError> {
|
||||||
|
Self::clone_into_with_transport_controlled(
|
||||||
|
destination,
|
||||||
|
identity,
|
||||||
|
configured,
|
||||||
|
Some(branch),
|
||||||
|
credentials,
|
||||||
|
&EmbeddedFetchTransport,
|
||||||
|
control,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,17 +883,35 @@ impl GitRepository {
|
|||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitCredentialProvider,
|
||||||
transport: &impl GitFetchTransport,
|
transport: &impl GitFetchTransport,
|
||||||
) -> Result<Self, GitError> {
|
) -> Result<Self, GitError> {
|
||||||
|
Self::clone_into_with_transport_controlled(
|
||||||
|
destination,
|
||||||
|
identity,
|
||||||
|
configured,
|
||||||
|
None,
|
||||||
|
credentials,
|
||||||
|
transport,
|
||||||
|
&GitOperationControl::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clone_into_with_transport_controlled(
|
||||||
|
destination: &Path,
|
||||||
|
identity: GitIdentity,
|
||||||
|
configured: &GitRemote,
|
||||||
|
branch: Option<&str>,
|
||||||
|
credentials: &impl GitCredentialProvider,
|
||||||
|
transport: &impl GitFetchTransport,
|
||||||
|
control: &GitOperationControl,
|
||||||
|
) -> Result<Self, GitError> {
|
||||||
|
control.report(GitProgressPhase::Validating)?;
|
||||||
validate_https_remote(configured.url().as_str())?;
|
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 {
|
let parent = destination.parent().ok_or_else(|| GitError::InvalidPath {
|
||||||
path: destination.to_owned(),
|
path: destination.to_owned(),
|
||||||
})?;
|
})?;
|
||||||
let metadata =
|
ensure_clone_parent(parent)?;
|
||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Ok(metadata) = fs::symlink_metadata(destination) {
|
if let Ok(metadata) = fs::symlink_metadata(destination) {
|
||||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||||
return Err(GitError::UnsafeWorktreeObject {
|
return Err(GitError::UnsafeWorktreeObject {
|
||||||
@@ -835,18 +926,7 @@ impl GitRepository {
|
|||||||
return Err(GitError::DirtyWorktree);
|
return Err(GitError::DirtyWorktree);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let temporary = (0..128_u8)
|
let temporary = private_temporary_directory(parent, "clone")?;
|
||||||
.find_map(|attempt| {
|
|
||||||
let name = format!(".ironstorage-clone-{}-{attempt}", rand::random::<u64>());
|
|
||||||
let candidate = parent.join(name);
|
|
||||||
match fs::create_dir(&candidate) {
|
|
||||||
Ok(()) => Some(Ok(candidate)),
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
|
|
||||||
Err(_) => Some(Err(io("create clone directory", parent))),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.transpose()?
|
|
||||||
.ok_or_else(|| io("create clone directory", parent))?;
|
|
||||||
let cloned = (|| {
|
let cloned = (|| {
|
||||||
let store = Repository::open(&temporary).map_err(invalid)?;
|
let store = Repository::open(&temporary).map_err(invalid)?;
|
||||||
gix::ThreadSafeRepository::init_opts(
|
gix::ThreadSafeRepository::init_opts(
|
||||||
@@ -858,17 +938,23 @@ impl GitRepository {
|
|||||||
.map_err(invalid)?;
|
.map_err(invalid)?;
|
||||||
let mut repository = Self::open(&store, identity.clone())?;
|
let mut repository = Self::open(&store, identity.clone())?;
|
||||||
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
|
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
|
||||||
repository.pull_with_transport(configured, None, credentials, transport)?;
|
repository.pull_with_transport_controlled(
|
||||||
Ok(repository)
|
configured,
|
||||||
|
branch,
|
||||||
|
credentials,
|
||||||
|
transport,
|
||||||
|
control,
|
||||||
|
)?;
|
||||||
|
control.report(GitProgressPhase::Refreshing)?;
|
||||||
|
Ok(())
|
||||||
})();
|
})();
|
||||||
if let Err(error) = cloned {
|
if let Err(error) = cloned {
|
||||||
let _ = fs::remove_dir_all(&temporary);
|
let _ = fs::remove_dir_all(&temporary);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
drop(cloned);
|
if destination.exists() && fs::remove_dir(destination).is_err() {
|
||||||
if destination.exists() {
|
let _ = fs::remove_dir_all(&temporary);
|
||||||
fs::remove_dir(destination)
|
return Err(io("prepare clone destination", destination));
|
||||||
.map_err(|_| io("prepare clone destination", destination))?;
|
|
||||||
}
|
}
|
||||||
if fs::rename(&temporary, destination).is_err() {
|
if fs::rename(&temporary, destination).is_err() {
|
||||||
let _ = fs::remove_dir_all(&temporary);
|
let _ = fs::remove_dir_all(&temporary);
|
||||||
@@ -880,6 +966,42 @@ impl GitRepository {
|
|||||||
Self::open_at(destination.to_owned(), identity)
|
Self::open_at(destination.to_owned(), identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remote_branch_names(&self, remote: &str) -> Result<Vec<String>, GitError> {
|
||||||
|
validate_remote_name(remote)?;
|
||||||
|
let prefix = format!("refs/remotes/{remote}/");
|
||||||
|
let mut branches = self
|
||||||
|
.repository
|
||||||
|
.references()
|
||||||
|
.map_err(invalid)?
|
||||||
|
.remote_branches()
|
||||||
|
.map_err(invalid)?
|
||||||
|
.filter_map(|reference| {
|
||||||
|
let reference = match reference {
|
||||||
|
Ok(reference) => reference,
|
||||||
|
Err(error) => return Some(Err(invalid(error))),
|
||||||
|
};
|
||||||
|
let name = match reference.name().as_bstr().to_str() {
|
||||||
|
Ok(name) => name,
|
||||||
|
Err(error) => return Some(Err(invalid(error))),
|
||||||
|
};
|
||||||
|
name.strip_prefix(&prefix)
|
||||||
|
.filter(|name| *name != "HEAD")
|
||||||
|
.map(|name| {
|
||||||
|
validate_remote_name(name)?;
|
||||||
|
Ok(name.to_owned())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, GitError>>()?;
|
||||||
|
branches.sort();
|
||||||
|
branches.dedup();
|
||||||
|
if branches.is_empty() {
|
||||||
|
return Err(GitError::InvalidRepository(
|
||||||
|
"the remote has no branches".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(branches)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn init(store: &Repository, identity: GitIdentity) -> Result<Self, GitError> {
|
pub fn init(store: &Repository, identity: GitIdentity) -> Result<Self, GitError> {
|
||||||
let root = store.root_path().to_owned();
|
let root = store.root_path().to_owned();
|
||||||
if root.join(".git").exists() {
|
if root.join(".git").exists() {
|
||||||
@@ -957,12 +1079,13 @@ impl GitRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn open_at(root: PathBuf, identity: GitIdentity) -> Result<Self, GitError> {
|
fn open_at(root: PathBuf, identity: GitIdentity) -> Result<Self, GitError> {
|
||||||
let repository =
|
let mut repository =
|
||||||
gix::open_opts(&root, isolated_options()).map_err(|_| GitError::NotRepository)?;
|
gix::open_opts(&root, isolated_options()).map_err(|_| GitError::NotRepository)?;
|
||||||
if repository.is_bare() {
|
if repository.is_bare() {
|
||||||
return Err(GitError::BareRepository);
|
return Err(GitError::BareRepository);
|
||||||
}
|
}
|
||||||
validate_local_config_security(&load_local_config(&repository)?)?;
|
validate_local_config_security(&load_local_config(&repository)?)?;
|
||||||
|
apply_in_memory_identity(&mut repository, &identity)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
root,
|
root,
|
||||||
repository,
|
repository,
|
||||||
@@ -1204,18 +1327,18 @@ impl GitRepository {
|
|||||||
return GitError::Cancelled;
|
return GitError::Cancelled;
|
||||||
}
|
}
|
||||||
let text = error.to_string();
|
let text = error.to_string();
|
||||||
|
let lower = text.to_ascii_lowercase();
|
||||||
if text.contains("401")
|
if text.contains("401")
|
||||||
|| text.contains("403")
|
|| text.contains("403")
|
||||||
|| text.to_ascii_lowercase().contains("authentication")
|
|| lower.contains("authentication")
|
||||||
|
|| (lower.contains("credential") && lower.contains("not accepted"))
|
||||||
{
|
{
|
||||||
GitError::AuthenticationFailed
|
GitError::AuthenticationFailed
|
||||||
} else if text.to_ascii_lowercase().contains("certificate")
|
} else if lower.contains("certificate") || lower.contains("tls") {
|
||||||
|| text.to_ascii_lowercase().contains("tls")
|
|
||||||
{
|
|
||||||
GitError::TlsFailed
|
GitError::TlsFailed
|
||||||
} else if text.to_ascii_lowercase().contains("network")
|
} else if lower.contains("network")
|
||||||
|| text.to_ascii_lowercase().contains("connect")
|
|| lower.contains("connect")
|
||||||
|| text.to_ascii_lowercase().contains("dns")
|
|| lower.contains("dns")
|
||||||
{
|
{
|
||||||
GitError::NetworkUnavailable
|
GitError::NetworkUnavailable
|
||||||
} else {
|
} else {
|
||||||
@@ -1878,6 +2001,7 @@ impl GitRepository {
|
|||||||
validate_local_config_security(&config)?;
|
validate_local_config_security(&config)?;
|
||||||
write_local_config(&self.repository, &config)?;
|
write_local_config(&self.repository, &config)?;
|
||||||
self.repository = gix::open_opts(&self.root, isolated_options()).map_err(invalid)?;
|
self.repository = gix::open_opts(&self.root, isolated_options()).map_err(invalid)?;
|
||||||
|
apply_in_memory_identity(&mut self.repository, &self.identity)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3032,6 +3156,43 @@ fn validate_remote_name(name: &str) -> Result<(), GitError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_clone_parent(parent: &Path) -> Result<(), GitError> {
|
||||||
|
let metadata = fs::symlink_metadata(parent).map_err(|_| io("inspect clone parent", parent))?;
|
||||||
|
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||||
|
return Err(GitError::UnsafeWorktreeObject {
|
||||||
|
path: parent.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn private_temporary_directory(parent: &Path, purpose: &str) -> Result<PathBuf, GitError> {
|
||||||
|
(0..128_u8)
|
||||||
|
.find_map(|attempt| {
|
||||||
|
let name = format!(".ironstorage-{purpose}-{}-{attempt}", rand::random::<u64>());
|
||||||
|
let candidate = parent.join(name);
|
||||||
|
match fs::create_dir(&candidate) {
|
||||||
|
Ok(()) => {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
if fs::set_permissions(&candidate, fs::Permissions::from_mode(0o700))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
let _ = fs::remove_dir(&candidate);
|
||||||
|
return Some(Err(io("secure private Git directory", parent)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(candidate))
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
|
||||||
|
Err(_) => Some(Err(io("create private Git directory", parent))),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.transpose()?
|
||||||
|
.ok_or_else(|| io("create private Git directory", parent))
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_https_remote(value: &str) -> Result<url::Url, GitError> {
|
fn validate_https_remote(value: &str) -> Result<url::Url, GitError> {
|
||||||
let parsed = url::Url::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?;
|
let parsed = url::Url::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?;
|
||||||
if parsed.scheme() != "https"
|
if parsed.scheme() != "https"
|
||||||
@@ -3215,9 +3376,45 @@ fn invalid(error: impl fmt::Display) -> GitError {
|
|||||||
GitError::InvalidRepository(error.to_string())
|
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 {
|
fn io(operation: &'static str, path: &Path) -> GitError {
|
||||||
GitError::Io {
|
GitError::Io {
|
||||||
operation,
|
operation,
|
||||||
path: path.to_owned(),
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub mod generate;
|
|||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod kdbx;
|
pub mod kdbx;
|
||||||
pub mod mobile;
|
pub mod mobile;
|
||||||
|
pub mod mobile_onboarding;
|
||||||
pub mod mutation;
|
pub mod mutation;
|
||||||
pub mod otp;
|
pub mod otp;
|
||||||
pub mod presentation;
|
pub mod presentation;
|
||||||
|
|||||||
665
crates/storage/src/mobile_onboarding.rs
Normal file
665
crates/storage/src/mobile_onboarding.rs
Normal file
@@ -0,0 +1,665 @@
|
|||||||
|
//! Storage-owned first-run setup for the native iPhone application.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
error::Error,
|
||||||
|
fmt, fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use sha2::{Digest as _, Sha256};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::{Config, ConfigError, ConfigLoader, GitRemote},
|
||||||
|
git::{
|
||||||
|
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
|
||||||
|
GitProgressPhase, GitRepository,
|
||||||
|
},
|
||||||
|
repository::{DirectoryPath, Repository, SecretBytes},
|
||||||
|
secret_store::{
|
||||||
|
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MobileOnboardingPhase {
|
||||||
|
Validating,
|
||||||
|
Authenticating,
|
||||||
|
Receiving,
|
||||||
|
Integrating,
|
||||||
|
Finishing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<GitProgressPhase> for MobileOnboardingPhase {
|
||||||
|
fn from(phase: GitProgressPhase) -> Self {
|
||||||
|
match phase {
|
||||||
|
GitProgressPhase::Validating => Self::Validating,
|
||||||
|
GitProgressPhase::Authenticating => Self::Authenticating,
|
||||||
|
GitProgressPhase::Receiving => Self::Receiving,
|
||||||
|
GitProgressPhase::Integrating | GitProgressPhase::Sending => Self::Integrating,
|
||||||
|
GitProgressPhase::Refreshing => Self::Finishing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileOnboardingProgress {
|
||||||
|
phase: MobileOnboardingPhase,
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileOnboardingProgress {
|
||||||
|
pub fn phase(&self) -> MobileOnboardingPhase {
|
||||||
|
self.phase
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
&self.detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MobileOnboardingOperation {
|
||||||
|
control: GitOperationControl,
|
||||||
|
phase: Arc<Mutex<MobileOnboardingPhase>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MobileOnboardingOperation {
|
||||||
|
fn default() -> Self {
|
||||||
|
let phase = Arc::new(Mutex::new(MobileOnboardingPhase::Validating));
|
||||||
|
let observed = Arc::clone(&phase);
|
||||||
|
Self {
|
||||||
|
control: GitOperationControl::new(move |phase| {
|
||||||
|
if let Ok(mut current) = observed.lock() {
|
||||||
|
*current = phase.into();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
phase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileOnboardingOperation {
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
self.control.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn progress(&self) -> MobileOnboardingProgress {
|
||||||
|
progress_copy(
|
||||||
|
self.phase
|
||||||
|
.lock()
|
||||||
|
.map_or(MobileOnboardingPhase::Validating, |phase| *phase),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover(
|
||||||
|
&self,
|
||||||
|
request: &MobileOnboardingRequest,
|
||||||
|
) -> Result<MobileOnboardingDiscovery, MobileOnboardingError> {
|
||||||
|
let paths = MobileOnboardingPaths::system()?;
|
||||||
|
paths.prepare_root()?;
|
||||||
|
let branches = GitRepository::discover_remote_branches(
|
||||||
|
&paths.root,
|
||||||
|
GitIdentity::ironstorage(),
|
||||||
|
&request.remote,
|
||||||
|
request,
|
||||||
|
&self.control,
|
||||||
|
)
|
||||||
|
.map_err(MobileOnboardingError::from_git)?;
|
||||||
|
let selected_branch = branches
|
||||||
|
.iter()
|
||||||
|
.position(|branch| branch == "main")
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(MobileOnboardingDiscovery {
|
||||||
|
branches,
|
||||||
|
selected_branch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup(
|
||||||
|
&self,
|
||||||
|
request: &MobileOnboardingRequest,
|
||||||
|
branch: &str,
|
||||||
|
use_existing: bool,
|
||||||
|
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||||
|
match Config::load(None) {
|
||||||
|
Ok(_) => return Err(MobileOnboardingError::already_configured()),
|
||||||
|
Err(ConfigError::NotFound { .. }) => {}
|
||||||
|
Err(error) => return Err(MobileOnboardingError::from_config(error)),
|
||||||
|
}
|
||||||
|
let paths = MobileOnboardingPaths::system()?;
|
||||||
|
paths.prepare_root()?;
|
||||||
|
let repository = if use_existing {
|
||||||
|
let repository = Repository::open(&paths.vault)
|
||||||
|
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
||||||
|
let git = GitRepository::open(&repository, GitIdentity::ironstorage())
|
||||||
|
.map_err(MobileOnboardingError::from_git)?;
|
||||||
|
let actual = git
|
||||||
|
.remote_url(request.remote.name().as_str())
|
||||||
|
.map_err(MobileOnboardingError::from_git)?;
|
||||||
|
if actual != request.remote.url().as_str()
|
||||||
|
|| git.current_branch().ok().as_deref() != Some(branch)
|
||||||
|
{
|
||||||
|
return Err(MobileOnboardingError::different_existing_clone());
|
||||||
|
}
|
||||||
|
repository
|
||||||
|
} else {
|
||||||
|
if paths
|
||||||
|
.vault
|
||||||
|
.read_dir()
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|mut entries| entries.next().is_some())
|
||||||
|
{
|
||||||
|
return Err(MobileOnboardingError::existing_clone());
|
||||||
|
}
|
||||||
|
GitRepository::clone_into_controlled(
|
||||||
|
&paths.vault,
|
||||||
|
GitIdentity::ironstorage(),
|
||||||
|
&request.remote,
|
||||||
|
branch,
|
||||||
|
request,
|
||||||
|
&self.control,
|
||||||
|
)
|
||||||
|
.map_err(MobileOnboardingError::from_git)?;
|
||||||
|
Repository::open(&paths.vault)
|
||||||
|
.map_err(|_| MobileOnboardingError::inaccessible_repository())?
|
||||||
|
};
|
||||||
|
self.control
|
||||||
|
.report(GitProgressPhase::Refreshing)
|
||||||
|
.map_err(MobileOnboardingError::from_git)?;
|
||||||
|
fs::create_dir_all(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?;
|
||||||
|
set_private_directory(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?;
|
||||||
|
let default_key = default_key(&repository)?;
|
||||||
|
store_application_token(request)?;
|
||||||
|
Config::create_mobile_clone(
|
||||||
|
paths.config,
|
||||||
|
&paths.vault,
|
||||||
|
&paths.keys,
|
||||||
|
&default_key,
|
||||||
|
&request.remote,
|
||||||
|
)
|
||||||
|
.map_err(MobileOnboardingError::from_config)?;
|
||||||
|
Ok(MobileOnboardingOutcome {
|
||||||
|
title: "Password Store Ready".to_owned(),
|
||||||
|
detail: format!("The {} branch is available locally.", branch),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MobileOnboardingRequest {
|
||||||
|
account: String,
|
||||||
|
token: SecretBytes,
|
||||||
|
remote: GitRemote,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileOnboardingRequest {
|
||||||
|
pub fn new(
|
||||||
|
server_url: String,
|
||||||
|
account: String,
|
||||||
|
repository_path: String,
|
||||||
|
token: Vec<u8>,
|
||||||
|
) -> Result<Self, MobileOnboardingError> {
|
||||||
|
let token = SecretBytes::new(token);
|
||||||
|
GitCredential::new(account.clone(), token.expose().to_vec())
|
||||||
|
.map_err(|_| MobileOnboardingError::invalid("Account or application token"))?;
|
||||||
|
let remote_url = repository_url(&server_url, &repository_path)?;
|
||||||
|
let server_id = stable_identifier("server", remote_origin(&remote_url).as_bytes());
|
||||||
|
let application_id = stable_identifier("repository", remote_url.as_str().as_bytes());
|
||||||
|
let remote = GitRemote::https("origin", remote_url.to_string(), server_id, application_id)
|
||||||
|
.map_err(MobileOnboardingError::from_config)?;
|
||||||
|
Ok(Self {
|
||||||
|
account,
|
||||||
|
token,
|
||||||
|
remote,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remote(&self) -> &GitRemote {
|
||||||
|
&self.remote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_configured_application_token(
|
||||||
|
account: String,
|
||||||
|
token: Vec<u8>,
|
||||||
|
) -> Result<(), MobileOnboardingError> {
|
||||||
|
let credential = GitCredential::new(account, token)
|
||||||
|
.map_err(|_| MobileOnboardingError::invalid("Account or application token"))?;
|
||||||
|
let config = Config::load(None).map_err(MobileOnboardingError::for_token_update)?;
|
||||||
|
let remote = config
|
||||||
|
.git_remotes()
|
||||||
|
.iter()
|
||||||
|
.find(|remote| remote.name().as_str() == "origin")
|
||||||
|
.ok_or_else(MobileOnboardingError::token_configuration)?;
|
||||||
|
store_git_credential(remote, &credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for MobileOnboardingRequest {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("MobileOnboardingRequest")
|
||||||
|
.field("account", &self.account)
|
||||||
|
.field("remote", &self.remote)
|
||||||
|
.field("token", &"[REDACTED]")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitCredentialProvider for MobileOnboardingRequest {
|
||||||
|
fn credential(
|
||||||
|
&self,
|
||||||
|
server: &crate::config::ServerId,
|
||||||
|
application: &crate::config::ApplicationId,
|
||||||
|
) -> Result<GitCredential, GitError> {
|
||||||
|
if server != self.remote.server_id() || application != self.remote.application_id() {
|
||||||
|
return Err(GitError::CredentialsUnavailable);
|
||||||
|
}
|
||||||
|
GitCredential::new(self.account.clone(), self.token.expose().to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileOnboardingDiscovery {
|
||||||
|
branches: Vec<String>,
|
||||||
|
selected_branch: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileOnboardingDiscovery {
|
||||||
|
pub fn branches(&self) -> &[String] {
|
||||||
|
&self.branches
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_branch(&self) -> usize {
|
||||||
|
self.selected_branch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileOnboardingOutcome {
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileOnboardingOutcome {
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
&self.detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MobileOnboardingErrorKind {
|
||||||
|
InvalidInput,
|
||||||
|
UnsupportedRemote,
|
||||||
|
Authentication,
|
||||||
|
Repository,
|
||||||
|
ExistingClone,
|
||||||
|
Interrupted,
|
||||||
|
SecureStorage,
|
||||||
|
Configuration,
|
||||||
|
AlreadyConfigured,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MobileOnboardingError {
|
||||||
|
kind: MobileOnboardingErrorKind,
|
||||||
|
title: &'static str,
|
||||||
|
detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileOnboardingError {
|
||||||
|
pub fn kind(&self) -> MobileOnboardingErrorKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
&self.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(field: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MobileOnboardingErrorKind::InvalidInput,
|
||||||
|
title: "Check Setup Details",
|
||||||
|
detail: format!("{field} is invalid."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn existing_clone() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MobileOnboardingErrorKind::ExistingClone,
|
||||||
|
title: "Local Clone Already Exists",
|
||||||
|
detail: "Choose Use Existing to open it without replacing any files.".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn different_existing_clone() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MobileOnboardingErrorKind::ExistingClone,
|
||||||
|
title: "Different Local Clone",
|
||||||
|
detail: "The existing clone uses a different remote or branch and was not changed."
|
||||||
|
.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inaccessible_repository() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MobileOnboardingErrorKind::Repository,
|
||||||
|
title: "Password Store Is Inaccessible",
|
||||||
|
detail: "The local password-store repository could not be opened.".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configuration() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MobileOnboardingErrorKind::Configuration,
|
||||||
|
title: "Setup Was Not Saved",
|
||||||
|
detail: "IronStorage could not save its local configuration.".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_configuration() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MobileOnboardingErrorKind::Configuration,
|
||||||
|
title: "Token Was Not Updated",
|
||||||
|
detail: "Set up the password store before replacing its application token.".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn for_token_update(_error: ConfigError) -> Self {
|
||||||
|
Self::token_configuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn already_configured() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MobileOnboardingErrorKind::AlreadyConfigured,
|
||||||
|
title: "IronStorage Is Already Configured",
|
||||||
|
detail: "The existing configuration and local clone were not replaced.".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_git(error: GitError) -> Self {
|
||||||
|
match error {
|
||||||
|
GitError::ForbiddenRemoteUrl => Self {
|
||||||
|
kind: MobileOnboardingErrorKind::UnsupportedRemote,
|
||||||
|
title: "HTTPS Required",
|
||||||
|
detail: "Use a credential-free HTTPS server URL. SSH and helper transports are not supported."
|
||||||
|
.to_owned(),
|
||||||
|
},
|
||||||
|
GitError::AuthenticationFailed
|
||||||
|
| GitError::CredentialsUnavailable
|
||||||
|
| GitError::CredentialAccessDenied
|
||||||
|
| GitError::CredentialCancelled => Self {
|
||||||
|
kind: MobileOnboardingErrorKind::Authentication,
|
||||||
|
title: "Authentication Failed",
|
||||||
|
detail: "Check the account and application token, then try again.".to_owned(),
|
||||||
|
},
|
||||||
|
GitError::Cancelled => Self {
|
||||||
|
kind: MobileOnboardingErrorKind::Interrupted,
|
||||||
|
title: "Setup Interrupted",
|
||||||
|
detail: "No existing local clone or configuration was replaced.".to_owned(),
|
||||||
|
},
|
||||||
|
GitError::DirtyWorktree => Self::existing_clone(),
|
||||||
|
GitError::NetworkUnavailable | GitError::TlsFailed => Self {
|
||||||
|
kind: MobileOnboardingErrorKind::Repository,
|
||||||
|
title: "Server Unavailable",
|
||||||
|
detail: "Check the HTTPS server and network connection, then try again.".to_owned(),
|
||||||
|
},
|
||||||
|
_ => Self {
|
||||||
|
kind: MobileOnboardingErrorKind::Repository,
|
||||||
|
title: "Repository Could Not Be Opened",
|
||||||
|
detail: "Check the repository path and selected branch, then try again.".to_owned(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_config(error: ConfigError) -> Self {
|
||||||
|
match error {
|
||||||
|
ConfigError::InvalidRemoteUrl { .. } => Self::from_git(GitError::ForbiddenRemoteUrl),
|
||||||
|
ConfigError::AlreadyConfigured { .. } => Self::already_configured(),
|
||||||
|
_ => Self::configuration(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_secret(error: SecretStoreError) -> Self {
|
||||||
|
let detail = match error {
|
||||||
|
SecretStoreError::Denied => "Access to secure token storage was denied.",
|
||||||
|
SecretStoreError::Cancelled => "Secure token storage was cancelled.",
|
||||||
|
_ => "Secure application-token storage is unavailable.",
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
kind: MobileOnboardingErrorKind::SecureStorage,
|
||||||
|
title: "Token Was Not Saved",
|
||||||
|
detail: detail.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MobileOnboardingError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(formatter, "{}: {}", self.title, self.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for MobileOnboardingError {}
|
||||||
|
|
||||||
|
struct MobileOnboardingPaths {
|
||||||
|
root: PathBuf,
|
||||||
|
config: PathBuf,
|
||||||
|
vault: PathBuf,
|
||||||
|
keys: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileOnboardingPaths {
|
||||||
|
fn system() -> Result<Self, MobileOnboardingError> {
|
||||||
|
let config = ConfigLoader::system()
|
||||||
|
.map_err(MobileOnboardingError::from_config)?
|
||||||
|
.default_path();
|
||||||
|
let root = config
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_owned)
|
||||||
|
.ok_or_else(MobileOnboardingError::configuration)?;
|
||||||
|
Ok(Self {
|
||||||
|
vault: root.join("vault"),
|
||||||
|
keys: root.join("keys"),
|
||||||
|
root,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_root(&self) -> Result<(), MobileOnboardingError> {
|
||||||
|
fs::create_dir_all(&self.root).map_err(|_| MobileOnboardingError::configuration())?;
|
||||||
|
set_private_directory(&self.root).map_err(|_| MobileOnboardingError::configuration())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repository_url(server_url: &str, repository_path: &str) -> Result<Url, MobileOnboardingError> {
|
||||||
|
let mut server =
|
||||||
|
Url::parse(server_url).map_err(|_| MobileOnboardingError::invalid("HTTPS server URL"))?;
|
||||||
|
if server.scheme() != "https"
|
||||||
|
|| server.host_str().is_none()
|
||||||
|
|| !server.username().is_empty()
|
||||||
|
|| server.password().is_some()
|
||||||
|
|| server.query().is_some()
|
||||||
|
|| server.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err(MobileOnboardingError::from_git(
|
||||||
|
GitError::ForbiddenRemoteUrl,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let parts = repository_path.split('/').collect::<Vec<_>>();
|
||||||
|
if parts.len() < 2
|
||||||
|
|| parts.iter().any(|part| {
|
||||||
|
part.is_empty()
|
||||||
|
|| matches!(*part, "." | "..")
|
||||||
|
|| !part
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(MobileOnboardingError::invalid("Repository path"));
|
||||||
|
}
|
||||||
|
let mut path = server.path().trim_end_matches('/').to_owned();
|
||||||
|
path.push('/');
|
||||||
|
path.push_str(&parts.join("/"));
|
||||||
|
if !path.ends_with(".git") {
|
||||||
|
path.push_str(".git");
|
||||||
|
}
|
||||||
|
server.set_path(&path);
|
||||||
|
Ok(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_origin(url: &Url) -> String {
|
||||||
|
format!(
|
||||||
|
"{}://{}:{}",
|
||||||
|
url.scheme(),
|
||||||
|
url.host_str().unwrap_or_default(),
|
||||||
|
url.port_or_known_default().unwrap_or(443)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stable_identifier(prefix: &str, value: &[u8]) -> String {
|
||||||
|
let digest = Sha256::digest(value);
|
||||||
|
format!("{prefix}-{}", hex_prefix(&digest, 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_prefix(bytes: &[u8], length: usize) -> String {
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.flat_map(|byte| [HEX[(byte >> 4) as usize], HEX[(byte & 0x0f) as usize]])
|
||||||
|
.take(length)
|
||||||
|
.map(char::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_key(repository: &Repository) -> Result<String, MobileOnboardingError> {
|
||||||
|
let root =
|
||||||
|
DirectoryPath::parse("").map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
||||||
|
let contents = repository
|
||||||
|
.read_policy_file(&root, false)
|
||||||
|
.map_err(|_| MobileOnboardingError::inaccessible_repository())?
|
||||||
|
.ok_or_else(MobileOnboardingError::inaccessible_repository)?;
|
||||||
|
let text = std::str::from_utf8(&contents)
|
||||||
|
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
||||||
|
text.lines()
|
||||||
|
.map(|line| line.split('#').next().unwrap_or_default().trim())
|
||||||
|
.find(|identity| !identity.is_empty())
|
||||||
|
.filter(|identity| identity.len() <= 512 && !identity.chars().any(char::is_control))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or_else(MobileOnboardingError::inaccessible_repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_application_token(request: &MobileOnboardingRequest) -> Result<(), MobileOnboardingError> {
|
||||||
|
let credential = request
|
||||||
|
.credential(request.remote.server_id(), request.remote.application_id())
|
||||||
|
.map_err(MobileOnboardingError::from_git)?;
|
||||||
|
store_git_credential(&request.remote, &credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_git_credential(
|
||||||
|
remote: &GitRemote,
|
||||||
|
credential: &GitCredential,
|
||||||
|
) -> Result<(), MobileOnboardingError> {
|
||||||
|
let store = NativeSecretStore::system(
|
||||||
|
SecretCachePolicy::Disabled,
|
||||||
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
|
)
|
||||||
|
.map_err(MobileOnboardingError::from_secret)?;
|
||||||
|
store.unlock().map_err(MobileOnboardingError::from_secret)?;
|
||||||
|
store
|
||||||
|
.store_https_git_credential(
|
||||||
|
remote.server_id(),
|
||||||
|
remote.application_id(),
|
||||||
|
credential.username(),
|
||||||
|
SecretBytes::new(credential.password().to_vec()),
|
||||||
|
)
|
||||||
|
.map_err(MobileOnboardingError::from_secret)?;
|
||||||
|
store.lock().map_err(MobileOnboardingError::from_secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn progress_copy(phase: MobileOnboardingPhase) -> MobileOnboardingProgress {
|
||||||
|
let (title, detail) = match phase {
|
||||||
|
MobileOnboardingPhase::Validating => ("Checking Setup", "Validating the HTTPS repository."),
|
||||||
|
MobileOnboardingPhase::Authenticating => (
|
||||||
|
"Authenticating",
|
||||||
|
"Using the application token from protected memory.",
|
||||||
|
),
|
||||||
|
MobileOnboardingPhase::Receiving => {
|
||||||
|
("Downloading Store", "Receiving Git objects securely.")
|
||||||
|
}
|
||||||
|
MobileOnboardingPhase::Integrating => ("Opening Store", "Preparing the selected branch."),
|
||||||
|
MobileOnboardingPhase::Finishing => ("Finishing Setup", "Saving protected local state."),
|
||||||
|
};
|
||||||
|
MobileOnboardingProgress {
|
||||||
|
phase,
|
||||||
|
title: title.to_owned(),
|
||||||
|
detail: detail.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_private_directory(path: &Path) -> std::io::Result<()> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = path;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
MobileOnboardingErrorKind, MobileOnboardingOperation, MobileOnboardingPhase,
|
||||||
|
MobileOnboardingRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_is_https_only_stable_and_secret_safe() {
|
||||||
|
let request = MobileOnboardingRequest::new(
|
||||||
|
"https://example.test/gitea".to_owned(),
|
||||||
|
"alice".to_owned(),
|
||||||
|
"team/passwords".to_owned(),
|
||||||
|
b"DO-NOT-RENDER".to_vec(),
|
||||||
|
)
|
||||||
|
.expect("valid request");
|
||||||
|
assert_eq!(
|
||||||
|
request.remote().url().as_str(),
|
||||||
|
"https://example.test/gitea/team/passwords.git"
|
||||||
|
);
|
||||||
|
assert!(!format!("{request:?}").contains("DO-NOT-RENDER"));
|
||||||
|
let error = MobileOnboardingRequest::new(
|
||||||
|
"ssh://example.test".to_owned(),
|
||||||
|
"alice".to_owned(),
|
||||||
|
"team/passwords".to_owned(),
|
||||||
|
b"DO-NOT-RENDER".to_vec(),
|
||||||
|
)
|
||||||
|
.expect_err("SSH must be rejected");
|
||||||
|
assert_eq!(error.kind(), MobileOnboardingErrorKind::UnsupportedRemote);
|
||||||
|
assert!(!error.to_string().contains("DO-NOT-RENDER"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_cancellation_is_visible_before_transport_work() {
|
||||||
|
let operation = MobileOnboardingOperation::default();
|
||||||
|
assert_eq!(
|
||||||
|
operation.progress().phase(),
|
||||||
|
MobileOnboardingPhase::Validating
|
||||||
|
);
|
||||||
|
operation.cancel();
|
||||||
|
assert!(operation.control.is_cancelled());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -474,6 +474,47 @@ impl<B: SecretStoreBackend> SecretStore<B> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create or replace one HTTPS Git account/token record without exposing
|
||||||
|
/// the previously stored account or token to a frontend.
|
||||||
|
pub fn store_https_git_credential(
|
||||||
|
&self,
|
||||||
|
server: &ServerId,
|
||||||
|
application: &ApplicationId,
|
||||||
|
account: impl Into<String>,
|
||||||
|
value: SecretBytes,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
validate_secret(&value)?;
|
||||||
|
let reference =
|
||||||
|
SecretReference::https_git_credential(server.as_str(), application.as_str(), account)?;
|
||||||
|
let locator = reference.locator();
|
||||||
|
let encoded = encode_record(&reference, &value)?;
|
||||||
|
let mut state = self.unlocked_state()?;
|
||||||
|
match self
|
||||||
|
.backend
|
||||||
|
.retrieve(&locator, self.protections.for_reference(&reference))
|
||||||
|
{
|
||||||
|
Ok(existing) => {
|
||||||
|
let existing = decode_record(existing)?;
|
||||||
|
if existing.reference.locator() != locator {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
self.backend.replace(
|
||||||
|
&locator,
|
||||||
|
self.protections.for_reference(&reference),
|
||||||
|
encoded.expose(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Err(SecretStoreError::Missing) => self.backend.create(
|
||||||
|
&locator,
|
||||||
|
self.protections.for_reference(&reference),
|
||||||
|
encoded.expose(),
|
||||||
|
)?,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
self.cache_insert(&mut state, locator, encoded);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> {
|
pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> {
|
||||||
let locator = reference.locator();
|
let locator = reference.locator();
|
||||||
let mut state = self.unlocked_state()?;
|
let mut state = self.unlocked_state()?;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use std::{
|
|||||||
error::Error,
|
error::Error,
|
||||||
fs,
|
fs,
|
||||||
io::Cursor,
|
io::Cursor,
|
||||||
path::Path,
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,6 +204,22 @@ impl GitFetchTransport for CloningFetch {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct OccupyingFetch(PathBuf);
|
||||||
|
|
||||||
|
impl GitFetchTransport for OccupyingFetch {
|
||||||
|
fn fetch(
|
||||||
|
&self,
|
||||||
|
repository: &GitRepository,
|
||||||
|
configured: &GitRemote,
|
||||||
|
credential: &GitCredential,
|
||||||
|
) -> Result<bool, GitError> {
|
||||||
|
let fetched = CloningFetch.fetch(repository, configured, credential)?;
|
||||||
|
fs::write(self.0.join("keep"), b"concurrent contents")
|
||||||
|
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
|
||||||
|
Ok(fetched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct NoopFetch;
|
struct NoopFetch;
|
||||||
|
|
||||||
impl GitFetchTransport for NoopFetch {
|
impl GitFetchTransport for NoopFetch {
|
||||||
@@ -547,6 +563,102 @@ fn clone_uses_a_private_directory_and_injected_fetch_transport() -> TestResult {
|
|||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn signed_commits_have_a_verifiable_ascii_armored_gpgsig() -> TestResult {
|
fn signed_commits_have_a_verifiable_ascii_armored_gpgsig() -> TestResult {
|
||||||
let fixture = FixtureSet::load()?;
|
let fixture = FixtureSet::load()?;
|
||||||
|
|||||||
@@ -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())?;
|
let credential = store.credential(remote.server_id(), remote.application_id())?;
|
||||||
assert_eq!(credential.username(), "alice");
|
assert_eq!(credential.username(), "alice");
|
||||||
assert_eq!(credential.password(), b"https-token");
|
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!(
|
assert!(
|
||||||
backend
|
backend
|
||||||
.protections()
|
.protections()
|
||||||
|
|||||||
Reference in New Issue
Block a user