From 632723d340a7b0dd034a8c71846d163ad7c644c4 Mon Sep 17 00:00:00 2001 From: Craig Russell Date: Wed, 29 May 2024 11:52:32 +0100 Subject: [PATCH] Add ability to support importing passwords from Google as CSV --- autofill/autofill-impl/build.gradle | 3 + .../autofill/impl/importing/CsvFileReader.kt | 43 +++++ .../impl/importing/CsvPasswordImporter.kt | 85 +++++++++ .../impl/importing/CsvPasswordParser.kt | 138 ++++++++++++++ .../impl/importing/DomainNameNormalizer.kt | 40 +++++ .../ExistingPasswordMatchDetector.kt | 48 +++++ .../importing/GooglePasswordBlobDecoder.kt | 44 +++++ .../importing/ImportedPasswordValidator.kt | 36 ++++ .../DefaultDomainNameNormalizerTest.kt | 74 ++++++++ ...efaultExistingPasswordMatchDetectorTest.kt | 64 +++++++ .../DefaultImportedPasswordValidatorTest.kt | 22 +++ ...glePasswordManagerCsvPasswordParserTest.kt | 168 ++++++++++++++++++ .../autofill/gpm_import_header_row_only.csv | 1 + .../gpm_import_header_row_unknown_field.csv | 1 + .../csv/autofill/gpm_import_missing_notes.csv | 2 + .../autofill/gpm_import_missing_password.csv | 2 + .../csv/autofill/gpm_import_missing_title.csv | 2 + .../autofill/gpm_import_missing_username.csv | 2 + .../gpm_import_one_valid_basic_password.csv | 2 + .../gpm_import_password_has_a_comma.csv | 2 + ...import_password_has_special_characters.csv | 2 + .../gpm_import_two_valid_basic_passwords.csv | 3 + ...m_import_two_valid_identical_passwords.csv | 3 + versions.properties | 2 + 24 files changed, 789 insertions(+) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvFileReader.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordParser.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingPasswordMatchDetector.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoder.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedPasswordValidator.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingPasswordMatchDetectorTest.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedPasswordValidatorTest.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvPasswordParserTest.kt create mode 100644 autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_only.csv create mode 100644 autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_unknown_field.csv create mode 100644 autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_notes.csv create mode 100644 autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_password.csv create mode 100644 autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_title.csv create mode 100644 autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_username.csv create mode 100644 autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_one_valid_basic_password.csv create mode 100644 autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_a_comma.csv create mode 100644 autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_special_characters.csv create mode 100644 autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_basic_passwords.csv create mode 100644 autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_identical_passwords.csv diff --git a/autofill/autofill-impl/build.gradle b/autofill/autofill-impl/build.gradle index d455ac29e520..8ccbc43a69fc 100644 --- a/autofill/autofill-impl/build.gradle +++ b/autofill/autofill-impl/build.gradle @@ -18,6 +18,7 @@ plugins { id 'com.android.library' id 'kotlin-android' id 'com.squareup.anvil' + id 'kotlin-parcelize' } apply from: "$rootProject.projectDir/gradle/android-library.gradle" @@ -59,6 +60,8 @@ dependencies { implementation "androidx.datastore:datastore-preferences:_" + implementation "de.siegmar:fastcsv:_" + implementation Square.retrofit2.converter.moshi implementation "com.squareup.moshi:moshi-kotlin:_" implementation "com.squareup.moshi:moshi-adapters:_" diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvFileReader.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvFileReader.kt new file mode 100644 index 000000000000..c996b80e392e --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvFileReader.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import android.content.Context +import android.net.Uri +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.io.BufferedReader +import java.io.InputStreamReader +import javax.inject.Inject + +interface CsvFileReader { + fun readCsvFile(fileUri: Uri): String +} + +@ContributesBinding(AppScope::class) +class ContentResolverFileReader @Inject constructor( + private val context: Context, +) : CsvFileReader { + + override fun readCsvFile(fileUri: Uri): String { + return context.contentResolver.openInputStream(fileUri)?.use { inputStream -> + BufferedReader(InputStreamReader(inputStream)).use { reader -> + reader.readText() + } + } ?: "" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt new file mode 100644 index 000000000000..2539628dd818 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import android.net.Uri +import android.os.Parcelable +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ParseResult +import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ParseResult.Success +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize + +interface CsvPasswordImporter { + suspend fun readCsv(blob: String): ParseResult + suspend fun readCsv(fileUri: Uri): ParseResult + + sealed interface ParseResult : Parcelable { + @Parcelize + data class Success(val numberPasswordsInSource: Int, val loginCredentialsToImport: List) : ParseResult + + @Parcelize + data object Error : ParseResult + } +} + +@ContributesBinding(AppScope::class) +class GooglePasswordManagerCsvPasswordImporter @Inject constructor( + private val parser: CsvPasswordParser, + private val fileReader: CsvFileReader, + private val credentialValidator: ImportedPasswordValidator, + private val domainNameNormalizer: DomainNameNormalizer, + private val dispatchers: DispatcherProvider, + private val blobDecoder: GooglePasswordBlobDecoder, +) : CsvPasswordImporter { + + override suspend fun readCsv(blob: String): ParseResult { + return kotlin.runCatching { + withContext(dispatchers.io()) { + val csv = blobDecoder.decode(blob) + convertToLoginCredentials(csv) + } + }.getOrElse { ParseResult.Error } + } + + override suspend fun readCsv(fileUri: Uri): ParseResult { + return kotlin.runCatching { + withContext(dispatchers.io()) { + val csv = fileReader.readCsvFile(fileUri) + convertToLoginCredentials(csv) + } + }.getOrElse { ParseResult.Error } + } + + private suspend fun convertToLoginCredentials(csv: String): Success { + val allPasswords = parser.parseCsv(csv) + val dedupedPasswords = allPasswords.distinct() + val validPasswords = filterValidPasswords(dedupedPasswords) + val normalizedDomains = domainNameNormalizer.normalizeDomains(validPasswords) + return Success(allPasswords.size, normalizedDomains) + } + + private fun filterValidPasswords(passwords: List): List { + return passwords.filter { it.isValid() } + } + + private fun LoginCredentials.isValid(): Boolean = credentialValidator.isValid(this) +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordParser.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordParser.kt new file mode 100644 index 000000000000..f3ab7664da24 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordParser.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import de.siegmar.fastcsv.reader.CsvReader +import de.siegmar.fastcsv.reader.CsvRow +import javax.inject.Inject +import kotlinx.coroutines.withContext +import timber.log.Timber + +interface CsvPasswordParser { + suspend fun parseCsv(csv: String): List +} + +@ContributesBinding(AppScope::class) +class GooglePasswordManagerCsvPasswordParser @Inject constructor( + private val dispatchers: DispatcherProvider, +) : CsvPasswordParser { + +// private val csvFormat by lazy { +// CSVFormat.Builder.create(CSVFormat.DEFAULT).build() +// } + + override suspend fun parseCsv(csv: String): List { + return kotlin.runCatching { + convertToPasswordList(csv).also { + Timber.i("Parsed CSV. Found %d passwords", it.size) + } + }.onFailure { + Timber.e("Failed to parse CSV: %s", it.message) + }.getOrElse { + emptyList() + } + } + + /** + * Format of the Google Password Manager CSV is: + * name | url | username | password | note + */ + private suspend fun convertToPasswordList(csv: String): List { + return withContext(dispatchers.io()) { + val lines = mutableListOf() + val iter = CsvReader.builder().build(csv).spliterator() + iter.forEachRemaining { lines.add(it) } + Timber.d("Found %d lines in the CSV", lines.size) + + lines.firstOrNull().verifyExpectedFormat() + + // drop the header row + val passwordsLines = lines.drop(1) + + Timber.v("About to parse %d passwords", passwordsLines.size) + return@withContext passwordsLines + .mapNotNull { + if (it.fields.size != EXPECTED_HEADERS.size) { + Timber.w("CSV line does not match expected format. Expected ${EXPECTED_HEADERS.size} parts, found ${it.fields.size}") + return@mapNotNull null + } + + parseToCredential( + domainTitle = it.getField(0).blanksToNull(), + domain = it.getField(1).blanksToNull(), + username = it.getField(2).blanksToNull(), + password = it.getField(3).blanksToNull(), + notes = it.getField(4).blanksToNull(), + ) + } + } + } + + private fun parseToCredential( + domainTitle: String?, + domain: String?, + username: String?, + password: String?, + notes: String?, + ): LoginCredentials { + return LoginCredentials( + domainTitle = domainTitle, + domain = domain, + username = username, + password = password, + notes = notes, + ) + } + + private fun String?.blanksToNull(): String? { + return if (isNullOrBlank()) null else this + } + + private fun CsvRow?.verifyExpectedFormat() { + if (this == null) { + throw IllegalArgumentException("File not recognised as a CSV") + } + + val headers = this.fields + + if (headers.size != EXPECTED_HEADERS.size) { + throw IllegalArgumentException( + "CSV header size does not match expected amount. Expected: ${EXPECTED_HEADERS.size}, found: ${headers.size}", + ) + } + + headers.forEachIndexed { index, value -> + if (value != EXPECTED_HEADERS[index]) { + throw IllegalArgumentException("CSV header does not match expected format. Expected: ${EXPECTED_HEADERS[index]}, found: $value") + } + } + } + + companion object { + val EXPECTED_HEADERS = listOf( + "name", + "url", + "username", + "password", + "note", + ) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt new file mode 100644 index 000000000000..f90e89298299 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface DomainNameNormalizer { + suspend fun normalizeDomains(unnormalized: List): List +} + +@ContributesBinding(AppScope::class) +class DefaultDomainNameNormalizer @Inject constructor( + private val urlMatcher: AutofillUrlMatcher, +) : DomainNameNormalizer { + override suspend fun normalizeDomains(unnormalized: List): List { + return unnormalized.map { + val currentDomain = it.domain ?: return@map null + val normalizedDomain = urlMatcher.cleanRawUrl(currentDomain) + it.copy(domain = normalizedDomain) + }.filterNotNull() + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingPasswordMatchDetector.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingPasswordMatchDetector.kt new file mode 100644 index 000000000000..e41892226212 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingPasswordMatchDetector.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.flow.firstOrNull + +interface ExistingPasswordMatchDetector { + suspend fun alreadyExists(newCredentials: LoginCredentials): Boolean +} + +@ContributesBinding(AppScope::class) +class DefaultExistingPasswordMatchDetector @Inject constructor( + private val urlMatcher: AutofillUrlMatcher, + private val autofillStore: InternalAutofillStore, +) : ExistingPasswordMatchDetector { + + override suspend fun alreadyExists(newCredentials: LoginCredentials): Boolean { + val credentials = autofillStore.getAllCredentials().firstOrNull() ?: return false + + return credentials.any { existing -> + existing.domain == newCredentials.domain && + existing.username == newCredentials.username && + existing.password == newCredentials.password && + existing.domainTitle == newCredentials.domainTitle && + existing.notes == newCredentials.notes + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoder.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoder.kt new file mode 100644 index 000000000000..b593bb9fd710 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoder.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import android.util.Base64 +import android.util.Base64.DEFAULT +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext + +interface GooglePasswordBlobDecoder { + suspend fun decode(data: String): String +} + +@ContributesBinding(AppScope::class) +class GooglePasswordBlobDecoderImpl @Inject constructor( + private val dispatchers: DispatcherProvider, +) : GooglePasswordBlobDecoder { + + override suspend fun decode(data: String): String { + return withContext(dispatchers.io()) { + val base64Data = data.split(",")[1] + val decodedBytes = Base64.decode(base64Data, DEFAULT) + val decoded = String(decodedBytes, Charsets.UTF_8) + return@withContext decoded + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedPasswordValidator.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedPasswordValidator.kt new file mode 100644 index 000000000000..bb6f6e56b2b5 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedPasswordValidator.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface ImportedPasswordValidator { + fun isValid(loginCredentials: LoginCredentials): Boolean +} + +@ContributesBinding(AppScope::class) +class DefaultImportedPasswordValidator @Inject constructor() : ImportedPasswordValidator { + + override fun isValid(loginCredentials: LoginCredentials): Boolean { + return with(loginCredentials) { + domain.isNullOrBlank().not() && password.isNullOrBlank().not() + } + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt new file mode 100644 index 000000000000..e9d59bb9d15f --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt @@ -0,0 +1,74 @@ +package com.duckduckgo.autofill.impl.importing + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.encoding.UrlUnicodeNormalizerImpl +import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultDomainNameNormalizerTest { + + private val testee = DefaultDomainNameNormalizer(AutofillDomainNameUrlMatcher(UrlUnicodeNormalizerImpl())) + + @Test + fun whenEmptyInputThenEmptyOutput() = runTest { + val input = emptyList() + val output = testee.normalizeDomains(input) + assertTrue(output.isEmpty()) + } + + @Test + fun whenInputDomainAlreadyNormalizedThenIncludedInOutput() = runTest { + val input = listOf(creds(domain = "example.com")) + val output = testee.normalizeDomains(input) + assertEquals(1, output.size) + assertEquals(creds(), output.first()) + } + + @Test + fun whenInputDomainNotAlreadyNormalizedThenIncludedInOutput() = runTest { + val input = listOf(creds(domain = "https://example.com/foo/bar")) + val output = testee.normalizeDomains(input) + assertEquals(1, output.size) + assertEquals(creds(), output.first()) + } + + @Test + fun whenInputDomainIsNullThenNotIncludedInOutput() = runTest { + val input = listOf(creds(domain = null)) + val output = testee.normalizeDomains(input) + assertTrue(output.isEmpty()) + } + + @Test + fun whenManyInputsWithOneNullDomainThenOnlyMissingDomainIsNotIncludedInOutput() = runTest { + val input = listOf( + creds(), + creds(domain = null), + creds(), + ) + val output = testee.normalizeDomains(input) + assertEquals(2, output.size) + } + + private fun creds( + domain: String? = "example.com", + username: String? = "username", + password: String? = "password", + notes: String? = "notes", + domainTitle: String? = "example title", + ): LoginCredentials { + return LoginCredentials( + domainTitle = domainTitle, + domain = domain, + username = username, + password = password, + notes = notes, + ) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingPasswordMatchDetectorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingPasswordMatchDetectorTest.kt new file mode 100644 index 000000000000..30512fc2296d --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingPasswordMatchDetectorTest.kt @@ -0,0 +1,64 @@ +package com.duckduckgo.autofill.impl.importing + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.encoding.UrlUnicodeNormalizerImpl +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class DefaultExistingPasswordMatchDetectorTest { + + private val autofillStore: InternalAutofillStore = mock() + + private val testee = DefaultExistingPasswordMatchDetector( + urlMatcher = AutofillDomainNameUrlMatcher(UrlUnicodeNormalizerImpl()), + autofillStore = autofillStore, + ) + + @Test + fun whenNoStoredPasswordsThenDoesNotAlreadyExist() = runTest { + configureNoStoredPasswords() + val creds = creds() + assertFalse(testee.alreadyExists(creds)) + } + + @Test + fun whenStoredPasswordsIsExactMatchThenAlreadyExists() = runTest { + configureStoredPasswords(listOf(creds())) + val creds = creds() + assertTrue(testee.alreadyExists(creds)) + } + + private suspend fun configureNoStoredPasswords() { + whenever(autofillStore.getAllCredentials()).thenReturn(flowOf(emptyList())) + } + + private suspend fun configureStoredPasswords(credentials: List) { + whenever(autofillStore.getAllCredentials()).thenReturn(flowOf(credentials)) + } + + private fun creds( + domain: String = "example.com", + username: String = "username", + password: String = "password", + notes: String = "notes", + domainTitle: String = "example title", + ): LoginCredentials { + return LoginCredentials( + domainTitle = domainTitle, + domain = domain, + username = username, + password = password, + notes = notes, + ) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedPasswordValidatorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedPasswordValidatorTest.kt new file mode 100644 index 000000000000..42b8c53c5afd --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedPasswordValidatorTest.kt @@ -0,0 +1,22 @@ +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import org.junit.Assert.* +import org.junit.Test + +class DefaultImportedPasswordValidatorTest { + private val testee = DefaultImportedPasswordValidator() + + @Test + fun whenThen() { + assertTrue(testee.isValid(validCreds())) + } + + private fun validCreds(): LoginCredentials { + return LoginCredentials( + username = "username", + password = "password", + domain = "example.com", + ) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvPasswordParserTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvPasswordParserTest.kt new file mode 100644 index 000000000000..a965cbab10c5 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvPasswordParserTest.kt @@ -0,0 +1,168 @@ +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.test.FileUtilities +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class GooglePasswordManagerCsvPasswordParserTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val testee = GooglePasswordManagerCsvPasswordParser( + dispatchers = coroutineTestRule.testDispatcherProvider, + ) + + @Test + fun whenEmptyStringThenNoPasswords() = runTest { + val passwords = testee.parseCsv("") + assertEquals(0, passwords.size) + } + + @Test + fun whenHeaderRowOnlyThenNoPasswords() = runTest { + val csv = "gpm_import_header_row_only".readFile() + val passwords = testee.parseCsv(csv) + assertEquals(0, passwords.size) + } + + @Test + fun whenHeaderRowHasUnknownFieldThenNoPasswords() = runTest { + val csv = "gpm_import_header_row_unknown_field".readFile() + val passwords = testee.parseCsv(csv) + assertEquals(0, passwords.size) + } + + @Test + fun whenHeadersRowAndOnePasswordRowThen1Password() = runTest { + val csv = "gpm_import_one_valid_basic_password".readFile() + val passwords = testee.parseCsv(csv) + assertEquals(1, passwords.size) + passwords.first().verifyMatchesCreds1() + } + + @Test + fun whenHeadersRowAndTwoDifferentPasswordsThen2Passwords() = runTest { + val csv = "gpm_import_two_valid_basic_passwords".readFile() + val passwords = testee.parseCsv(csv) + assertEquals(2, passwords.size) + passwords[0].verifyMatchesCreds1() + passwords[1].verifyMatchesCreds2() + } + + @Test + fun whenTwoIdenticalPasswordsThen2Passwords() = runTest { + val csv = "gpm_import_two_valid_identical_passwords".readFile() + val passwords = testee.parseCsv(csv) + assertEquals(2, passwords.size) + passwords[0].verifyMatchesCreds1() + passwords[1].verifyMatchesCreds1() + } + + @Test + fun whenPasswordContainsACommaThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_password_has_a_comma".readFile() + val passwords = testee.parseCsv(csv) + + assertEquals(1, passwords.size) + val expected = LoginCredentials( + domain = "https://example.com", + domainTitle = "example.com", + username = "user", + password = "password, a comma it has", + notes = "notes", + ) + passwords.first().verifyMatches(expected) + } + + @Test + fun whenPasswordContainsOtherSpecialCharactersThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_password_has_special_characters".readFile() + val passwords = testee.parseCsv(csv) + + assertEquals(1, passwords.size) + val expected = creds1.copy(password = "p\$ssw0rd`\"[]'\\") + passwords.first().verifyMatches(expected) + } + + @Test + fun whenNotesIsEmptyThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_missing_notes".readFile() + val passwords = testee.parseCsv(csv) + + assertEquals(1, passwords.size) + passwords.first().verifyMatches(creds1.copy(notes = null)) + } + + @Test + fun whenUsernameIsEmptyThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_missing_username".readFile() + val passwords = testee.parseCsv(csv) + + assertEquals(1, passwords.size) + passwords.first().verifyMatches(creds1.copy(username = null)) + } + + @Test + fun whenPasswordIsEmptyThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_missing_password".readFile() + val passwords = testee.parseCsv(csv) + + assertEquals(1, passwords.size) + passwords.first().verifyMatches(creds1.copy(password = null)) + } + + @Test + fun whenTitleIsEmptyThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_missing_title".readFile() + val passwords = testee.parseCsv(csv) + + assertEquals(1, passwords.size) + passwords.first().verifyMatches(creds1.copy(domainTitle = null)) + } + + private fun LoginCredentials.verifyMatchesCreds1() = verifyMatches(creds1) + private fun LoginCredentials.verifyMatchesCreds2() = verifyMatches(creds2) + + private fun LoginCredentials.verifyMatches(expected: LoginCredentials) { + assertEquals(expected.domainTitle, domainTitle) + assertEquals(expected.domain, domain) + assertEquals(expected.username, username) + assertEquals(expected.password, password) + assertEquals(expected.notes, notes) + } + + private val creds1 = LoginCredentials( + domain = "https://example.com", + domainTitle = "example.com", + username = "user", + password = "password", + notes = "note", + ) + + private val creds2 = LoginCredentials( + domain = "https://example.net", + domainTitle = "example.net", + username = "user2", + password = "password2", + notes = "note2", + ) + + private fun String.readFile(): String { + val fileContents = kotlin.runCatching { + FileUtilities.loadText( + GooglePasswordManagerCsvPasswordParserTest::class.java.classLoader!!, + "csv/autofill/$this.csv", + ) + }.getOrNull() + + if (fileContents == null) { + throw IllegalArgumentException("Failed to load specified CSV file: $this") + } + return fileContents + } +} diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_only.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_only.csv new file mode 100644 index 000000000000..56ea2b0b131e --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_only.csv @@ -0,0 +1 @@ +name,url,username,password,note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_unknown_field.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_unknown_field.csv new file mode 100644 index 000000000000..1d3982e6b801 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_unknown_field.csv @@ -0,0 +1 @@ +name,url,username,password,note,unknown \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_notes.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_notes.csv new file mode 100644 index 000000000000..d23607118f6b --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_notes.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,https://example.com,user,"password", \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_password.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_password.csv new file mode 100644 index 000000000000..11d0c635a6c6 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_password.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,https://example.com,user,,note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_title.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_title.csv new file mode 100644 index 000000000000..30707aac64fb --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_title.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +,https://example.com,user,password,note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_username.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_username.csv new file mode 100644 index 000000000000..7a8782bb31f9 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_username.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,https://example.com,,password,note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_one_valid_basic_password.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_one_valid_basic_password.csv new file mode 100644 index 000000000000..a72dbddaa359 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_one_valid_basic_password.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,https://example.com,user,password,note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_a_comma.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_a_comma.csv new file mode 100644 index 000000000000..a86c81f1b71a --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_a_comma.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,https://example.com,user,"password, a comma it has",notes \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_special_characters.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_special_characters.csv new file mode 100644 index 000000000000..4e59eadb4927 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_special_characters.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,https://example.com,user,"p$ssw0rd`""[]'\",note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_basic_passwords.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_basic_passwords.csv new file mode 100644 index 000000000000..b873993e01f3 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_basic_passwords.csv @@ -0,0 +1,3 @@ +name,url,username,password,note +example.com,https://example.com,user,password,note +example.net,https://example.net,user2,password2,note2 \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_identical_passwords.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_identical_passwords.csv new file mode 100644 index 000000000000..513747e4a57c --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_identical_passwords.csv @@ -0,0 +1,3 @@ +name,url,username,password,note +example.com,https://example.com,user,password,note +example.com,https://example.com,user,password,note \ No newline at end of file diff --git a/versions.properties b/versions.properties index e5cb457becc1..0ea6b052d5c3 100644 --- a/versions.properties +++ b/versions.properties @@ -160,3 +160,5 @@ version.com.journeyapps..zxing-android-embedded=4.3.0 version.com.google.zxing..core=3.5.3 version.android.tools.desugar_jdk_libs=2.1.2 + +version.de.siegmar..fastcsv=2.2.2 \ No newline at end of file