diff --git a/Package.swift b/Package.swift index 33a3f997f..86e14f60c 100644 --- a/Package.swift +++ b/Package.swift @@ -75,7 +75,8 @@ let package = Package( "UserScript", "ContentBlocking", "SecureStorage", - "Subscription" + "Subscription", + "PixelKit" ], resources: [ .process("ContentBlocking/UserScripts/contentblockerrules.js"), diff --git a/Sources/BrowserServicesKit/DataImport/Bookmarks/BookmarksImportSummary.swift b/Sources/BrowserServicesKit/DataImport/Bookmarks/BookmarksImportSummary.swift new file mode 100644 index 000000000..9b6a1c9ad --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/Bookmarks/BookmarksImportSummary.swift @@ -0,0 +1,38 @@ +// +// BookmarksImportSummary.swift +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public struct BookmarksImportSummary: Equatable { + public var successful: Int + public var duplicates: Int + public var failed: Int + + public init(successful: Int, duplicates: Int, failed: Int) { + self.successful = successful + self.duplicates = duplicates + self.failed = failed + } + + public static func += (left: inout BookmarksImportSummary, right: BookmarksImportSummary) { + left.successful += right.successful + left.duplicates += right.duplicates + left.failed += right.failed + } + +} diff --git a/Sources/BrowserServicesKit/DataImport/DataImport.swift b/Sources/BrowserServicesKit/DataImport/DataImport.swift new file mode 100644 index 000000000..f33a1d99f --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/DataImport.swift @@ -0,0 +1,386 @@ +// +// DataImport.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SecureStorage +import PixelKit +import Foundation + +public enum DataImport { + + public enum Source: String, RawRepresentable, CaseIterable, Equatable { + case brave + case chrome + case chromium + case coccoc + case edge + case firefox + case opera + case operaGX + case safari + case safariTechnologyPreview + case tor + case vivaldi + case yandex + case onePassword8 + case onePassword7 + case bitwarden + case lastPass + case csv + case bookmarksHTML + + static let preferredSources: [Self] = [.chrome, .safari] + + } + + public enum DataType: String, Hashable, CaseIterable, CustomStringConvertible { + + case bookmarks + case passwords + + public var description: String { rawValue } + + public var importAction: DataImportAction { + switch self { + case .bookmarks: .bookmarks + case .passwords: .passwords + } + } + + } + + public struct DataTypeSummary: Equatable { + public let successful: Int + public let duplicate: Int + public let failed: Int + + public var isEmpty: Bool { + self == .empty + } + + public static var empty: Self { + DataTypeSummary(successful: 0, duplicate: 0, failed: 0) + } + + public init(successful: Int, duplicate: Int, failed: Int) { + self.successful = successful + self.duplicate = duplicate + self.failed = failed + } + public init(_ bookmarksImportSummary: BookmarksImportSummary) { + self.init(successful: bookmarksImportSummary.successful, duplicate: bookmarksImportSummary.duplicates, failed: bookmarksImportSummary.failed) + } + } + + public enum ErrorType: String, CustomStringConvertible, CaseIterable { + case noData + case decryptionError + case dataCorrupted + case keychainError + case other + + public var description: String { rawValue } + } + +} + +public enum DataImportAction: String, RawRepresentable { + case bookmarks + case passwords + case favicons + case generic + + public init(_ type: DataImport.DataType) { + switch type { + case .bookmarks: self = .bookmarks + case .passwords: self = .passwords + } + } +} + +public protocol DataImportError: Error, CustomNSError, ErrorWithPixelParameters, LocalizedError { + associatedtype OperationType: RawRepresentable where OperationType.RawValue == Int + + var action: DataImportAction { get } + var type: OperationType { get } + var underlyingError: Error? { get } + + var errorType: DataImport.ErrorType { get } + +} +extension DataImportError /* : CustomNSError */ { + public var errorCode: Int { + type.rawValue + } + + public var errorUserInfo: [String: Any] { + guard let underlyingError else { return [:] } + return [ + NSUnderlyingErrorKey: underlyingError + ] + } +} +extension DataImportError /* : ErrorWithParameters */ { + public var errorParameters: [String: String] { + underlyingError?.pixelParameters ?? [:] + } +} +extension DataImportError /* : LocalizedError */ { + + public var errorDescription: String? { + let error = (self as NSError) + return "\(error.domain) \(error.code)" + { + guard let underlyingError = underlyingError as NSError? else { return "" } + return " (\(underlyingError.domain) \(underlyingError.code))" + }() + } + +} + +public struct FetchableRecordError: Error, CustomNSError { + let column: Int + + public static var errorDomain: String { "FetchableRecordError.\(T.self)" } + public var errorCode: Int { column } + + public init(column: Int) { + self.column = column + } + +} + +public enum DataImportProgressEvent { + case initial + case importingPasswords(numberOfPasswords: Int?, fraction: Double) + case importingBookmarks(numberOfBookmarks: Int?, fraction: Double) + case done +} + +public typealias DataImportSummary = [DataImport.DataType: DataImportResult] +public typealias DataImportTask = TaskWithProgress +public typealias DataImportProgressCallback = DataImportTask.ProgressUpdateCallback + +/// Represents an object able to import data from an outside source. The outside source may be capable of importing multiple types of data. +/// For instance, a browser data importer may be able to import passwords and bookmarks. +public protocol DataImporter { + + /// Performs a quick check to determine if the data is able to be imported. It does not guarantee that the import will succeed. + /// For example, a CSV importer will return true if the URL it has been created with is a CSV file, but does not check whether the CSV data matches the expected format. + var importableTypes: [DataImport.DataType] { get } + + /// validate file access/encryption password requirement before starting import. Returns non-empty dictionary with failures if access validation fails. + func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? + /// Start import process. Returns cancellable TaskWithProgress + func importData(types: Set) -> DataImportTask + + func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool + +} + +extension DataImporter { + + public var importableTypes: [DataImport.DataType] { + [.bookmarks, .passwords] + } + + public func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? { + nil + } + + public func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { + false + } + +} + +public enum DataImportResult: CustomStringConvertible { + case success(T) + case failure(any DataImportError) + + public func get() throws -> T { + switch self { + case .success(let value): + return value + case .failure(let error): + throw error + } + } + + public var isSuccess: Bool { + if case .success = self { + true + } else { + false + } + } + + public var error: (any DataImportError)? { + if case .failure(let error) = self { + error + } else { + nil + } + } + + /// Returns a new result, mapping any success value using the given transformation. + /// - Parameter transform: A closure that takes the success value of this instance. + /// - Returns: A `Result` instance with the result of evaluating `transform` + /// as the new success value if this instance represents a success. + @inlinable public func map(_ transform: (T) -> NewT) -> DataImportResult { + switch self { + case .success(let value): + return .success(transform(value)) + case .failure(let error): + return .failure(error) + } + } + + /// Returns a new result, mapping any success value using the given transformation and unwrapping the produced result. + /// + /// - Parameter transform: A closure that takes the success value of the instance. + /// - Returns: A `Result` instance, either from the closure or the previous + /// `.failure`. + @inlinable public func flatMap(_ transform: (T) throws -> DataImportResult) rethrows -> DataImportResult { + switch self { + case .success(let value): + switch try transform(value) { + case .success(let transformedValue): + return .success(transformedValue) + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } + } + + public var description: String { + switch self { + case .success(let value): + ".success(\(value))" + case .failure(let error): + ".failure(\(error))" + } + } + +} + +extension DataImportResult: Equatable where T: Equatable { + public static func == (lhs: DataImportResult, rhs: DataImportResult) -> Bool { + switch lhs { + case .success(let value): + if case .success(value) = rhs { + true + } else { + false + } + case .failure(let error1): + if case .failure(let error2) = rhs { + error1.errorParameters == error2.errorParameters + } else { + false + } + } + } + +} + +public struct LoginImporterError: DataImportError { + + private let error: Error? + private let _type: OperationType? + + public var action: DataImportAction { .passwords } + + public init(error: Error?, type: OperationType? = nil) { + self.error = error + self._type = type + } + + public struct OperationType: RawRepresentable, Equatable { + public let rawValue: Int + + static let malformedCSV = OperationType(rawValue: -2) + + public init(rawValue: Int) { + self.rawValue = rawValue + } + } + + public var type: OperationType { + _type ?? OperationType(rawValue: (error as NSError?)?.code ?? 0) + } + + public var underlyingError: Error? { + switch error { + case let secureStorageError as SecureStorageError: + switch secureStorageError { + case .initFailed(let error), + .authError(let error), + .failedToOpenDatabase(let error), + .databaseError(let error): + return error + + case .keystoreError(let status), .keystoreReadError(let status), .keystoreUpdateError(let status): + return NSError(domain: "KeyStoreError", code: Int(status)) + + case .secError(let status): + return NSError(domain: "secError", code: Int(status)) + + case .authRequired, + .invalidPassword, + .noL1Key, + .noL2Key, + .duplicateRecord, + .generalCryptoError, + .encodingFailed: + return secureStorageError + } + default: + return error + } + } + + public var errorType: DataImport.ErrorType { + if case .malformedCSV = type { + return .dataCorrupted + } + if let secureStorageError = error as? SecureStorageError { + switch secureStorageError { + case .initFailed, + .authError, + .failedToOpenDatabase, + .databaseError: + return .keychainError + + case .keystoreError, .secError, .keystoreReadError, .keystoreUpdateError: + return .keychainError + + case .authRequired, + .invalidPassword, + .noL1Key, + .noL2Key, + .duplicateRecord, + .generalCryptoError, + .encodingFailed: + return .decryptionError + } + } + return .other + } + +} diff --git a/Sources/BrowserServicesKit/DataImport/Logins/AutofillLoginImportStateStoring.swift b/Sources/BrowserServicesKit/DataImport/Logins/AutofillLoginImportStateStoring.swift new file mode 100644 index 000000000..a53940734 --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/Logins/AutofillLoginImportStateStoring.swift @@ -0,0 +1,22 @@ +// +// AutofillLoginImportStateStoring.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +public protocol AutofillLoginImportStateStoring { + var hasImportedLogins: Bool { get set } + var isCredentialsImportPromptPermanantlyDismissed: Bool { get set } +} diff --git a/Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVImporter.swift b/Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVImporter.swift new file mode 100644 index 000000000..67df5a551 --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVImporter.swift @@ -0,0 +1,341 @@ +// +// CSVImporter.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Common +import Foundation +import SecureStorage + +final public class CSVImporter: DataImporter { + + public struct ColumnPositions { + + private enum Regex { + // should end with "login" or "username" + static let username = regex("(?:^|\\b|\\s|_)(?:login|username)$", .caseInsensitive) + // should end with "password" or "pwd" + static let password = regex("(?:^|\\b|\\s|_)(?:password|pwd)$", .caseInsensitive) + // should end with "name" or "title" + static let title = regex("(?:^|\\b|\\s|_)(?:name|title)$", .caseInsensitive) + // should end with "url", "uri" + static let url = regex("(?:^|\\b|\\s|_)(?:url|uri)$", .caseInsensitive) + // should end with "notes" or "note" + static let notes = regex("(?:^|\\b|\\s|_)(?:notes|note)$", .caseInsensitive) + } + + static let rowFormatWithTitle = ColumnPositions(titleIndex: 0, urlIndex: 1, usernameIndex: 2, passwordIndex: 3) + static let rowFormatWithoutTitle = ColumnPositions(titleIndex: nil, urlIndex: 0, usernameIndex: 1, passwordIndex: 2) + + let maximumIndex: Int + + public let titleIndex: Int? + public let urlIndex: Int? + + public let usernameIndex: Int + public let passwordIndex: Int + + let notesIndex: Int? + + let isZohoVault: Bool + + init(titleIndex: Int?, urlIndex: Int?, usernameIndex: Int, passwordIndex: Int, notesIndex: Int? = nil, isZohoVault: Bool = false) { + self.titleIndex = titleIndex + self.urlIndex = urlIndex + self.usernameIndex = usernameIndex + self.passwordIndex = passwordIndex + self.notesIndex = notesIndex + self.maximumIndex = max(titleIndex ?? -1, urlIndex ?? -1, usernameIndex, passwordIndex, notesIndex ?? -1) + self.isZohoVault = isZohoVault + } + + private enum Format { + case general + case zohoGeneral + case zohoVault + } + + public init?(csv: [[String]]) { + guard csv.count > 1, + csv[1].count >= 3 else { return nil } + var headerRow = csv[0] + + var format = Format.general + + let usernameIndex: Int + if let idx = headerRow.firstIndex(where: { value in + Regex.username.matches(in: value, range: value.fullRange).isEmpty == false + }) { + usernameIndex = idx + headerRow[usernameIndex] = "" + + // Zoho + } else if headerRow.first == "Password Name" { + if let idx = csv[1].firstIndex(of: "SecretData") { + format = .zohoVault + usernameIndex = idx + } else if csv[1].count == 7 { + format = .zohoGeneral + usernameIndex = 5 + } else { + return nil + } + } else { + return nil + } + + let passwordIndex: Int + switch format { + case .general: + guard let idx = headerRow + .firstIndex(where: { !Regex.password.matches(in: $0, range: $0.fullRange).isEmpty }) else { return nil } + passwordIndex = idx + headerRow[passwordIndex] = "" + + case .zohoGeneral: + passwordIndex = usernameIndex + 1 + case .zohoVault: + passwordIndex = usernameIndex + } + + let titleIndex = headerRow.firstIndex(where: { !Regex.title.matches(in: $0, range: $0.fullRange).isEmpty }) + titleIndex.map { headerRow[$0] = "" } + + let urlIndex = headerRow.firstIndex(where: { !Regex.url.matches(in: $0, range: $0.fullRange).isEmpty }) + urlIndex.map { headerRow[$0] = "" } + + let notesIndex = headerRow.firstIndex(where: { !Regex.notes.matches(in: $0, range: $0.fullRange).isEmpty }) + + self.init(titleIndex: titleIndex, + urlIndex: urlIndex, + usernameIndex: usernameIndex, + passwordIndex: passwordIndex, + notesIndex: notesIndex, + isZohoVault: format == .zohoVault) + } + + public init?(source: DataImport.Source) { + switch source { + case .onePassword7, .onePassword8: + self.init(titleIndex: 3, urlIndex: 5, usernameIndex: 6, passwordIndex: 2) + case .lastPass, .firefox, .edge, .chrome, .chromium, .coccoc, .brave, .opera, .operaGX, + .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex, .csv, .bookmarksHTML, .bitwarden: + return nil + } + } + + } + + struct ImportError: DataImportError { + enum OperationType: Int { + case cannotReadFile + } + + var action: DataImportAction { .passwords } + let type: OperationType + let underlyingError: Error? + + var errorType: DataImport.ErrorType { + .dataCorrupted + } + } + + private let fileURL: URL? + private let csvContent: String? + private let loginImporter: LoginImporter + private let defaultColumnPositions: ColumnPositions? + private let secureVaultReporter: SecureVaultReporting + + public init(fileURL: URL?, csvContent: String? = nil, loginImporter: LoginImporter, defaultColumnPositions: ColumnPositions?, reporter: SecureVaultReporting) { + self.fileURL = fileURL + self.csvContent = csvContent + self.loginImporter = loginImporter + self.defaultColumnPositions = defaultColumnPositions + self.secureVaultReporter = reporter + } + + static func totalValidLogins(in fileURL: URL, defaultColumnPositions: ColumnPositions?) -> Int { + guard let fileContents = try? String(contentsOf: fileURL, encoding: .utf8) else { return 0 } + + let logins = extractLogins(from: fileContents, defaultColumnPositions: defaultColumnPositions) ?? [] + + return logins.count + } + + static public func totalValidLogins(in csvContent: String, defaultColumnPositions: ColumnPositions?) -> Int { + let logins = extractLogins(from: csvContent, defaultColumnPositions: defaultColumnPositions) ?? [] + + return logins.count + } + + public static func extractLogins(from fileContents: String, defaultColumnPositions: ColumnPositions? = nil) -> [ImportedLoginCredential]? { + guard let parsed = try? CSVParser().parse(string: fileContents) else { return nil } + + let columnPositions: ColumnPositions? + var startRow = 0 + if let autodetected = ColumnPositions(csv: parsed) { + columnPositions = autodetected + startRow = 1 + } else { + columnPositions = defaultColumnPositions + } + + guard parsed.indices.contains(startRow) else { return [] } // no data + + let result = parsed[startRow...].compactMap(columnPositions.read) + + guard !result.isEmpty else { + if parsed.filter({ !$0.isEmpty }).isEmpty { + return [] // no data + } else { + return nil // error: could not parse data + } + } + + return result + } + + public var importableTypes: [DataImport.DataType] { + return [.passwords] + } + + public func importData(types: Set) -> DataImportTask { + .detachedWithProgress { updateProgress in + do { + let result = try await self.importLoginsSync(updateProgress: updateProgress) + return [.passwords: result] + } catch is CancellationError { + } catch { + assertionFailure("Only CancellationError should be thrown here") + } + return [:] + } + } + + private func importLoginsSync(updateProgress: @escaping DataImportProgressCallback) async throws -> DataImportResult { + + try updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.0)) + + let fileContents: String + do { + if let csvContent = csvContent { + fileContents = csvContent + } else if let fileURL = fileURL { + fileContents = try String(contentsOf: fileURL, encoding: .utf8) + } else { + throw ImportError(type: .cannotReadFile, underlyingError: nil) + } + } catch { + return .failure(ImportError(type: .cannotReadFile, underlyingError: error)) + } + + do { + try updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.2)) + + let loginCredentials = try Self.extractLogins(from: fileContents, defaultColumnPositions: defaultColumnPositions) ?? { + try Task.checkCancellation() + throw LoginImporterError(error: nil, type: .malformedCSV) + }() + + try updateProgress(.importingPasswords(numberOfPasswords: loginCredentials.count, fraction: 0.5)) + + let summary = try loginImporter.importLogins(loginCredentials, reporter: secureVaultReporter) { count in + try updateProgress(.importingPasswords(numberOfPasswords: count, fraction: 0.5 + 0.5 * (Double(count) / Double(loginCredentials.count)))) + } + + try updateProgress(.importingPasswords(numberOfPasswords: loginCredentials.count, fraction: 1.0)) + + return .success(summary) + } catch is CancellationError { + throw CancellationError() + } catch let error as DataImportError { + return .failure(error) + } catch { + return .failure(LoginImporterError(error: error)) + } + } + +} + +extension ImportedLoginCredential { + + // Some browsers will export credentials with a header row. To detect this, the URL field on the first parsed row is checked whether it passes + // the data detector test. If it doesn't, it's assumed to be a header row. + fileprivate var isHeaderRow: Bool { + let types: NSTextCheckingResult.CheckingType = [.link] + + guard let detector = try? NSDataDetector(types: types.rawValue), + let url, !url.isEmpty else { return false } + + if detector.numberOfMatches(in: url, options: [], range: url.fullRange) > 0 { + return false + } + + return true + } + +} + +extension CSVImporter.ColumnPositions { + + func read(_ row: [String]) -> ImportedLoginCredential? { + let username: String + let password: String + + if isZohoVault { + // cell contents: + // SecretType:Web Account + // User Name:username + // Password:password + guard let lines = row[safe: usernameIndex]?.components(separatedBy: "\n"), + let usernameLine = lines.first(where: { $0.hasPrefix("User Name:") }), + let passwordLine = lines.first(where: { $0.hasPrefix("Password:") }) else { return nil } + + username = usernameLine.dropping(prefix: "User Name:") + password = passwordLine.dropping(prefix: "Password:") + + } else if let user = row[safe: usernameIndex], + let pass = row[safe: passwordIndex] { + + username = user + password = pass + } else { + return nil + } + + return ImportedLoginCredential(title: row[safe: titleIndex ?? -1], + url: row[safe: urlIndex ?? -1], + username: username, + password: password, + notes: row[safe: notesIndex ?? -1]) + } + +} + +extension CSVImporter.ColumnPositions? { + + func read(_ row: [String]) -> ImportedLoginCredential? { + let columnPositions = self ?? [ + .rowFormatWithTitle, + .rowFormatWithoutTitle + ].first(where: { + row.count > $0.maximumIndex + }) + + return columnPositions?.read(row) + } + +} diff --git a/Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVParser.swift b/Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVParser.swift new file mode 100644 index 000000000..f6d94acd9 --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVParser.swift @@ -0,0 +1,303 @@ +// +// CSVParser.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public struct CSVParser { + + public init() {} + + public func parse(string: String) throws -> [[String]] { + var parser = Parser() + + for character in string { + try Task.checkCancellation() + // errors are only handled at the Parser level in `.branching` state + try? parser.accept(character) + } + + // errors are only handled at the Parser level in `.branching` state + try? parser.flushField(final: true) + + return parser.result + } + + private enum State { + case start + case field + case enquotedField + case branching([Parser]) + + /// returns: `true` if case is `.branching` + mutating func performIfBranching(_ action: (inout Parser) throws -> Void) -> Bool { + guard case .branching(var parsers) = self else { return false } + + for idx in parsers.indices.reversed() { + do { + try action(&parsers[idx]) + } catch { + parsers.remove(at: idx) + } + } + self = .branching(parsers) + return true + } + } + + private struct Parser { + var delimiter: Character? + + enum QuoteEscapingType { + case unknown + case doubleQuote + case backslash + } + var quoteEscapingType = QuoteEscapingType.unknown + + var result: [[String]] = [[]] + + var state = State.start + enum PrecedingCharKind { + case none + case quote + case backslash + } + var precedingCharKind = PrecedingCharKind.none + + var currentField = "" + + enum ParserError: Error { + case unexpectedCharacterAfterQuote(Character) + case nonEnquotedFieldPayloadStart(Character) + case unexpectedDoubleQuoteInBackslashEscapedQuoteMode + case unexpectedEOF + } + + init() {} + + private func copy(applying action: (inout Self) -> Void) -> Self { + var copy = self + action(©) + return copy + } + + @inline(__always) mutating func flushField(final: Bool = false) throws { + if state.performIfBranching({ try $0.flushField(final: final) }) { + guard case .branching(let parsers) = state else { fatalError("Unexpected state") } + if parsers.count == 1 { + self = parsers[0] + } else if final, + let bestResult = parsers.max(by: { $0.result.reduce(0, { $0 + $1.count }) < $1.result.reduce(0, { $0 + $1.count }) }) { + // not expected corner case: branching parser state at the EOF + // find parser resulting with most fields + self = bestResult + } + return + } + result[result.endIndex - 1].append(currentField) + + let lastState = state + let lastPrecedingCharKind = precedingCharKind + + currentField = "" + state = .start + precedingCharKind = .none + + if final, case .enquotedField = lastState, lastPrecedingCharKind != .quote { + throw ParserError.unexpectedEOF + } + } + + @inline(__always) mutating func nextLine() throws { + try flushField() + result.append([]) + state = .start + precedingCharKind = .none + } + + mutating func accept(_ character: Character) throws { + if state.performIfBranching({ try $0.accept(character) }) { + if case .branching(let parsers) = state, parsers.count == 1 { + self = parsers[0] + } + return + } + + let kind = character.kind(delimiter: delimiter) + switch (state, kind, preceding: precedingCharKind, quoteEscaping: quoteEscapingType) { + case (_, .unsupported, _, _): + return // skip control characters + + // expecting field start + case (.start, .quote, _, _): + // enquoted field starting + state = .enquotedField + case (.start, .delimiter, _, _): + // empty field + try flushField() + delimiter = character + case (.start, .whitespace, _, _): + return // trim leading whitespaces + case (.start, .newline, _, _): + try nextLine() + case (.start, .payload, _, quoteEscaping: .backslash): + // all non-empty fields should be enquoted in backslash-escaped-quote mode + state = .field + currentField.append(character) + throw ParserError.nonEnquotedFieldPayloadStart(character) + case (.start, .payload, _, _), (.start, .backslash, _, _): + state = .field + currentField.append(character) + + // handle backslash + case (.enquotedField, .backslash, preceding: .none, quoteEscaping: .unknown), + (.enquotedField, .backslash, preceding: .none, quoteEscaping: .backslash): + precedingCharKind = .backslash + + case (.enquotedField, .quote, preceding: .backslash, quoteEscaping: .unknown): + // `\"` received in an unknown `quoteEscaping` state. + // It may be either just a backslash-escaped quote or a `\` followed by a field end - `"\n` or `",`. + // To figure the right way we do branching: + // branch that finishes without errors will be the chosen one. + state = .branching([ + // one parser will continue parsing in backslash-escaped-quote mode + copy { + $0.quoteEscapingType = .backslash + }, + // - another one will continue parsing in double-quote-escaped mode + copy{ + $0.quoteEscapingType = .doubleQuote + // take the backslash as a payload + $0.currentField.append("\\") + $0.precedingCharKind = .none + } + ]) + try self.accept(character) // feed the quote character to the parsers + + case (.enquotedField, .quote, preceding: .backslash, quoteEscaping: .backslash): + // `\"` received in backslash-escaped-quote mode + // it either means an escaped quote or a backslash followed by a field ending quote. + // To figure the right way we do branching: + // branch that finishes without errors will be the chosen one. + state = .branching([ + // - one parser will finish the field and continue parsing + copy { + // take the backslash as a payload at the end of the field + $0.currentField.append("\\") + // delimeter received next will finish the field + $0.precedingCharKind = .quote + }, + // - another one will unescape the quote and continue parsing the field + copy { + // append the quote and resume building the field + $0.currentField.append(character /* quote */) + $0.precedingCharKind = .none + } + ]) + + case (_, _, preceding: .backslash, _): + // any non-quote character following a backslash: it was just a backslash + precedingCharKind = .none + currentField.append("\\") + try self.accept(character) // feed the character again + + // quote in field body is escaped with 2 quotes (or with a backslash) + case (_, .quote, preceding: .none, _): + precedingCharKind = .quote + case (_, .quote, preceding: .quote, quoteEscaping: .unknown), + (_, .quote, preceding: .quote, quoteEscaping: .doubleQuote): + currentField.append(character /* quote */) + precedingCharKind = .none + quoteEscapingType = .doubleQuote + + // double quotes not allowed in backslash-escaped-quote mode + case (.enquotedField, .quote, preceding: .quote, quoteEscaping: .backslash): + precedingCharKind = .quote + currentField.append(character /* quote */) + throw ParserError.unexpectedDoubleQuoteInBackslashEscapedQuoteMode + + // enquoted field end + case (.enquotedField, .delimiter, .quote, _): + try flushField() + delimiter = character + case (.enquotedField, .newline, preceding: .quote, _): + try nextLine() + case (.enquotedField, .whitespace, preceding: .quote, _): + return // trim whitespaces between fields + + // unbalanced quote + case (_, _, preceding: .quote, _): + // only expecting a second quote after a quote in field body + currentField.append("\"") + currentField.append(character) + precedingCharKind = .none + throw ParserError.unexpectedCharacterAfterQuote(character) + + // non-enquoted field end + case (.field, .delimiter, _, _): + try flushField() + delimiter = character + case (.field, .newline, _, _): + try nextLine() + + default: + currentField.append(character) + } + } + } + +} + +private extension Character { + + enum Kind { + case backslash + case quote + case delimiter + case newline + case whitespace + case unsupported + case payload + } + + func kind(delimiter: Character?) -> Kind { + if self == "\\" { + .backslash + } else if self == "\"" { + .quote + } else if self.unicodeScalars.contains(where: { CharacterSet.unsupportedCharacters.contains($0) }) { + .unsupported + } else if CharacterSet.newlines.contains(unicodeScalars.first!) { + .newline + } else if CharacterSet.whitespaces.contains(unicodeScalars.first!) { + .whitespace + } else if self == delimiter || (delimiter == nil && CharacterSet.delimiters.contains(unicodeScalars.first!)) { + .delimiter + } else { + .payload + } + } + +} + +private extension CharacterSet { + + static let unsupportedCharacters = CharacterSet.controlCharacters.union(.illegalCharacters).subtracting(.newlines) + static let delimiters = CharacterSet(charactersIn: ",;") + +} diff --git a/Sources/BrowserServicesKit/DataImport/Logins/LoginImport.swift b/Sources/BrowserServicesKit/DataImport/Logins/LoginImport.swift new file mode 100644 index 000000000..f3c8c3749 --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/Logins/LoginImport.swift @@ -0,0 +1,44 @@ +// +// LoginImport.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SecureStorage + +public struct ImportedLoginCredential: Equatable { + + public let title: String? + public let url: String? + public let username: String + public let password: String + public let notes: String? + + public init(title: String? = nil, url: String?, username: String, password: String, notes: String? = nil) { + self.title = title + self.url = url.flatMap(URL.init(string:))?.host ?? url // Try to use the host if possible, as the Secure Vault saves credentials using the host. + self.username = username + self.password = password + self.notes = notes + } + +} + +public protocol LoginImporter { + + func importLogins(_ logins: [ImportedLoginCredential], reporter: SecureVaultReporting, progressCallback: @escaping (Int) throws -> Void) throws -> DataImport.DataTypeSummary + +} diff --git a/Sources/BrowserServicesKit/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift b/Sources/BrowserServicesKit/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift new file mode 100644 index 000000000..c64f17149 --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift @@ -0,0 +1,114 @@ +// +// SecureVaultLoginImporter.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SecureStorage + +public class SecureVaultLoginImporter: LoginImporter { + private var loginImportState: AutofillLoginImportStateStoring? + + public init(loginImportState: AutofillLoginImportStateStoring? = nil) { + self.loginImportState = loginImportState + } + + private enum ImporterError: Error { + case duplicate + } + + public func importLogins(_ logins: [ImportedLoginCredential], reporter: SecureVaultReporting, progressCallback: @escaping (Int) throws -> Void) throws -> DataImport.DataTypeSummary { + + let vault = try AutofillSecureVaultFactory.makeVault(reporter: reporter) + + var successful: [String] = [] + var duplicates: [String] = [] + var failed: [String] = [] + + let encryptionKey = try vault.getEncryptionKey() + let hashingSalt = try vault.getHashingSalt() + + let accounts = (try? vault.accounts()) ?? .init() + + try vault.inDatabaseTransaction { database in + for (idx, login) in logins.enumerated() { + let title = login.title + let account = SecureVaultModels.WebsiteAccount(title: title, username: login.username, domain: login.url, notes: login.notes) + let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: login.password.data(using: .utf8)!) + let importSummaryValue: String + + if let title = account.title { + importSummaryValue = "\(title): \(credentials.account.domain ?? "") (\(credentials.account.username ?? ""))" + } else { + importSummaryValue = "\(credentials.account.domain ?? "") (\(credentials.account.username ?? ""))" + } + + do { + if let signature = try vault.encryptPassword(for: credentials, key: encryptionKey, salt: hashingSalt).account.signature { + let isDuplicate = accounts.contains { + $0.isDuplicateOf(accountToBeImported: account, signatureOfAccountToBeImported: signature, passwordToBeImported: login.password) + } + if isDuplicate { + throw ImporterError.duplicate + } + } + _ = try vault.storeWebsiteCredentials(credentials, in: database, encryptedUsing: encryptionKey, hashedUsing: hashingSalt) + successful.append(importSummaryValue) + } catch { + if case .duplicateRecord = error as? SecureStorageError { + duplicates.append(importSummaryValue) + } else if case .duplicate = error as? ImporterError { + duplicates.append(importSummaryValue) + } else { + failed.append(importSummaryValue) + } + } + + try progressCallback(idx + 1) + } + } + + if successful.count > 0 { + NotificationCenter.default.post(name: .autofillSaveEvent, object: nil, userInfo: nil) + } + + loginImportState?.hasImportedLogins = true + return .init(successful: successful.count, duplicate: duplicates.count, failed: failed.count) + } +} + +extension SecureVaultModels.WebsiteAccount { + + // Deduplication rules: https://app.asana.com/0/0/1207598052765977/f + func isDuplicateOf(accountToBeImported: Self, signatureOfAccountToBeImported: String, passwordToBeImported: String?) -> Bool { + guard signature == signatureOfAccountToBeImported || passwordToBeImported.isNilOrEmpty else { + return false + } + guard username == accountToBeImported.username || accountToBeImported.username.isNilOrEmpty else { + return false + } + guard domain == accountToBeImported.domain || accountToBeImported.domain.isNilOrEmpty else { + return false + } + guard notes == accountToBeImported.notes || accountToBeImported.notes.isNilOrEmpty else { + return false + } + guard patternMatchedTitle() == accountToBeImported.patternMatchedTitle() || accountToBeImported.patternMatchedTitle().isEmpty else { + return false + } + return true + } +} diff --git a/Sources/BrowserServicesKit/DataImport/TaskWithProgress.swift b/Sources/BrowserServicesKit/DataImport/TaskWithProgress.swift new file mode 100644 index 000000000..cc1ecfc7b --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/TaskWithProgress.swift @@ -0,0 +1,169 @@ +// +// TaskWithProgress.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public typealias TaskProgressProgressEvent = TaskWithProgress.ProgressEvent +public typealias TaskProgress = AsyncStream> +public typealias TaskProgressUpdateCallback = TaskWithProgress.ProgressUpdateCallback + +/// Used to run an async Task while tracking its completion progress +/// Usage: +/// ``` +/// func doSomeOperation() -> TaskWithProgress { +/// .withProgress((total: 100, completed: 0)) { updateProgress in +/// try await part1() +/// updateProgress(50) +/// +/// try await part2() +/// updateProgress(100) +/// } +/// } +/// ``` +public struct TaskWithProgress: Sendable where Success: Sendable, Failure: Error { + + public enum ProgressEvent { + case progress(ProgressUpdate) + case completed(Result) + } + + public let task: Task + + public typealias Progress = AsyncStream + public typealias ProgressUpdateCallback = (ProgressUpdate) throws -> Void + + public let progress: Progress + + fileprivate init(task: Task, progress: Progress) { + self.task = task + self.progress = progress + } + + /// The task's result + var value: Success { + get async throws { + try await task.value + } + } + + /// The task's result + public var result: Result { + get async { + await task.result + } + } + + /// Cancel the Task + public func cancel() { + task.cancel() + } + + var isCancelled: Bool { + task.isCancelled + } + +} + +extension TaskWithProgress: Hashable { + + public func hash(into hasher: inout Hasher) { + task.hash(into: &hasher) + } + + public static func == (lhs: TaskWithProgress, rhs: TaskWithProgress) -> Bool { + lhs.task == rhs.task + } + +} + +extension TaskWithProgress where Failure == Never { + + /// The result from a nonthrowing task, after it completes. + public var value: Success { + get async { + await task.value + } + } + +} + +public protocol AnyTask { + associatedtype Success + associatedtype Failure +} +extension Task: AnyTask {} +extension TaskWithProgress: AnyTask {} + +extension AnyTask where Failure == Error { + + public static func detachedWithProgress(_ progress: ProgressUpdate? = nil, priority: TaskPriority? = nil, do operation: @escaping @Sendable (@escaping TaskProgressUpdateCallback) async throws -> Success) -> TaskWithProgress { + let (progressStream, progressContinuation) = TaskProgress.makeStream() + if let progress { + progressContinuation.yield(.progress(progress)) + } + + let task = Task.detached { + let updateProgressCallback: TaskProgressUpdateCallback = { update in + try Task.checkCancellation() + progressContinuation.yield(.progress(update)) + } + + defer { + progressContinuation.finish() + } + do { + let result = try await operation(updateProgressCallback) + progressContinuation.yield(.completed(.success(result))) + + return result + } catch { + progressContinuation.yield(.completed(.failure(error))) + throw error + } + } + + return TaskWithProgress(task: task, progress: progressStream) + } + +} + +extension AnyTask where Failure == Never { + + public static func detachedWithProgress(_ progress: ProgressUpdate? = nil, completed: UInt? = nil, priority: TaskPriority? = nil, do operation: @escaping @Sendable (@escaping TaskProgressUpdateCallback) async -> Success) -> TaskWithProgress { + let (progressStream, progressContinuation) = TaskProgress.makeStream() + if let progress { + progressContinuation.yield(.progress(progress)) + } + + let task = Task.detached { + let updateProgressCallback: TaskProgressUpdateCallback = { update in + try Task.checkCancellation() + progressContinuation.yield(.progress(update)) + } + + let result = await operation(updateProgressCallback) + progressContinuation.yield(.completed(.success(result))) + progressContinuation.finish() + + return result + } + + return TaskWithProgress(task: task, progress: progressStream) + } + +} diff --git a/Tests/BrowserServicesKitTests/DataImport/CSVImporterTests.swift b/Tests/BrowserServicesKitTests/DataImport/CSVImporterTests.swift new file mode 100644 index 000000000..15a8f2d58 --- /dev/null +++ b/Tests/BrowserServicesKitTests/DataImport/CSVImporterTests.swift @@ -0,0 +1,133 @@ +// +// CSVImporterTests.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest +@testable import BrowserServicesKit +import SecureStorage + +class CSVImporterTests: XCTestCase { + + let temporaryFileCreator = TemporaryFileCreator() + + override func tearDown() { + super.tearDown() + temporaryFileCreator.deleteCreatedTemporaryFiles() + } + + func testWhenImportingCSVFileWithHeader_ThenHeaderRowIsExcluded() { + let csvFileContents = """ + title,url,username,password + Some Title,duck.com,username,p4ssw0rd + """ + + let logins = CSVImporter.extractLogins(from: csvFileContents) + XCTAssertEqual(logins, [ImportedLoginCredential(title: "Some Title", url: "duck.com", username: "username", password: "p4ssw0rd")]) + } + + func testWhenImportingCSVFileWithHeader_AndHeaderHasBitwardenFormat_ThenHeaderRowIsExcluded() { + let csvFileContents = """ + name,login_uri,login_username,login_password + Some Title,duck.com,username,p4ssw0rd + """ + + let logins = CSVImporter.extractLogins(from: csvFileContents) + XCTAssertEqual(logins, [ImportedLoginCredential(title: "Some Title", url: "duck.com", username: "username", password: "p4ssw0rd")]) + } + + func testWhenImportingCSVFileWithHeader_ThenHeaderColumnPositionsAreRespected() { + let csvFileContents = """ + Password,Title,Username,Url + p4ssw0rd,"Some Title",username,duck.com + """ + + let logins = CSVImporter.extractLogins(from: csvFileContents) + XCTAssertEqual(logins, [ImportedLoginCredential(title: "Some Title", url: "duck.com", username: "username", password: "p4ssw0rd")]) + } + + func testWhenImportingCSVFileWithoutHeader_ThenNoRowsAreExcluded() { + let csvFileContents = """ + Some Title,duck.com,username,p4ssw0rd + """ + + let logins = CSVImporter.extractLogins(from: csvFileContents) + XCTAssertEqual(logins, [ImportedLoginCredential(title: "Some Title", url: "duck.com", username: "username", password: "p4ssw0rd")]) + } + + func testWhenImportingCSVDataFromTheFileSystem_AndNoTitleIsIncluded_ThenLoginCredentialsAreImported() async { + let mockLoginImporter = MockLoginImporter() + let file = "https://example.com/,username,password" + let savedFileURL = temporaryFileCreator.persist(fileContents: file.data(using: .utf8)!, named: "test.csv")! + let csvImporter = CSVImporter(fileURL: savedFileURL, loginImporter: mockLoginImporter, defaultColumnPositions: nil, reporter: MockSecureVaultReporting()) + + let result = await csvImporter.importData(types: [.passwords]).task.value + + XCTAssertEqual(result, [.passwords: .success(.init(successful: 1, duplicate: 0, failed: 0))]) + } + + func testWhenImportingCSVDataFromTheFileSystem_AndTitleIsIncluded_ThenLoginCredentialsAreImported() async { + let mockLoginImporter = MockLoginImporter() + let file = "title,https://example.com/,username,password" + let savedFileURL = temporaryFileCreator.persist(fileContents: file.data(using: .utf8)!, named: "test.csv")! + let csvImporter = CSVImporter(fileURL: savedFileURL, loginImporter: mockLoginImporter, defaultColumnPositions: nil, reporter: MockSecureVaultReporting()) + + let result = await csvImporter.importData(types: [.passwords]).task.value + + XCTAssertEqual(result, [.passwords: .success(.init(successful: 1, duplicate: 0, failed: 0))]) + } + + func testWhenInferringColumnPostions_AndColumnsAreValid_AndTitleIsIncluded_ThenPositionsAreCalculated() { + let csvValues = ["url", "username", "password", "title"] + let inferred = CSVImporter.ColumnPositions(csvValues: csvValues) + + XCTAssertEqual(inferred?.urlIndex, 0) + XCTAssertEqual(inferred?.usernameIndex, 1) + XCTAssertEqual(inferred?.passwordIndex, 2) + XCTAssertEqual(inferred?.titleIndex, 3) + } + + func testWhenInferringColumnPostions_AndColumnsAreValid_AndTitleIsNotIncluded_ThenPositionsAreCalculated() { + let csvValues = ["url", "username", "password"] + let inferred = CSVImporter.ColumnPositions(csvValues: csvValues) + + XCTAssertEqual(inferred?.urlIndex, 0) + XCTAssertEqual(inferred?.usernameIndex, 1) + XCTAssertEqual(inferred?.passwordIndex, 2) + XCTAssertNil(inferred?.titleIndex) + } + + func testWhenInferringColumnPostions_AndColumnsAreInvalidThenPositionsAreCalculated() { + let csvValues = ["url", "username", "title"] // `password` is required, this test verifies that the inference fails when it's missing + let inferred = CSVImporter.ColumnPositions(csvValues: csvValues) + + XCTAssertNil(inferred) + } + +} + +extension CSVImporter.ColumnPositions { + + init?(csvValues: [String]) { + self.init(csv: [csvValues, Array(repeating: "", count: csvValues.count)]) + } + +} + +private class MockSecureVaultReporting: SecureVaultReporting { + func secureVaultError(_ error: SecureStorage.SecureStorageError) {} +} diff --git a/Tests/BrowserServicesKitTests/DataImport/CSVParserTests.swift b/Tests/BrowserServicesKitTests/DataImport/CSVParserTests.swift new file mode 100644 index 000000000..6c8772133 --- /dev/null +++ b/Tests/BrowserServicesKitTests/DataImport/CSVParserTests.swift @@ -0,0 +1,226 @@ +// +// CSVParserTests.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest +@testable import BrowserServicesKit + +final class CSVParserTests: XCTestCase { + + func testWhenParsingMultipleRowsThenMultipleArraysAreReturned() throws { + let string = """ + line 1 + line 2 + """ + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["line 1"], ["line 2"]]) + } + + func testControlCharactersAreIgnored() throws { + let string = """ + \u{FEFF}line\u{10} 1\u{10} + line 2\u{FEFF} + """ + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["line 1"], ["line 2"]]) + } + + func testWhenParsingRowsWithVariableNumbersOfEntriesThenParsingSucceeds() throws { + let string = """ + one + two;three; + four;five;six + """ + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["one"], ["two", "three", ""], ["four", "five", "six"]]) + } + + func testWhenParsingRowsSurroundedByQuotesThenQuotesAreRemoved() throws { + let string = """ + "url","username", "password" + """ + " " + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["url", "username", "password"]]) + } + + func testWhenParsingMalformedCsvWithExtraQuote_ParserAddsItToOutput() throws { + let string = #""" + "url","user"name","password","" + "a.b.com/usdf","my@name.is","\"mypass\wrd\","" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [ + ["url", #"user"name"#, "password", ""], + ["a.b.com/usdf", "my@name.is", #""mypass\wrd\"#, ""], + ]) + } + + func testWhenParsingRowsWithDoubleQuoteEscapedQuoteThenQuoteIsUnescaped() throws { + let string = #""" + "url","username","password\""with""quotes\""and/slash""\" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["url", "username", #"password\"with"quotes\"and/slash"\"#]]) + } + + func testWhenParsingRowsWithBackslashEscapedQuoteThenQuoteIsUnescaped() throws { + let string = #""" + "\\","\"password\"with","quotes\","\",\", + """# + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [[#"\\"#, #""password"with"#, #"quotes\"#, #"",\"#, ""]]) + } + + func testWhenParsingRowsWithBackslashEscapedQuoteThenQuoteIsUnescaped2() throws { + let string = #""" + "\"", "\"\"","\\","\"password\"with","quotes\","\",\", + """# + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed.map({ $0.map { $0.replacingOccurrences(of: "\\", with: "|").replacingOccurrences(of: "\"", with: "”") } }), [["\"", "\"\"", #"\\"#, #""password"with"#, #"quotes\"#, #"",\"#, ""].map { $0.replacingOccurrences(of: "\\", with: "|").replacingOccurrences(of: "\"", with: "”") }]) + } + + func testWhenParsingRowsWithUnescapedTitleAndEscapedQuoteAndEmojiThenQuoteIsUnescaped() throws { + let string = #""" + Title,Url,Username,Password,OTPAuth,Notes + "A",,"",,,"It’s \\"you! 🖐 Select Edit to fill in more details, like your address and contact information.\", + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [ + ["Title", "Url", "Username", "Password", "OTPAuth", "Notes"], + ["A", "", "", "", "", #"It’s \"you! 🖐 Select Edit to fill in more details, like your address and contact information.\"#, ""] + ]) + } + + func testWhenParsingRowsWithEscapedQuotesAndLineBreaksQuotesUnescapedAndLinebreaksParsed() throws { + let string = #""" + Title,Url,Username,Password,OTPAuth,Notes + "А",,"",,,"It's\", + B,,,,you! 🖐 se\" ect Edit to fill in more details, like your address and contact + information.", + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [ + ["Title", "Url", "Username", "Password", "OTPAuth", "Notes"], + ["А", "", "", "", "", "It's\",\nB,,,,you! 🖐 se\" ect Edit to fill in more details, like your address and contact\ninformation.", ""] + ]) + } + + func testWhenParsingQuotedRowsContainingCommasThenTheyAreTreatedAsOneColumnEntry() throws { + let string = """ + "url","username","password,with,commas" + """ + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["url", "username", "password,with,commas"]]) + } + + func testWhenSingleValueWithQuotedEscapedQuoteThenQuoteIsUnescaped() throws { + let string = #""" + "\"" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["\""]]) + } + + func testSingleEnquotedBackslashValueIsParsedAsBackslash() throws { + let string = #""" + "\" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [[#"\"#]]) + } + + func testBackslashValueWithDoubleQuoteIsParsedAsBackslashWithQuote() throws { + let string = #""" + \"" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [[#"\""#]]) + } + + func testEnquotedBackslashValueWithDoubleQuoteIsParsedAsBackslashWithQuote() throws { + let string = #""" + "\""" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [[#"\""#]]) + } + + func testEscapedDoubleQuote() throws { + let string = #""" + """" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["\""]]) + } + + func testWhenValueIsDoubleQuotedThenQuotesAreUnescaped() throws { + let string = #""" + ""hello!"" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["\"hello!\""]]) + } + + func testWhenExpressionIsParsableEitherAsDoubleQuoteEscapedOrBackslashEscapedThenEqualFieldsNumberIsPreferred() throws { + let string = #""" + 1,2,3,4,5,6, + "\",b,c,d,e,f, + asdf,\"",hi there!,4,5,6, + a,b,c,d,e,f, + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [ + ["1", "2", "3", "4", "5", "6", ""], + ["\\", "b", "c", "d", "e", "f", ""], + ["asdf", #"\""#, "hi there!", "4", "5", "6", ""], + ["a", "b", "c", "d", "e", "f", ""], + ]) + } + +} diff --git a/Tests/BrowserServicesKitTests/DataImport/MockLoginImporter.swift b/Tests/BrowserServicesKitTests/DataImport/MockLoginImporter.swift new file mode 100644 index 000000000..ecf1f0e06 --- /dev/null +++ b/Tests/BrowserServicesKitTests/DataImport/MockLoginImporter.swift @@ -0,0 +1,33 @@ +// +// MockLoginImporter.swift +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +@testable import BrowserServicesKit +import SecureStorage + +final public class MockLoginImporter: LoginImporter { + var importedLogins: DataImportSummary? + + public func importLogins(_ logins: [BrowserServicesKit.ImportedLoginCredential], reporter: SecureVaultReporting, progressCallback: @escaping (Int) throws -> Void) throws -> DataImport.DataTypeSummary { + let summary = DataImport.DataTypeSummary(successful: logins.count, duplicate: 0, failed: 0) + + self.importedLogins = [.passwords: .success(summary)] + return summary + } + +} diff --git a/Tests/BrowserServicesKitTests/DataImport/TemporaryFileCreator.swift b/Tests/BrowserServicesKitTests/DataImport/TemporaryFileCreator.swift new file mode 100644 index 000000000..7b490798a --- /dev/null +++ b/Tests/BrowserServicesKitTests/DataImport/TemporaryFileCreator.swift @@ -0,0 +1,53 @@ +// +// TemporaryFileCreator.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest + +final class TemporaryFileCreator { + + var createdFileNames: [String] = [] + + func persist(fileContents: Data, named fileName: String) -> URL? { + let fileURL = temporaryURL(fileName: fileName) + + do { + try fileContents.write(to: fileURL) + createdFileNames.append(fileName) + + return fileURL + } catch { + XCTFail("\(#file): Failed to persist temporary file named '\(fileName)'") + } + + return nil + } + + func deleteCreatedTemporaryFiles() { + for fileName in createdFileNames { + let fileURL = temporaryURL(fileName: fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + + private func temporaryURL(fileName: String) -> URL { + let temporaryURL = FileManager.default.temporaryDirectory + return temporaryURL.appendingPathComponent(fileName) + } + +} diff --git a/Tests/BrowserServicesKitTests/DataImport/WebsiteAccount_isDuplicateTests.swift b/Tests/BrowserServicesKitTests/DataImport/WebsiteAccount_isDuplicateTests.swift new file mode 100644 index 000000000..84ff74a09 --- /dev/null +++ b/Tests/BrowserServicesKitTests/DataImport/WebsiteAccount_isDuplicateTests.swift @@ -0,0 +1,150 @@ +// +// WebsiteAccount_isDuplicateTests.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest +import GRDB +@testable import BrowserServicesKit + +final class WebsiteAccount_isDuplicateTests: XCTestCase { + + func test_strictDuplicate_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertTrue(isDuplicate) + } + + func test_strictAntithesis_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blerg", username: "blerg789", domain: "blerg.com", notes: "blehp di blerg blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blerg0blerg4blerg2", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_differingUsernamesOnly_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "different123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blerg0blerg4blerg2", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_usernameInStored_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: nil, domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_usernameInImport_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: nil, domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + // The end-to-end behaviour will actually be false due to the URL being used to create the signature + // https://app.asana.com/0/0/1207598052765977/1207736565669077/f + XCTAssertTrue(isDuplicate) + } + + func test_differing_passwordsOnly_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "differentSignature123", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_passwordInStored_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "nilPasswordStillHasSignature", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "nilPasswordStillHasSignature", passwordToBeImported: "password123") + // The end-to-end behaviour will actually be false due to the URL being used to create the signature + // https://app.asana.com/0/0/1207598052765977/1207736565669077/f + XCTAssertTrue(isDuplicate) + } + + func test_nil_passwordInImport_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "nilPasswordStillHasSignature", passwordToBeImported: nil) + XCTAssertTrue(isDuplicate) + } + + func test_differing_URLOnly_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "differing.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_URLInStored_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: nil, signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_URLInImport_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: nil, notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + // The end-to-end behaviour will actually be false due to the URL being used to create the signature + // https://app.asana.com/0/0/1207598052765977/1207736565669077/f + XCTAssertTrue(isDuplicate) + } + + func test_differing_notesOnly_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "differing notes") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_notesInStored_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: nil) + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_notesInImport_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: nil) + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertTrue(isDuplicate) + } + + func test_differing_titleOnly_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Different", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_titleInStored_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: nil, username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_titleInImport_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: nil, username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertTrue(isDuplicate) + } +}