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.
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
fileprivate struct FfiConverterUInt32: FfiConverterPrimitive {
|
||||
typealias FfiType = UInt32
|
||||
typealias SwiftType = UInt32
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt32 {
|
||||
return try lift(readInt(&buf))
|
||||
}
|
||||
|
||||
public static func write(_ value: SwiftType, into buf: inout [UInt8]) {
|
||||
writeInt(&buf, lower(value))
|
||||
}
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
fileprivate struct FfiConverterBool : FfiConverter {
|
||||
typealias FfiType = Int8
|
||||
typealias SwiftType = Bool
|
||||
|
||||
public static func lift(_ value: Int8) throws -> Bool {
|
||||
return value != 0
|
||||
}
|
||||
|
||||
public static func lower(_ value: Bool) -> Int8 {
|
||||
return value ? 1 : 0
|
||||
}
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Bool {
|
||||
return try lift(readInt(&buf))
|
||||
}
|
||||
|
||||
public static func write(_ value: Bool, into buf: inout [UInt8]) {
|
||||
writeInt(&buf, lower(value))
|
||||
}
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
@@ -509,6 +549,323 @@ fileprivate struct FfiConverterString: FfiConverter {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public protocol MobileOnboardingOperationProtocol: AnyObject, Sendable {
|
||||
|
||||
func cancel()
|
||||
|
||||
func discover() throws -> MobileOnboardingDiscovery
|
||||
|
||||
func progress() -> MobileOnboardingProgress
|
||||
|
||||
func setup(branch: String, useExisting: Bool) throws -> MobileOnboardingOutcome
|
||||
|
||||
}
|
||||
open class MobileOnboardingOperation: MobileOnboardingOperationProtocol, @unchecked Sendable {
|
||||
fileprivate let handle: UInt64
|
||||
|
||||
/// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly.
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct NoHandle {
|
||||
public init() {}
|
||||
}
|
||||
|
||||
// TODO: We'd like this to be `private` but for Swifty reasons,
|
||||
// we can't implement `FfiConverter` without making this `required` and we can't
|
||||
// make it `required` without making it `public`.
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
required public init(unsafeFromHandle handle: UInt64) {
|
||||
self.handle = handle
|
||||
}
|
||||
|
||||
// This constructor can be used to instantiate a fake object.
|
||||
// - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject].
|
||||
//
|
||||
// - Warning:
|
||||
// Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash.
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public init(noHandle: NoHandle) {
|
||||
self.handle = 0
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func uniffiCloneHandle() -> UInt64 {
|
||||
return try! rustCall { uniffi_ironstorage_apple_fn_clone_mobileonboardingoperation(self.handle, $0) }
|
||||
}
|
||||
// No primary constructor declared for this class.
|
||||
|
||||
deinit {
|
||||
if handle == 0 {
|
||||
// Mock objects have handle=0 don't try to free them
|
||||
return
|
||||
}
|
||||
|
||||
try! rustCall { uniffi_ironstorage_apple_fn_free_mobileonboardingoperation(handle, $0) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
open func cancel() {try! rustCall() {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_cancel(
|
||||
self.uniffiCloneHandle(),uniffiCallStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
open func discover()throws -> MobileOnboardingDiscovery {
|
||||
return try FfiConverterTypeMobileOnboardingDiscovery_lift(try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_discover(
|
||||
self.uniffiCloneHandle(),uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
open func progress() -> MobileOnboardingProgress {
|
||||
return try! FfiConverterTypeMobileOnboardingProgress_lift(try! rustCall() {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_progress(
|
||||
self.uniffiCloneHandle(),uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
open func setup(branch: String, useExisting: Bool)throws -> MobileOnboardingOutcome {
|
||||
return try FfiConverterTypeMobileOnboardingOutcome_lift(try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_setup(
|
||||
self.uniffiCloneHandle(),
|
||||
FfiConverterString.lower(branch),
|
||||
FfiConverterBool.lower(useExisting),uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileOnboardingOperation: FfiConverter {
|
||||
typealias FfiType = UInt64
|
||||
typealias SwiftType = MobileOnboardingOperation
|
||||
|
||||
public static func lift(_ handle: UInt64) throws -> MobileOnboardingOperation {
|
||||
return MobileOnboardingOperation(unsafeFromHandle: handle)
|
||||
}
|
||||
|
||||
public static func lower(_ value: MobileOnboardingOperation) -> UInt64 {
|
||||
return value.uniffiCloneHandle()
|
||||
}
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingOperation {
|
||||
let handle: UInt64 = try readInt(&buf)
|
||||
return try lift(handle)
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileOnboardingOperation, into buf: inout [UInt8]) {
|
||||
writeInt(&buf, lower(value))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingOperation_lift(_ handle: UInt64) throws -> MobileOnboardingOperation {
|
||||
return try FfiConverterTypeMobileOnboardingOperation.lift(handle)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingOperation_lower(_ value: MobileOnboardingOperation) -> UInt64 {
|
||||
return FfiConverterTypeMobileOnboardingOperation.lower(value)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public struct MobileOnboardingDiscovery: Equatable, Hashable {
|
||||
public var branches: [String]
|
||||
public var selectedBranch: UInt32
|
||||
|
||||
// Default memberwise initializers are never public by default, so we
|
||||
// declare one manually.
|
||||
public init(branches: [String], selectedBranch: UInt32) {
|
||||
self.branches = branches
|
||||
self.selectedBranch = selectedBranch
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#if compiler(>=6)
|
||||
extension MobileOnboardingDiscovery: Sendable {}
|
||||
#endif
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileOnboardingDiscovery: FfiConverterRustBuffer {
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingDiscovery {
|
||||
return
|
||||
try MobileOnboardingDiscovery(
|
||||
branches: FfiConverterSequenceString.read(from: &buf),
|
||||
selectedBranch: FfiConverterUInt32.read(from: &buf)
|
||||
)
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileOnboardingDiscovery, into buf: inout [UInt8]) {
|
||||
FfiConverterSequenceString.write(value.branches, into: &buf)
|
||||
FfiConverterUInt32.write(value.selectedBranch, into: &buf)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingDiscovery_lift(_ buf: RustBuffer) throws -> MobileOnboardingDiscovery {
|
||||
return try FfiConverterTypeMobileOnboardingDiscovery.lift(buf)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingDiscovery_lower(_ value: MobileOnboardingDiscovery) -> RustBuffer {
|
||||
return FfiConverterTypeMobileOnboardingDiscovery.lower(value)
|
||||
}
|
||||
|
||||
|
||||
public struct MobileOnboardingOutcome: Equatable, Hashable {
|
||||
public var title: String
|
||||
public var detail: String
|
||||
|
||||
// Default memberwise initializers are never public by default, so we
|
||||
// declare one manually.
|
||||
public init(title: String, detail: String) {
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#if compiler(>=6)
|
||||
extension MobileOnboardingOutcome: Sendable {}
|
||||
#endif
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileOnboardingOutcome: FfiConverterRustBuffer {
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingOutcome {
|
||||
return
|
||||
try MobileOnboardingOutcome(
|
||||
title: FfiConverterString.read(from: &buf),
|
||||
detail: FfiConverterString.read(from: &buf)
|
||||
)
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileOnboardingOutcome, into buf: inout [UInt8]) {
|
||||
FfiConverterString.write(value.title, into: &buf)
|
||||
FfiConverterString.write(value.detail, into: &buf)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingOutcome_lift(_ buf: RustBuffer) throws -> MobileOnboardingOutcome {
|
||||
return try FfiConverterTypeMobileOnboardingOutcome.lift(buf)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingOutcome_lower(_ value: MobileOnboardingOutcome) -> RustBuffer {
|
||||
return FfiConverterTypeMobileOnboardingOutcome.lower(value)
|
||||
}
|
||||
|
||||
|
||||
public struct MobileOnboardingProgress: Equatable, Hashable {
|
||||
public var phase: MobileOnboardingPhase
|
||||
public var title: String
|
||||
public var detail: String
|
||||
|
||||
// Default memberwise initializers are never public by default, so we
|
||||
// declare one manually.
|
||||
public init(phase: MobileOnboardingPhase, title: String, detail: String) {
|
||||
self.phase = phase
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#if compiler(>=6)
|
||||
extension MobileOnboardingProgress: Sendable {}
|
||||
#endif
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileOnboardingProgress: FfiConverterRustBuffer {
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingProgress {
|
||||
return
|
||||
try MobileOnboardingProgress(
|
||||
phase: FfiConverterTypeMobileOnboardingPhase.read(from: &buf),
|
||||
title: FfiConverterString.read(from: &buf),
|
||||
detail: FfiConverterString.read(from: &buf)
|
||||
)
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileOnboardingProgress, into buf: inout [UInt8]) {
|
||||
FfiConverterTypeMobileOnboardingPhase.write(value.phase, into: &buf)
|
||||
FfiConverterString.write(value.title, into: &buf)
|
||||
FfiConverterString.write(value.detail, into: &buf)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingProgress_lift(_ buf: RustBuffer) throws -> MobileOnboardingProgress {
|
||||
return try FfiConverterTypeMobileOnboardingProgress.lift(buf)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingProgress_lower(_ value: MobileOnboardingProgress) -> RustBuffer {
|
||||
return FfiConverterTypeMobileOnboardingProgress.lower(value)
|
||||
}
|
||||
|
||||
|
||||
public struct MobilePage: Equatable, Hashable {
|
||||
public var tab: MobileTab
|
||||
public var title: String
|
||||
@@ -637,6 +994,287 @@ public func FfiConverterTypeMobileShell_lower(_ value: MobileShell) -> RustBuffe
|
||||
}
|
||||
|
||||
|
||||
|
||||
public enum MobileOnboardingErrorKind: Equatable, Hashable {
|
||||
|
||||
case invalidInput
|
||||
case unsupportedRemote
|
||||
case authentication
|
||||
case repository
|
||||
case existingClone
|
||||
case interrupted
|
||||
case secureStorage
|
||||
case configuration
|
||||
case alreadyConfigured
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#if compiler(>=6)
|
||||
extension MobileOnboardingErrorKind: Sendable {}
|
||||
#endif
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileOnboardingErrorKind: FfiConverterRustBuffer {
|
||||
typealias SwiftType = MobileOnboardingErrorKind
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingErrorKind {
|
||||
let variant: Int32 = try readInt(&buf)
|
||||
switch variant {
|
||||
|
||||
case 1: return .invalidInput
|
||||
|
||||
case 2: return .unsupportedRemote
|
||||
|
||||
case 3: return .authentication
|
||||
|
||||
case 4: return .repository
|
||||
|
||||
case 5: return .existingClone
|
||||
|
||||
case 6: return .interrupted
|
||||
|
||||
case 7: return .secureStorage
|
||||
|
||||
case 8: return .configuration
|
||||
|
||||
case 9: return .alreadyConfigured
|
||||
|
||||
default: throw UniffiInternalError.unexpectedEnumCase
|
||||
}
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileOnboardingErrorKind, into buf: inout [UInt8]) {
|
||||
switch value {
|
||||
|
||||
|
||||
case .invalidInput:
|
||||
writeInt(&buf, Int32(1))
|
||||
|
||||
|
||||
case .unsupportedRemote:
|
||||
writeInt(&buf, Int32(2))
|
||||
|
||||
|
||||
case .authentication:
|
||||
writeInt(&buf, Int32(3))
|
||||
|
||||
|
||||
case .repository:
|
||||
writeInt(&buf, Int32(4))
|
||||
|
||||
|
||||
case .existingClone:
|
||||
writeInt(&buf, Int32(5))
|
||||
|
||||
|
||||
case .interrupted:
|
||||
writeInt(&buf, Int32(6))
|
||||
|
||||
|
||||
case .secureStorage:
|
||||
writeInt(&buf, Int32(7))
|
||||
|
||||
|
||||
case .configuration:
|
||||
writeInt(&buf, Int32(8))
|
||||
|
||||
|
||||
case .alreadyConfigured:
|
||||
writeInt(&buf, Int32(9))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingErrorKind_lift(_ buf: RustBuffer) throws -> MobileOnboardingErrorKind {
|
||||
return try FfiConverterTypeMobileOnboardingErrorKind.lift(buf)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingErrorKind_lower(_ value: MobileOnboardingErrorKind) -> RustBuffer {
|
||||
return FfiConverterTypeMobileOnboardingErrorKind.lower(value)
|
||||
}
|
||||
|
||||
|
||||
|
||||
public
|
||||
enum MobileOnboardingFfiError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError {
|
||||
|
||||
|
||||
|
||||
case Failed(kind: MobileOnboardingErrorKind, title: String, detail: String
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public var errorDescription: String? {
|
||||
String(reflecting: self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if compiler(>=6)
|
||||
extension MobileOnboardingFfiError: Sendable {}
|
||||
#endif
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileOnboardingFfiError: FfiConverterRustBuffer {
|
||||
typealias SwiftType = MobileOnboardingFfiError
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingFfiError {
|
||||
let variant: Int32 = try readInt(&buf)
|
||||
switch variant {
|
||||
|
||||
|
||||
|
||||
|
||||
case 1: return .Failed(
|
||||
kind: try FfiConverterTypeMobileOnboardingErrorKind.read(from: &buf),
|
||||
title: try FfiConverterString.read(from: &buf),
|
||||
detail: try FfiConverterString.read(from: &buf)
|
||||
)
|
||||
|
||||
default: throw UniffiInternalError.unexpectedEnumCase
|
||||
}
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileOnboardingFfiError, into buf: inout [UInt8]) {
|
||||
switch value {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
case let .Failed(kind,title,detail):
|
||||
writeInt(&buf, Int32(1))
|
||||
FfiConverterTypeMobileOnboardingErrorKind.write(kind, into: &buf)
|
||||
FfiConverterString.write(title, into: &buf)
|
||||
FfiConverterString.write(detail, into: &buf)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingFfiError_lift(_ buf: RustBuffer) throws -> MobileOnboardingFfiError {
|
||||
return try FfiConverterTypeMobileOnboardingFfiError.lift(buf)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingFfiError_lower(_ value: MobileOnboardingFfiError) -> RustBuffer {
|
||||
return FfiConverterTypeMobileOnboardingFfiError.lower(value)
|
||||
}
|
||||
|
||||
|
||||
|
||||
public enum MobileOnboardingPhase: Equatable, Hashable {
|
||||
|
||||
case validating
|
||||
case authenticating
|
||||
case receiving
|
||||
case integrating
|
||||
case finishing
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#if compiler(>=6)
|
||||
extension MobileOnboardingPhase: Sendable {}
|
||||
#endif
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public struct FfiConverterTypeMobileOnboardingPhase: FfiConverterRustBuffer {
|
||||
typealias SwiftType = MobileOnboardingPhase
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileOnboardingPhase {
|
||||
let variant: Int32 = try readInt(&buf)
|
||||
switch variant {
|
||||
|
||||
case 1: return .validating
|
||||
|
||||
case 2: return .authenticating
|
||||
|
||||
case 3: return .receiving
|
||||
|
||||
case 4: return .integrating
|
||||
|
||||
case 5: return .finishing
|
||||
|
||||
default: throw UniffiInternalError.unexpectedEnumCase
|
||||
}
|
||||
}
|
||||
|
||||
public static func write(_ value: MobileOnboardingPhase, into buf: inout [UInt8]) {
|
||||
switch value {
|
||||
|
||||
|
||||
case .validating:
|
||||
writeInt(&buf, Int32(1))
|
||||
|
||||
|
||||
case .authenticating:
|
||||
writeInt(&buf, Int32(2))
|
||||
|
||||
|
||||
case .receiving:
|
||||
writeInt(&buf, Int32(3))
|
||||
|
||||
|
||||
case .integrating:
|
||||
writeInt(&buf, Int32(4))
|
||||
|
||||
|
||||
case .finishing:
|
||||
writeInt(&buf, Int32(5))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingPhase_lift(_ buf: RustBuffer) throws -> MobileOnboardingPhase {
|
||||
return try FfiConverterTypeMobileOnboardingPhase.lift(buf)
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
public func FfiConverterTypeMobileOnboardingPhase_lower(_ value: MobileOnboardingPhase) -> RustBuffer {
|
||||
return FfiConverterTypeMobileOnboardingPhase.lower(value)
|
||||
}
|
||||
|
||||
|
||||
|
||||
public
|
||||
enum MobilePreferenceError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError {
|
||||
|
||||
@@ -878,6 +1516,31 @@ public func FfiConverterTypeMobileTab_lower(_ value: MobileTab) -> RustBuffer {
|
||||
}
|
||||
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer {
|
||||
typealias SwiftType = [String]
|
||||
|
||||
public static func write(_ value: [String], into buf: inout [UInt8]) {
|
||||
let len = Int32(value.count)
|
||||
writeInt(&buf, len)
|
||||
for item in value {
|
||||
FfiConverterString.write(item, into: &buf)
|
||||
}
|
||||
}
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [String] {
|
||||
let len: Int32 = try readInt(&buf)
|
||||
var seq = [String]()
|
||||
seq.reserveCapacity(Int(len))
|
||||
for _ in 0 ..< len {
|
||||
seq.append(try FfiConverterString.read(from: &buf))
|
||||
}
|
||||
return seq
|
||||
}
|
||||
}
|
||||
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
@@ -902,6 +1565,17 @@ fileprivate struct FfiConverterSequenceTypeMobilePage: FfiConverterRustBuffer {
|
||||
return seq
|
||||
}
|
||||
}
|
||||
public func mobileOnboardingOperation(serverUrl: String, account: String, repositoryPath: String, applicationToken: String)throws -> MobileOnboardingOperation {
|
||||
return try FfiConverterTypeMobileOnboardingOperation_lift(try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_func_mobile_onboarding_operation(
|
||||
FfiConverterString.lower(serverUrl),
|
||||
FfiConverterString.lower(account),
|
||||
FfiConverterString.lower(repositoryPath),
|
||||
FfiConverterString.lower(applicationToken),uniffiCallStatus
|
||||
)
|
||||
})
|
||||
}
|
||||
public func mobileShell() -> MobileShell {
|
||||
return try! FfiConverterTypeMobileShell_lift(try! rustCall() {
|
||||
uniffiCallStatus in
|
||||
@@ -924,6 +1598,14 @@ public func productName() -> String {
|
||||
)
|
||||
})
|
||||
}
|
||||
public func replaceConfiguredMobileApplicationToken(account: String, applicationToken: String)throws {try rustCallWithError(FfiConverterTypeMobileOnboardingFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_func_replace_configured_mobile_application_token(
|
||||
FfiConverterString.lower(account),
|
||||
FfiConverterString.lower(applicationToken),uniffiCallStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
public func setSelectedMobileTab(tab: MobileTab)throws {try rustCallWithError(FfiConverterTypeMobilePreferenceError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_apple_fn_func_set_selected_mobile_tab(
|
||||
@@ -947,6 +1629,9 @@ private let initializationResult: InitializationResult = {
|
||||
if bindings_contract_version != scaffolding_contract_version {
|
||||
return InitializationResult.contractVersionMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation() != 8354) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_func_mobile_shell() != 42687) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
@@ -956,9 +1641,24 @@ private let initializationResult: InitializationResult = {
|
||||
if (uniffi_ironstorage_apple_checksum_func_product_name() != 43533) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_func_replace_configured_mobile_application_token() != 32386) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab() != 65280) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_cancel() != 49755) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_discover() != 2309) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_progress() != 28201) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_setup() != 3525) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
|
||||
return InitializationResult.ok
|
||||
}()
|
||||
|
||||
@@ -242,6 +242,41 @@ typedef struct UniffiForeignFutureResultVoid {
|
||||
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
|
||||
uint64_t uniffi_ironstorage_apple_fn_clone_mobileonboardingoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEONBOARDINGOPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEONBOARDINGOPERATION
|
||||
void uniffi_ironstorage_apple_fn_free_mobileonboardingoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
|
||||
void uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_discover(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_PROGRESS
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_PROGRESS
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_progress(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_setup(uint64_t ptr, RustBuffer branch, int8_t use_existing, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_ONBOARDING_OPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_ONBOARDING_OPERATION
|
||||
uint64_t uniffi_ironstorage_apple_fn_func_mobile_onboarding_operation(RustBuffer server_url, RustBuffer account, RustBuffer repository_path, RustBuffer application_token, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL
|
||||
@@ -258,6 +293,11 @@ RustBuffer uniffi_ironstorage_apple_fn_func_mobile_shell_fixture(RustBuffer stat
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_PRODUCT_NAME
|
||||
RustBuffer uniffi_ironstorage_apple_fn_func_product_name(RustCallStatus *_Nonnull out_status
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_REPLACE_CONFIGURED_MOBILE_APPLICATION_TOKEN
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_REPLACE_CONFIGURED_MOBILE_APPLICATION_TOKEN
|
||||
void uniffi_ironstorage_apple_fn_func_replace_configured_mobile_application_token(RustBuffer account, RustBuffer application_token, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_SET_SELECTED_MOBILE_TAB
|
||||
@@ -523,6 +563,12 @@ void ffi_ironstorage_apple_rust_future_free_void(uint64_t handle
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
|
||||
void ffi_ironstorage_apple_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION
|
||||
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_onboarding_operation(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL
|
||||
@@ -541,12 +587,42 @@ uint16_t uniffi_ironstorage_apple_checksum_func_mobile_shell_fixture(void
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_PRODUCT_NAME
|
||||
uint16_t uniffi_ironstorage_apple_checksum_func_product_name(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_REPLACE_CONFIGURED_MOBILE_APPLICATION_TOKEN
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_REPLACE_CONFIGURED_MOBILE_APPLICATION_TOKEN
|
||||
uint16_t uniffi_ironstorage_apple_checksum_func_replace_configured_mobile_application_token(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB
|
||||
uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_cancel(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_discover(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_PROGRESS
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_PROGRESS
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_progress(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_SETUP
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_SETUP
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_setup(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_UNIFFI_CONTRACT_VERSION
|
||||
|
||||
@@ -167,6 +167,23 @@ private final class ShellViewController: UITableViewController {
|
||||
private func apply(_ page: MobilePage) {
|
||||
self.page = page
|
||||
title = page.title
|
||||
if page.state == .empty, shellTab == .home {
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "Set Up",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(setupRequested)
|
||||
)
|
||||
} else if page.state == .ready, shellTab == .preferences {
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "Update Token",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(tokenUpdateRequested)
|
||||
)
|
||||
} else {
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
}
|
||||
refreshControl?.endRefreshing()
|
||||
tableView.reloadData()
|
||||
|
||||
@@ -183,6 +200,14 @@ private final class ShellViewController: UITableViewController {
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
|
||||
@objc private func setupRequested() {
|
||||
navigationController?.pushViewController(OnboardingViewController(), animated: true)
|
||||
}
|
||||
|
||||
@objc private func tokenUpdateRequested() {
|
||||
navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
|
||||
}
|
||||
|
||||
private func stateImage(_ state: MobileShellState) -> String {
|
||||
switch state {
|
||||
case .loading: "hourglass"
|
||||
@@ -193,3 +218,482 @@ private final class ShellViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TokenUpdateViewController: UITableViewController {
|
||||
private let accountField = UITextField()
|
||||
private let tokenField = UITextField()
|
||||
private var updateTask: Task<Void, Never>?
|
||||
private var generation = 0
|
||||
private var isWorking = false
|
||||
|
||||
init() {
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Update Token"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
for field in [accountField, tokenField] {
|
||||
field.borderStyle = .none
|
||||
field.clearButtonMode = .whileEditing
|
||||
field.autocorrectionType = .no
|
||||
field.autocapitalizationType = .none
|
||||
field.adjustsFontForContentSizeCategory = true
|
||||
field.font = .preferredFont(forTextStyle: .body)
|
||||
}
|
||||
accountField.placeholder = "Account name"
|
||||
tokenField.placeholder = "New application token"
|
||||
tokenField.textContentType = .oneTimeCode
|
||||
tokenField.isSecureTextEntry = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
deinit {
|
||||
updateTask?.cancel()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
generation += 1
|
||||
updateTask?.cancel()
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 2 : 1
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForHeaderInSection section: Int
|
||||
) -> String? {
|
||||
section == 0 ? "HTTPS Credential" : nil
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
section == 0
|
||||
? "The existing token is never displayed. Updating replaces it only in protected system storage."
|
||||
: nil
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||
if indexPath.section == 1 {
|
||||
cell.textLabel?.text = isWorking ? "Updating…" : "Replace Token"
|
||||
cell.textLabel?.textColor = isWorking ? .secondaryLabel : view.tintColor
|
||||
cell.textLabel?.textAlignment = .center
|
||||
cell.isUserInteractionEnabled = !isWorking
|
||||
return cell
|
||||
}
|
||||
let field = indexPath.row == 0 ? accountField : tokenField
|
||||
field.translatesAutoresizingMaskIntoConstraints = false
|
||||
field.isEnabled = !isWorking
|
||||
cell.contentView.addSubview(field)
|
||||
NSLayoutConstraint.activate([
|
||||
field.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
|
||||
field.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
|
||||
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 10),
|
||||
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -10)
|
||||
])
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.section == 1, !isWorking else { return }
|
||||
replaceToken()
|
||||
}
|
||||
|
||||
private func replaceToken() {
|
||||
generation += 1
|
||||
let current = generation
|
||||
isWorking = true
|
||||
navigationItem.prompt = "Saving in protected system storage."
|
||||
tableView.reloadData()
|
||||
let account = accountField.text ?? ""
|
||||
let token = tokenField.text ?? ""
|
||||
updateTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try replaceConfiguredMobileApplicationToken(
|
||||
account: account,
|
||||
applicationToken: token
|
||||
)
|
||||
return Result<Void, OnboardingFailure>.success(())
|
||||
} catch let error as MobileOnboardingFfiError {
|
||||
return .failure(OnboardingFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard let self, current == generation else { return }
|
||||
isWorking = false
|
||||
navigationItem.prompt = nil
|
||||
tableView.reloadData()
|
||||
switch result {
|
||||
case .success:
|
||||
tokenField.text = nil
|
||||
let alert = UIAlertController(
|
||||
title: "Token Updated",
|
||||
message: "The new application token is ready for HTTPS Git operations.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
case let .failure(failure):
|
||||
let alert = UIAlertController(
|
||||
title: failure.title,
|
||||
message: failure.detail,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class OnboardingViewController: UITableViewController {
|
||||
private let serverField = UITextField()
|
||||
private let accountField = UITextField()
|
||||
private let repositoryField = UITextField()
|
||||
private let tokenField = UITextField()
|
||||
private var branches: [String] = []
|
||||
private var selectedBranch = 0
|
||||
private var operation: MobileOnboardingOperation?
|
||||
private var workTask: Task<Void, Never>?
|
||||
private var progressTask: Task<Void, Never>?
|
||||
private var generation = 0
|
||||
private var isWorking = false
|
||||
|
||||
init() {
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Connect Store"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
configureFields()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
deinit {
|
||||
operation?.cancel()
|
||||
workTask?.cancel()
|
||||
progressTask?.cancel()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
cancelWork()
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
branches.isEmpty ? 2 : 3
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
switch section {
|
||||
case 0: 4
|
||||
case 1: 1
|
||||
default: 2
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForHeaderInSection section: Int
|
||||
) -> String? {
|
||||
switch section {
|
||||
case 0: "HTTPS Repository"
|
||||
case 1: branches.isEmpty ? nil : "Remote"
|
||||
default: "Local Clone"
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
section == 0 ? "The application token is stored in protected system storage, never in configuration." : nil
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
if indexPath.section == 0 {
|
||||
return fieldCell(indexPath.row)
|
||||
}
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if branches.isEmpty {
|
||||
cell.textLabel?.text = "Discover Branches"
|
||||
cell.textLabel?.textColor = view.tintColor
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
} else if indexPath.section == 1 {
|
||||
cell.textLabel?.text = "Branch"
|
||||
cell.detailTextLabel?.text = branches[selectedBranch]
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
} else if indexPath.row == 0 {
|
||||
cell.textLabel?.text = "Clone Password Store"
|
||||
cell.textLabel?.textColor = view.tintColor
|
||||
} else {
|
||||
cell.textLabel?.text = "Use Existing Clone"
|
||||
cell.textLabel?.textColor = .secondaryLabel
|
||||
}
|
||||
cell.isUserInteractionEnabled = !isWorking
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard !isWorking else { return }
|
||||
if branches.isEmpty, indexPath.section == 1 {
|
||||
discoverBranches()
|
||||
} else if indexPath.section == 1 {
|
||||
chooseBranch(from: tableView.cellForRow(at: indexPath))
|
||||
} else if indexPath.section == 2 {
|
||||
runSetup(useExisting: indexPath.row == 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureFields() {
|
||||
for field in [serverField, accountField, repositoryField, tokenField] {
|
||||
field.borderStyle = .none
|
||||
field.clearButtonMode = .whileEditing
|
||||
field.autocorrectionType = .no
|
||||
field.returnKeyType = .next
|
||||
field.adjustsFontForContentSizeCategory = true
|
||||
field.font = .preferredFont(forTextStyle: .body)
|
||||
}
|
||||
serverField.placeholder = "https://git.example.com"
|
||||
serverField.textContentType = .URL
|
||||
serverField.keyboardType = .URL
|
||||
serverField.autocapitalizationType = .none
|
||||
accountField.placeholder = "Account name"
|
||||
accountField.autocapitalizationType = .none
|
||||
repositoryField.placeholder = "owner/password-store"
|
||||
repositoryField.autocapitalizationType = .none
|
||||
tokenField.placeholder = "Application token"
|
||||
tokenField.textContentType = .oneTimeCode
|
||||
tokenField.isSecureTextEntry = true
|
||||
}
|
||||
|
||||
private func fieldCell(_ row: Int) -> UITableViewCell {
|
||||
let field = [serverField, accountField, repositoryField, tokenField][row]
|
||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||
field.translatesAutoresizingMaskIntoConstraints = false
|
||||
cell.contentView.addSubview(field)
|
||||
NSLayoutConstraint.activate([
|
||||
field.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
|
||||
field.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
|
||||
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 10),
|
||||
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -10)
|
||||
])
|
||||
return cell
|
||||
}
|
||||
|
||||
private func makeOperation() throws -> MobileOnboardingOperation {
|
||||
try mobileOnboardingOperation(
|
||||
serverUrl: serverField.text ?? "",
|
||||
account: accountField.text ?? "",
|
||||
repositoryPath: repositoryField.text ?? "",
|
||||
applicationToken: tokenField.text ?? ""
|
||||
)
|
||||
}
|
||||
|
||||
private func discoverBranches() {
|
||||
do {
|
||||
let operation = try makeOperation()
|
||||
begin(operation, title: "Checking Repository") { operation in
|
||||
let discovery = try operation.discover()
|
||||
return .discovered(discovery)
|
||||
}
|
||||
} catch {
|
||||
present(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func runSetup(useExisting: Bool) {
|
||||
guard branches.indices.contains(selectedBranch) else { return }
|
||||
do {
|
||||
let operation = try makeOperation()
|
||||
let branch = branches[selectedBranch]
|
||||
begin(operation, title: useExisting ? "Opening Store" : "Cloning Store") { operation in
|
||||
let outcome = try operation.setup(branch: branch, useExisting: useExisting)
|
||||
return .completed(outcome)
|
||||
}
|
||||
} catch {
|
||||
present(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func begin(
|
||||
_ operation: MobileOnboardingOperation,
|
||||
title: String,
|
||||
work: @escaping @Sendable (MobileOnboardingOperation) throws -> WorkResult
|
||||
) {
|
||||
cancelWork()
|
||||
generation += 1
|
||||
let current = generation
|
||||
self.operation = operation
|
||||
isWorking = true
|
||||
showProgress(title: title, detail: "Preparing secure storage.")
|
||||
tableView.reloadData()
|
||||
progressTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled, let self, current == generation else { return }
|
||||
let progress = operation.progress()
|
||||
showProgress(title: progress.title, detail: progress.detail)
|
||||
}
|
||||
}
|
||||
workTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<WorkResult, OnboardingFailure>.success(try work(operation))
|
||||
} catch let error as MobileOnboardingFfiError {
|
||||
return .failure(OnboardingFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard let self, current == generation else { return }
|
||||
finish(result)
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<WorkResult, OnboardingFailure>) {
|
||||
progressTask?.cancel()
|
||||
progressTask = nil
|
||||
operation = nil
|
||||
isWorking = false
|
||||
navigationItem.titleView = nil
|
||||
navigationItem.prompt = nil
|
||||
switch result {
|
||||
case let .success(.discovered(discovery)):
|
||||
branches = discovery.branches
|
||||
selectedBranch = min(Int(discovery.selectedBranch), max(branches.count - 1, 0))
|
||||
tableView.reloadData()
|
||||
case let .success(.completed(outcome)):
|
||||
tokenField.text = nil
|
||||
let alert = UIAlertController(title: outcome.title, message: outcome.detail, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
case let .failure(failure):
|
||||
tableView.reloadData()
|
||||
present(failure)
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseBranch(from source: UIView?) {
|
||||
let sheet = UIAlertController(title: "Branch", message: nil, preferredStyle: .actionSheet)
|
||||
for (index, branch) in branches.enumerated() {
|
||||
sheet.addAction(UIAlertAction(title: branch, style: .default) { [weak self] _ in
|
||||
self?.selectedBranch = index
|
||||
self?.tableView.reloadSections(IndexSet(integer: 1), with: .automatic)
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
sheet.popoverPresentationController?.sourceView = source
|
||||
sheet.popoverPresentationController?.sourceRect = source?.bounds ?? .zero
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
private func present(_ error: Error) {
|
||||
if let error = error as? MobileOnboardingFfiError {
|
||||
present(OnboardingFailure(error))
|
||||
} else {
|
||||
present(.unexpected)
|
||||
}
|
||||
}
|
||||
|
||||
private func present(_ failure: OnboardingFailure) {
|
||||
let alert = UIAlertController(title: failure.title, message: failure.detail, preferredStyle: .alert)
|
||||
if failure.kind == .existingClone, !branches.isEmpty {
|
||||
alert.addAction(UIAlertAction(title: "Use Existing", style: .default) { [weak self] _ in
|
||||
self?.runSetup(useExisting: true)
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func showProgress(title: String, detail: String) {
|
||||
let spinner = UIActivityIndicatorView(style: .medium)
|
||||
spinner.startAnimating()
|
||||
spinner.accessibilityLabel = "In progress"
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
let stack = UIStackView(arrangedSubviews: [spinner, label])
|
||||
stack.spacing = 8
|
||||
navigationItem.titleView = stack
|
||||
navigationItem.prompt = detail
|
||||
}
|
||||
|
||||
private func cancelWork() {
|
||||
generation += 1
|
||||
operation?.cancel()
|
||||
operation = nil
|
||||
workTask?.cancel()
|
||||
workTask = nil
|
||||
progressTask?.cancel()
|
||||
progressTask = nil
|
||||
navigationItem.prompt = nil
|
||||
navigationItem.titleView = nil
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
|
||||
private enum WorkResult: Sendable {
|
||||
case discovered(MobileOnboardingDiscovery)
|
||||
case completed(MobileOnboardingOutcome)
|
||||
}
|
||||
|
||||
private struct OnboardingFailure: Error, Sendable {
|
||||
let kind: MobileOnboardingErrorKind?
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
init(_ error: MobileOnboardingFfiError) {
|
||||
switch error {
|
||||
case let .Failed(kind, title, detail):
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
static let unexpected = OnboardingFailure(
|
||||
kind: nil,
|
||||
title: "Setup Failed",
|
||||
detail: "IronStorage could not complete setup."
|
||||
)
|
||||
|
||||
private init(kind: MobileOnboardingErrorKind?, title: String, detail: String) {
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
|
||||
//! Mechanical UniFFI exports for Apple presentation code.
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
use std::{error::Error, fmt, sync::Arc};
|
||||
|
||||
use ironstorage::{
|
||||
config::ConfigError,
|
||||
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
|
||||
mobile_onboarding::{
|
||||
self, MobileOnboardingError as StorageOnboardingError,
|
||||
MobileOnboardingErrorKind as StorageOnboardingErrorKind,
|
||||
MobileOnboardingPhase as StorageOnboardingPhase,
|
||||
},
|
||||
};
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
@@ -92,6 +97,146 @@ pub struct MobileShell {
|
||||
pub pages: Vec<MobilePage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileOnboardingPhase {
|
||||
Validating,
|
||||
Authenticating,
|
||||
Receiving,
|
||||
Integrating,
|
||||
Finishing,
|
||||
}
|
||||
|
||||
impl From<StorageOnboardingPhase> for MobileOnboardingPhase {
|
||||
fn from(phase: StorageOnboardingPhase) -> Self {
|
||||
match phase {
|
||||
StorageOnboardingPhase::Validating => Self::Validating,
|
||||
StorageOnboardingPhase::Authenticating => Self::Authenticating,
|
||||
StorageOnboardingPhase::Receiving => Self::Receiving,
|
||||
StorageOnboardingPhase::Integrating => Self::Integrating,
|
||||
StorageOnboardingPhase::Finishing => Self::Finishing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileOnboardingProgress {
|
||||
pub phase: MobileOnboardingPhase,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileOnboardingDiscovery {
|
||||
pub branches: Vec<String>,
|
||||
pub selected_branch: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileOnboardingOutcome {
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileOnboardingErrorKind {
|
||||
InvalidInput,
|
||||
UnsupportedRemote,
|
||||
Authentication,
|
||||
Repository,
|
||||
ExistingClone,
|
||||
Interrupted,
|
||||
SecureStorage,
|
||||
Configuration,
|
||||
AlreadyConfigured,
|
||||
}
|
||||
|
||||
impl From<StorageOnboardingErrorKind> for MobileOnboardingErrorKind {
|
||||
fn from(kind: StorageOnboardingErrorKind) -> Self {
|
||||
match kind {
|
||||
StorageOnboardingErrorKind::InvalidInput => Self::InvalidInput,
|
||||
StorageOnboardingErrorKind::UnsupportedRemote => Self::UnsupportedRemote,
|
||||
StorageOnboardingErrorKind::Authentication => Self::Authentication,
|
||||
StorageOnboardingErrorKind::Repository => Self::Repository,
|
||||
StorageOnboardingErrorKind::ExistingClone => Self::ExistingClone,
|
||||
StorageOnboardingErrorKind::Interrupted => Self::Interrupted,
|
||||
StorageOnboardingErrorKind::SecureStorage => Self::SecureStorage,
|
||||
StorageOnboardingErrorKind::Configuration => Self::Configuration,
|
||||
StorageOnboardingErrorKind::AlreadyConfigured => Self::AlreadyConfigured,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, uniffi::Error)]
|
||||
pub enum MobileOnboardingFfiError {
|
||||
Failed {
|
||||
kind: MobileOnboardingErrorKind,
|
||||
title: String,
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileOnboardingFfiError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Failed { title, detail, .. } => write!(formatter, "{title}: {detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for MobileOnboardingFfiError {}
|
||||
|
||||
impl From<StorageOnboardingError> for MobileOnboardingFfiError {
|
||||
fn from(error: StorageOnboardingError) -> Self {
|
||||
Self::Failed {
|
||||
kind: error.kind().into(),
|
||||
title: error.title().to_owned(),
|
||||
detail: error.detail().to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct MobileOnboardingOperation {
|
||||
operation: mobile_onboarding::MobileOnboardingOperation,
|
||||
request: mobile_onboarding::MobileOnboardingRequest,
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
impl MobileOnboardingOperation {
|
||||
pub fn progress(&self) -> MobileOnboardingProgress {
|
||||
let progress = self.operation.progress();
|
||||
MobileOnboardingProgress {
|
||||
phase: progress.phase().into(),
|
||||
title: progress.title().to_owned(),
|
||||
detail: progress.detail().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn discover(&self) -> Result<MobileOnboardingDiscovery, MobileOnboardingFfiError> {
|
||||
let discovery = self.operation.discover(&self.request)?;
|
||||
Ok(MobileOnboardingDiscovery {
|
||||
branches: discovery.branches().to_vec(),
|
||||
selected_branch: u32::try_from(discovery.selected_branch()).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn setup(
|
||||
&self,
|
||||
branch: String,
|
||||
use_existing: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
|
||||
let outcome = self.operation.setup(&self.request, &branch, use_existing)?;
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: outcome.title().to_owned(),
|
||||
detail: outcome.detail().to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.operation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
impl From<mobile::MobileShell> for MobileShell {
|
||||
fn from(shell: mobile::MobileShell) -> Self {
|
||||
Self {
|
||||
@@ -156,9 +301,39 @@ pub fn set_selected_mobile_tab(tab: MobileTab) -> Result<(), MobilePreferenceErr
|
||||
mobile::store_selected_tab(tab.into()).map_err(Into::into)
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_onboarding_operation(
|
||||
server_url: String,
|
||||
account: String,
|
||||
repository_path: String,
|
||||
application_token: String,
|
||||
) -> Result<Arc<MobileOnboardingOperation>, MobileOnboardingFfiError> {
|
||||
Ok(Arc::new(MobileOnboardingOperation {
|
||||
operation: mobile_onboarding::MobileOnboardingOperation::default(),
|
||||
request: mobile_onboarding::MobileOnboardingRequest::new(
|
||||
server_url,
|
||||
account,
|
||||
repository_path,
|
||||
application_token.into_bytes(),
|
||||
)?,
|
||||
}))
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn replace_configured_mobile_application_token(
|
||||
account: String,
|
||||
application_token: String,
|
||||
) -> Result<(), MobileOnboardingFfiError> {
|
||||
mobile_onboarding::replace_configured_application_token(
|
||||
account,
|
||||
application_token.into_bytes(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MobileShellState, MobileTab};
|
||||
use super::{MobileOnboardingErrorKind, MobileOnboardingFfiError, MobileShellState, MobileTab};
|
||||
|
||||
#[test]
|
||||
fn bridge_reads_product_name_from_storage_crate() {
|
||||
@@ -180,4 +355,23 @@ mod tests {
|
||||
assert!(shell.pages.iter().all(|page| page.state == state));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_rejects_non_https_before_creating_an_operation() {
|
||||
let error = match super::mobile_onboarding_operation(
|
||||
"ssh://example.test".to_owned(),
|
||||
"alice".to_owned(),
|
||||
"team/passwords".to_owned(),
|
||||
"DO-NOT-RENDER".to_owned(),
|
||||
) {
|
||||
Ok(_) => panic!("SSH must be rejected"),
|
||||
Err(error) => error,
|
||||
};
|
||||
match error {
|
||||
MobileOnboardingFfiError::Failed { kind, detail, .. } => {
|
||||
assert_eq!(kind, MobileOnboardingErrorKind::UnsupportedRemote);
|
||||
assert!(!detail.contains("DO-NOT-RENDER"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::{
|
||||
env,
|
||||
error::Error,
|
||||
fmt, fs,
|
||||
fs::OpenOptions,
|
||||
io::Write,
|
||||
path::{Component, Path, PathBuf},
|
||||
time::Duration,
|
||||
@@ -167,6 +168,73 @@ impl Config {
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
pub(crate) fn create_mobile_clone(
|
||||
source: PathBuf,
|
||||
vault: &Path,
|
||||
key_material: &Path,
|
||||
default_key: &str,
|
||||
remote: &GitRemote,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let base = source
|
||||
.parent()
|
||||
.ok_or(ConfigError::InvalidField { field: "source" })?;
|
||||
let vault = vault
|
||||
.strip_prefix(base)
|
||||
.map_err(|_| ConfigError::InvalidField { field: "vault" })?;
|
||||
let key_material =
|
||||
key_material
|
||||
.strip_prefix(base)
|
||||
.map_err(|_| ConfigError::InvalidField {
|
||||
field: "key_material",
|
||||
})?;
|
||||
let mut root = toml::Table::new();
|
||||
root.insert(
|
||||
"vault".to_owned(),
|
||||
toml::Value::String(path_text(vault, "vault")?),
|
||||
);
|
||||
root.insert(
|
||||
"default_key".to_owned(),
|
||||
toml::Value::String(default_key.to_owned()),
|
||||
);
|
||||
root.insert(
|
||||
"key_material".to_owned(),
|
||||
toml::Value::String(path_text(key_material, "key_material")?),
|
||||
);
|
||||
let mut configured = toml::Table::new();
|
||||
configured.insert(
|
||||
"name".to_owned(),
|
||||
toml::Value::String(remote.name().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"url".to_owned(),
|
||||
toml::Value::String(remote.url().to_string()),
|
||||
);
|
||||
configured.insert(
|
||||
"server_id".to_owned(),
|
||||
toml::Value::String(remote.server_id().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"application_id".to_owned(),
|
||||
toml::Value::String(remote.application_id().as_str().to_owned()),
|
||||
);
|
||||
let mut git = toml::Table::new();
|
||||
git.insert(
|
||||
"remotes".to_owned(),
|
||||
toml::Value::Array(vec![toml::Value::Table(configured)]),
|
||||
);
|
||||
root.insert("git".to_owned(), toml::Value::Table(git));
|
||||
let document = toml::Value::Table(root);
|
||||
let raw = document
|
||||
.clone()
|
||||
.try_into::<RawConfig>()
|
||||
.map_err(|_| ConfigError::Malformed {
|
||||
path: source.clone(),
|
||||
})?;
|
||||
let config = validate_config(source, document, raw)?;
|
||||
config.persist_new()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Select a configured remote by name, or the configured default (first
|
||||
/// remote) when no name was requested.
|
||||
pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> {
|
||||
@@ -313,6 +381,72 @@ impl Config {
|
||||
path: self.source.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn persist_new(&self) -> Result<(), ConfigError> {
|
||||
let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let name = self.source.file_name().ok_or_else(|| ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
if self.source.exists() {
|
||||
return Err(ConfigError::AlreadyConfigured {
|
||||
path: self.source.clone(),
|
||||
});
|
||||
}
|
||||
fs::create_dir_all(parent).map_err(|_| ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
set_private_directory(parent).map_err(|_| ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let temporary = (0..128_u8)
|
||||
.find_map(|attempt| {
|
||||
let path = parent.join(format!(
|
||||
".ironstorage-config-{}-{attempt}",
|
||||
rand::random::<u64>()
|
||||
));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
options.mode(0o600);
|
||||
}
|
||||
match options.open(&path) {
|
||||
Ok(file) => Some(Ok((path, file))),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
|
||||
Err(_) => Some(Err(ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})),
|
||||
}
|
||||
})
|
||||
.transpose()?
|
||||
.ok_or_else(|| ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let (temporary_path, mut temporary_file) = temporary;
|
||||
let installed = temporary_file
|
||||
.write_all(contents.as_bytes())
|
||||
.and_then(|()| temporary_file.sync_all())
|
||||
.and_then(|()| fs::hard_link(&temporary_path, parent.join(name)));
|
||||
drop(temporary_file);
|
||||
let _ = fs::remove_file(&temporary_path);
|
||||
match installed {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
Err(ConfigError::AlreadyConfigured {
|
||||
path: self.source.clone(),
|
||||
})
|
||||
}
|
||||
Err(_) => Err(ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic path context for configuration loading.
|
||||
@@ -432,6 +566,21 @@ pub struct GitRemote {
|
||||
}
|
||||
|
||||
impl GitRemote {
|
||||
pub fn https(
|
||||
name: impl Into<String>,
|
||||
url: impl Into<String>,
|
||||
server_id: impl Into<String>,
|
||||
application_id: impl Into<String>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let mut remotes = validate_remotes(vec![RawGitRemote {
|
||||
name: name.into(),
|
||||
url: url.into(),
|
||||
server_id: server_id.into(),
|
||||
application_id: application_id.into(),
|
||||
}])?;
|
||||
Ok(remotes.remove(0))
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &RemoteName {
|
||||
&self.name
|
||||
}
|
||||
@@ -505,6 +654,7 @@ pub enum ConfigError {
|
||||
VaultUnavailable { path: PathBuf },
|
||||
VaultIsNotDirectory { path: PathBuf },
|
||||
Write { path: PathBuf },
|
||||
AlreadyConfigured { path: PathBuf },
|
||||
KeyMaterialNotFound { path: PathBuf },
|
||||
InvalidKeyMaterial { path: PathBuf },
|
||||
DuplicateRemote { name: String },
|
||||
@@ -575,6 +725,11 @@ impl fmt::Display for ConfigError {
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
Self::AlreadyConfigured { path } => write!(
|
||||
formatter,
|
||||
"configuration already exists and was not replaced: {}",
|
||||
path.display()
|
||||
),
|
||||
Self::KeyMaterialNotFound { path } => write!(
|
||||
formatter,
|
||||
"exported key material does not exist: {}",
|
||||
@@ -760,6 +915,23 @@ fn resolve_required_path(
|
||||
Ok(resolve_path(base, &value))
|
||||
}
|
||||
|
||||
fn path_text(path: &Path, field: &'static str) -> Result<String, ConfigError> {
|
||||
path.to_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or(ConfigError::InvalidField { field })
|
||||
}
|
||||
|
||||
fn set_private_directory(path: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_key_identity(value: String) -> Result<KeyIdentity, ConfigError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty()
|
||||
@@ -1014,7 +1186,14 @@ fn native_config_directory() -> Option<PathBuf> {
|
||||
.map(|home| home.join("Library/Application Support"))
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[cfg(target_os = "ios")]
|
||||
fn native_config_directory() -> Option<PathBuf> {
|
||||
env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.map(|home| home.join("Library/Application Support"))
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(any(target_os = "ios", target_os = "macos"))))]
|
||||
fn native_config_directory() -> Option<PathBuf> {
|
||||
match env::var_os("XDG_CONFIG_HOME") {
|
||||
Some(path) if !path.is_empty() && Path::new(&path).is_absolute() => {
|
||||
@@ -1031,3 +1210,53 @@ fn native_config_directory() -> Option<PathBuf> {
|
||||
fn native_config_directory() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::{Config, ConfigError, GitRemote};
|
||||
|
||||
#[test]
|
||||
fn mobile_clone_configuration_is_secret_free_and_never_replaced() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let original = temporary.path().join("original");
|
||||
let relocated = temporary.path().join("relocated");
|
||||
let vault = original.join("vault");
|
||||
let keys = original.join("keys");
|
||||
let source = original.join("config.toml");
|
||||
fs::create_dir_all(&vault).expect("vault");
|
||||
fs::create_dir(&keys).expect("keys");
|
||||
let remote = GitRemote::https(
|
||||
"origin",
|
||||
"https://example.test/team/passwords.git",
|
||||
"server-example",
|
||||
"repository-example",
|
||||
)
|
||||
.expect("remote");
|
||||
Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote)
|
||||
.expect("create config");
|
||||
let contents = fs::read_to_string(&source).expect("read config");
|
||||
assert!(!contents.contains("token ="));
|
||||
assert!(!contents.contains("password ="));
|
||||
assert!(!contents.contains(&original.to_string_lossy().into_owned()));
|
||||
assert!(contents.contains("vault = \"vault\""));
|
||||
assert!(contents.contains("key_material = \"keys\""));
|
||||
assert!(contents.contains("https://example.test/team/passwords.git"));
|
||||
assert_eq!(
|
||||
Config::create_mobile_clone(source.clone(), &vault, &keys, "BOB", &remote)
|
||||
.expect_err("must not replace existing config"),
|
||||
ConfigError::AlreadyConfigured {
|
||||
path: source.clone()
|
||||
}
|
||||
);
|
||||
fs::rename(&original, &relocated).expect("relocate app container");
|
||||
assert_eq!(
|
||||
Config::load(Some(&relocated.join("config.toml")))
|
||||
.expect("reload config")
|
||||
.default_key()
|
||||
.as_str(),
|
||||
"ALICE"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,18 +788,91 @@ impl FetchOutcome {
|
||||
}
|
||||
|
||||
impl GitRepository {
|
||||
pub fn discover_remote_branches(
|
||||
parent: &Path,
|
||||
identity: GitIdentity,
|
||||
configured: &GitRemote,
|
||||
credentials: &impl GitCredentialProvider,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<Vec<String>, GitError> {
|
||||
Self::discover_remote_branches_with_transport(
|
||||
parent,
|
||||
identity,
|
||||
configured,
|
||||
credentials,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn discover_remote_branches_with_transport(
|
||||
parent: &Path,
|
||||
identity: GitIdentity,
|
||||
configured: &GitRemote,
|
||||
credentials: &impl GitCredentialProvider,
|
||||
transport: &impl GitFetchTransport,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<Vec<String>, GitError> {
|
||||
control.report(GitProgressPhase::Validating)?;
|
||||
validate_https_remote(configured.url().as_str())?;
|
||||
ensure_clone_parent(parent)?;
|
||||
let temporary = private_temporary_directory(parent, "probe")?;
|
||||
let result = (|| {
|
||||
let store = Repository::open(&temporary).map_err(invalid)?;
|
||||
gix::ThreadSafeRepository::init_opts(
|
||||
store.root_path(),
|
||||
gix::create::Kind::WithWorktree,
|
||||
gix::create::Options::default(),
|
||||
isolated_options(),
|
||||
)
|
||||
.map_err(invalid)?;
|
||||
let mut repository = Self::open(&store, identity)?;
|
||||
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
|
||||
repository.fetch_with_transport_controlled(
|
||||
configured,
|
||||
credentials,
|
||||
transport,
|
||||
control,
|
||||
)?;
|
||||
repository.remote_branch_names(configured.name().as_str())
|
||||
})();
|
||||
let _ = fs::remove_dir_all(&temporary);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn clone_into(
|
||||
destination: &Path,
|
||||
identity: GitIdentity,
|
||||
configured: &GitRemote,
|
||||
credentials: &impl GitCredentialProvider,
|
||||
) -> Result<Self, GitError> {
|
||||
Self::clone_into_with_transport(
|
||||
Self::clone_into_with_transport_controlled(
|
||||
destination,
|
||||
identity,
|
||||
configured,
|
||||
None,
|
||||
credentials,
|
||||
&EmbeddedFetchTransport,
|
||||
&GitOperationControl::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clone_into_controlled(
|
||||
destination: &Path,
|
||||
identity: GitIdentity,
|
||||
configured: &GitRemote,
|
||||
branch: &str,
|
||||
credentials: &impl GitCredentialProvider,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<Self, GitError> {
|
||||
Self::clone_into_with_transport_controlled(
|
||||
destination,
|
||||
identity,
|
||||
configured,
|
||||
Some(branch),
|
||||
credentials,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -810,17 +883,35 @@ impl GitRepository {
|
||||
credentials: &impl GitCredentialProvider,
|
||||
transport: &impl GitFetchTransport,
|
||||
) -> Result<Self, GitError> {
|
||||
Self::clone_into_with_transport_controlled(
|
||||
destination,
|
||||
identity,
|
||||
configured,
|
||||
None,
|
||||
credentials,
|
||||
transport,
|
||||
&GitOperationControl::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clone_into_with_transport_controlled(
|
||||
destination: &Path,
|
||||
identity: GitIdentity,
|
||||
configured: &GitRemote,
|
||||
branch: Option<&str>,
|
||||
credentials: &impl GitCredentialProvider,
|
||||
transport: &impl GitFetchTransport,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<Self, GitError> {
|
||||
control.report(GitProgressPhase::Validating)?;
|
||||
validate_https_remote(configured.url().as_str())?;
|
||||
if let Some(branch) = branch {
|
||||
validate_remote_name(branch)?;
|
||||
}
|
||||
let parent = destination.parent().ok_or_else(|| GitError::InvalidPath {
|
||||
path: destination.to_owned(),
|
||||
})?;
|
||||
let metadata =
|
||||
fs::symlink_metadata(parent).map_err(|_| io("inspect clone parent", parent))?;
|
||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||
return Err(GitError::UnsafeWorktreeObject {
|
||||
path: parent.to_owned(),
|
||||
});
|
||||
}
|
||||
ensure_clone_parent(parent)?;
|
||||
if let Ok(metadata) = fs::symlink_metadata(destination) {
|
||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||
return Err(GitError::UnsafeWorktreeObject {
|
||||
@@ -835,18 +926,7 @@ impl GitRepository {
|
||||
return Err(GitError::DirtyWorktree);
|
||||
}
|
||||
}
|
||||
let temporary = (0..128_u8)
|
||||
.find_map(|attempt| {
|
||||
let name = format!(".ironstorage-clone-{}-{attempt}", rand::random::<u64>());
|
||||
let candidate = parent.join(name);
|
||||
match fs::create_dir(&candidate) {
|
||||
Ok(()) => Some(Ok(candidate)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
|
||||
Err(_) => Some(Err(io("create clone directory", parent))),
|
||||
}
|
||||
})
|
||||
.transpose()?
|
||||
.ok_or_else(|| io("create clone directory", parent))?;
|
||||
let temporary = private_temporary_directory(parent, "clone")?;
|
||||
let cloned = (|| {
|
||||
let store = Repository::open(&temporary).map_err(invalid)?;
|
||||
gix::ThreadSafeRepository::init_opts(
|
||||
@@ -858,17 +938,23 @@ impl GitRepository {
|
||||
.map_err(invalid)?;
|
||||
let mut repository = Self::open(&store, identity.clone())?;
|
||||
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
|
||||
repository.pull_with_transport(configured, None, credentials, transport)?;
|
||||
Ok(repository)
|
||||
repository.pull_with_transport_controlled(
|
||||
configured,
|
||||
branch,
|
||||
credentials,
|
||||
transport,
|
||||
control,
|
||||
)?;
|
||||
control.report(GitProgressPhase::Refreshing)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = cloned {
|
||||
let _ = fs::remove_dir_all(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
drop(cloned);
|
||||
if destination.exists() {
|
||||
fs::remove_dir(destination)
|
||||
.map_err(|_| io("prepare clone destination", destination))?;
|
||||
if destination.exists() && fs::remove_dir(destination).is_err() {
|
||||
let _ = fs::remove_dir_all(&temporary);
|
||||
return Err(io("prepare clone destination", destination));
|
||||
}
|
||||
if fs::rename(&temporary, destination).is_err() {
|
||||
let _ = fs::remove_dir_all(&temporary);
|
||||
@@ -880,6 +966,42 @@ impl GitRepository {
|
||||
Self::open_at(destination.to_owned(), identity)
|
||||
}
|
||||
|
||||
fn remote_branch_names(&self, remote: &str) -> Result<Vec<String>, GitError> {
|
||||
validate_remote_name(remote)?;
|
||||
let prefix = format!("refs/remotes/{remote}/");
|
||||
let mut branches = self
|
||||
.repository
|
||||
.references()
|
||||
.map_err(invalid)?
|
||||
.remote_branches()
|
||||
.map_err(invalid)?
|
||||
.filter_map(|reference| {
|
||||
let reference = match reference {
|
||||
Ok(reference) => reference,
|
||||
Err(error) => return Some(Err(invalid(error))),
|
||||
};
|
||||
let name = match reference.name().as_bstr().to_str() {
|
||||
Ok(name) => name,
|
||||
Err(error) => return Some(Err(invalid(error))),
|
||||
};
|
||||
name.strip_prefix(&prefix)
|
||||
.filter(|name| *name != "HEAD")
|
||||
.map(|name| {
|
||||
validate_remote_name(name)?;
|
||||
Ok(name.to_owned())
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, GitError>>()?;
|
||||
branches.sort();
|
||||
branches.dedup();
|
||||
if branches.is_empty() {
|
||||
return Err(GitError::InvalidRepository(
|
||||
"the remote has no branches".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
pub fn init(store: &Repository, identity: GitIdentity) -> Result<Self, GitError> {
|
||||
let root = store.root_path().to_owned();
|
||||
if root.join(".git").exists() {
|
||||
@@ -957,12 +1079,13 @@ impl GitRepository {
|
||||
}
|
||||
|
||||
fn open_at(root: PathBuf, identity: GitIdentity) -> Result<Self, GitError> {
|
||||
let repository =
|
||||
let mut repository =
|
||||
gix::open_opts(&root, isolated_options()).map_err(|_| GitError::NotRepository)?;
|
||||
if repository.is_bare() {
|
||||
return Err(GitError::BareRepository);
|
||||
}
|
||||
validate_local_config_security(&load_local_config(&repository)?)?;
|
||||
apply_in_memory_identity(&mut repository, &identity)?;
|
||||
Ok(Self {
|
||||
root,
|
||||
repository,
|
||||
@@ -1204,18 +1327,18 @@ impl GitRepository {
|
||||
return GitError::Cancelled;
|
||||
}
|
||||
let text = error.to_string();
|
||||
let lower = text.to_ascii_lowercase();
|
||||
if text.contains("401")
|
||||
|| text.contains("403")
|
||||
|| text.to_ascii_lowercase().contains("authentication")
|
||||
|| lower.contains("authentication")
|
||||
|| (lower.contains("credential") && lower.contains("not accepted"))
|
||||
{
|
||||
GitError::AuthenticationFailed
|
||||
} else if text.to_ascii_lowercase().contains("certificate")
|
||||
|| text.to_ascii_lowercase().contains("tls")
|
||||
{
|
||||
} else if lower.contains("certificate") || lower.contains("tls") {
|
||||
GitError::TlsFailed
|
||||
} else if text.to_ascii_lowercase().contains("network")
|
||||
|| text.to_ascii_lowercase().contains("connect")
|
||||
|| text.to_ascii_lowercase().contains("dns")
|
||||
} else if lower.contains("network")
|
||||
|| lower.contains("connect")
|
||||
|| lower.contains("dns")
|
||||
{
|
||||
GitError::NetworkUnavailable
|
||||
} else {
|
||||
@@ -1878,6 +2001,7 @@ impl GitRepository {
|
||||
validate_local_config_security(&config)?;
|
||||
write_local_config(&self.repository, &config)?;
|
||||
self.repository = gix::open_opts(&self.root, isolated_options()).map_err(invalid)?;
|
||||
apply_in_memory_identity(&mut self.repository, &self.identity)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3032,6 +3156,43 @@ fn validate_remote_name(name: &str) -> Result<(), GitError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_clone_parent(parent: &Path) -> Result<(), GitError> {
|
||||
let metadata = fs::symlink_metadata(parent).map_err(|_| io("inspect clone parent", parent))?;
|
||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||
return Err(GitError::UnsafeWorktreeObject {
|
||||
path: parent.to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn private_temporary_directory(parent: &Path, purpose: &str) -> Result<PathBuf, GitError> {
|
||||
(0..128_u8)
|
||||
.find_map(|attempt| {
|
||||
let name = format!(".ironstorage-{purpose}-{}-{attempt}", rand::random::<u64>());
|
||||
let candidate = parent.join(name);
|
||||
match fs::create_dir(&candidate) {
|
||||
Ok(()) => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
if fs::set_permissions(&candidate, fs::Permissions::from_mode(0o700))
|
||||
.is_err()
|
||||
{
|
||||
let _ = fs::remove_dir(&candidate);
|
||||
return Some(Err(io("secure private Git directory", parent)));
|
||||
}
|
||||
}
|
||||
Some(Ok(candidate))
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
|
||||
Err(_) => Some(Err(io("create private Git directory", parent))),
|
||||
}
|
||||
})
|
||||
.transpose()?
|
||||
.ok_or_else(|| io("create private Git directory", parent))
|
||||
}
|
||||
|
||||
fn validate_https_remote(value: &str) -> Result<url::Url, GitError> {
|
||||
let parsed = url::Url::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?;
|
||||
if parsed.scheme() != "https"
|
||||
@@ -3215,9 +3376,45 @@ fn invalid(error: impl fmt::Display) -> GitError {
|
||||
GitError::InvalidRepository(error.to_string())
|
||||
}
|
||||
|
||||
fn apply_in_memory_identity(
|
||||
repository: &mut gix::Repository,
|
||||
identity: &GitIdentity,
|
||||
) -> Result<(), GitError> {
|
||||
let mut config = repository.config_snapshot_mut();
|
||||
config
|
||||
.set_raw_value("user.name", identity.name())
|
||||
.map_err(invalid)?;
|
||||
config
|
||||
.set_raw_value("user.email", identity.email())
|
||||
.map_err(invalid)?;
|
||||
drop(config);
|
||||
if repository.committer().is_none() {
|
||||
return Err(GitError::InvalidIdentity);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn io(operation: &'static str, path: &Path) -> GitError {
|
||||
GitError::Io {
|
||||
operation,
|
||||
path: path.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{GitIdentity, GitRepository};
|
||||
use crate::repository::Repository;
|
||||
|
||||
#[test]
|
||||
fn embedded_identity_survives_local_config_updates() {
|
||||
let temporary = tempfile::tempdir().expect("temporary repository");
|
||||
let store = Repository::open(temporary.path()).expect("store");
|
||||
let mut repository =
|
||||
GitRepository::init(&store, GitIdentity::ironstorage()).expect("initialize Git");
|
||||
repository
|
||||
.add_remote("origin", "https://example.test/password-store.git")
|
||||
.expect("add remote");
|
||||
assert!(repository.repository.committer().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod generate;
|
||||
pub mod git;
|
||||
pub mod kdbx;
|
||||
pub mod mobile;
|
||||
pub mod mobile_onboarding;
|
||||
pub mod mutation;
|
||||
pub mod otp;
|
||||
pub mod presentation;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Create or replace one HTTPS Git account/token record without exposing
|
||||
/// the previously stored account or token to a frontend.
|
||||
pub fn store_https_git_credential(
|
||||
&self,
|
||||
server: &ServerId,
|
||||
application: &ApplicationId,
|
||||
account: impl Into<String>,
|
||||
value: SecretBytes,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
validate_secret(&value)?;
|
||||
let reference =
|
||||
SecretReference::https_git_credential(server.as_str(), application.as_str(), account)?;
|
||||
let locator = reference.locator();
|
||||
let encoded = encode_record(&reference, &value)?;
|
||||
let mut state = self.unlocked_state()?;
|
||||
match self
|
||||
.backend
|
||||
.retrieve(&locator, self.protections.for_reference(&reference))
|
||||
{
|
||||
Ok(existing) => {
|
||||
let existing = decode_record(existing)?;
|
||||
if existing.reference.locator() != locator {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
}
|
||||
self.backend.replace(
|
||||
&locator,
|
||||
self.protections.for_reference(&reference),
|
||||
encoded.expose(),
|
||||
)?;
|
||||
}
|
||||
Err(SecretStoreError::Missing) => self.backend.create(
|
||||
&locator,
|
||||
self.protections.for_reference(&reference),
|
||||
encoded.expose(),
|
||||
)?,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
self.cache_insert(&mut state, locator, encoded);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> {
|
||||
let locator = reference.locator();
|
||||
let mut state = self.unlocked_state()?;
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
error::Error,
|
||||
fs,
|
||||
io::Cursor,
|
||||
path::Path,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
@@ -204,6 +204,22 @@ impl GitFetchTransport for CloningFetch {
|
||||
}
|
||||
}
|
||||
|
||||
struct OccupyingFetch(PathBuf);
|
||||
|
||||
impl GitFetchTransport for OccupyingFetch {
|
||||
fn fetch(
|
||||
&self,
|
||||
repository: &GitRepository,
|
||||
configured: &GitRemote,
|
||||
credential: &GitCredential,
|
||||
) -> Result<bool, GitError> {
|
||||
let fetched = CloningFetch.fetch(repository, configured, credential)?;
|
||||
fs::write(self.0.join("keep"), b"concurrent contents")
|
||||
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
|
||||
Ok(fetched)
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopFetch;
|
||||
|
||||
impl GitFetchTransport for NoopFetch {
|
||||
@@ -547,6 +563,102 @@ fn clone_uses_a_private_directory_and_injected_fetch_transport() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_discovery_and_controlled_clone_stay_inside_rust() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let config = remote_config(&temporary)?;
|
||||
let remote = &config.git_remotes()[0];
|
||||
let control = GitOperationControl::default();
|
||||
let branches = GitRepository::discover_remote_branches_with_transport(
|
||||
temporary.path(),
|
||||
identity(),
|
||||
remote,
|
||||
&Credentials,
|
||||
&CloningFetch,
|
||||
&control,
|
||||
)?;
|
||||
assert_eq!(branches, ["main"]);
|
||||
assert!(
|
||||
!temporary
|
||||
.path()
|
||||
.read_dir()?
|
||||
.filter_map(Result::ok)
|
||||
.any(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".ironstorage-probe-")
|
||||
})
|
||||
);
|
||||
|
||||
let destination = temporary.path().join("selected-branch");
|
||||
let cloned = GitRepository::clone_into_with_transport_controlled(
|
||||
&destination,
|
||||
identity(),
|
||||
remote,
|
||||
Some(&branches[0]),
|
||||
&Credentials,
|
||||
&CloningFetch,
|
||||
&control,
|
||||
)?;
|
||||
assert_eq!(cloned.current_branch()?, "main");
|
||||
assert_eq!(fs::read(destination.join(".gpg-id"))?, b"ALICE\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_clone_never_touches_an_existing_destination() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let config = remote_config(&temporary)?;
|
||||
let destination = temporary.path().join("existing");
|
||||
fs::create_dir(&destination)?;
|
||||
fs::write(destination.join("keep"), b"unchanged")?;
|
||||
let control = GitOperationControl::default();
|
||||
control.cancel();
|
||||
let result = GitRepository::clone_into_with_transport_controlled(
|
||||
&destination,
|
||||
identity(),
|
||||
&config.git_remotes()[0],
|
||||
Some("main"),
|
||||
&Credentials,
|
||||
&CloningFetch,
|
||||
&control,
|
||||
);
|
||||
assert!(matches!(result, Err(GitError::Cancelled)));
|
||||
assert_eq!(fs::read(destination.join("keep"))?, b"unchanged");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_race_preserves_the_destination_and_removes_private_work() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let config = remote_config(&temporary)?;
|
||||
let destination = temporary.path().join("concurrent");
|
||||
fs::create_dir(&destination)?;
|
||||
let result = GitRepository::clone_into_with_transport(
|
||||
&destination,
|
||||
identity(),
|
||||
&config.git_remotes()[0],
|
||||
&Credentials,
|
||||
&OccupyingFetch(destination.clone()),
|
||||
);
|
||||
assert!(matches!(result, Err(GitError::Io { .. })));
|
||||
assert_eq!(fs::read(destination.join("keep"))?, b"concurrent contents");
|
||||
assert!(
|
||||
!temporary
|
||||
.path()
|
||||
.read_dir()?
|
||||
.filter_map(Result::ok)
|
||||
.any(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".ironstorage-clone-")
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_commits_have_a_verifiable_ascii_armored_gpgsig() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
|
||||
@@ -381,6 +381,19 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult
|
||||
let credential = store.credential(remote.server_id(), remote.application_id())?;
|
||||
assert_eq!(credential.username(), "alice");
|
||||
assert_eq!(credential.password(), b"https-token");
|
||||
store.store_https_git_credential(
|
||||
remote.server_id(),
|
||||
remote.application_id(),
|
||||
"bob",
|
||||
SecretBytes::new(b"replacement-token".to_vec()),
|
||||
)?;
|
||||
assert!(matches!(
|
||||
store.retrieve(&git),
|
||||
Err(SecretStoreError::Missing)
|
||||
));
|
||||
let credential = store.credential(remote.server_id(), remote.application_id())?;
|
||||
assert_eq!(credential.username(), "bob");
|
||||
assert_eq!(credential.password(), b"replacement-token");
|
||||
assert!(
|
||||
backend
|
||||
.protections()
|
||||
|
||||
Reference in New Issue
Block a user