diff options
Diffstat (limited to 'mobile/android/android-components/components/service/digitalassetlinks/src')
21 files changed, 1539 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/digitalassetlinks/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest /> diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinder.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinder.kt new file mode 100644 index 0000000000..bd4e497d34 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinder.kt @@ -0,0 +1,128 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks + +import android.annotation.SuppressLint +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.Signature +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import androidx.annotation.VisibleForTesting +import mozilla.components.support.utils.ext.getPackageInfoCompat +import java.io.ByteArrayInputStream +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.security.cert.CertificateEncodingException +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +/** + * Get the SHA256 certificate for an installed Android app. + */ +class AndroidAssetFinder { + + /** + * Converts the Android App with the given package name into an asset descriptor + * by computing the SHA256 certificate for each signing signature. + * + * The output is lazily computed. If desired, only the first item from the sequence could + * be used and other certificates (if any) will not be computed. + */ + fun getAndroidAppAsset( + packageName: String, + packageManager: PackageManager, + ): Sequence<AssetDescriptor.Android> { + return packageManager.getSignatures(packageName).asSequence() + .mapNotNull { signature -> getCertificateSHA256Fingerprint(signature) } + .map { fingerprint -> AssetDescriptor.Android(packageName, fingerprint) } + } + + /** + * Computes the SHA256 certificate for the given package name. The app with the given package + * name has to be installed on device. The output will be a 30 long HEX string with : between + * each value. + * @return The SHA256 certificate for the package name. + */ + @VisibleForTesting + internal fun getCertificateSHA256Fingerprint(signature: Signature): String? { + val input = ByteArrayInputStream(signature.toByteArray()) + return try { + val certificate = CertificateFactory.getInstance("X509").generateCertificate(input) as X509Certificate + byteArrayToHexString(MessageDigest.getInstance("SHA256").digest(certificate.encoded)) + } catch (e: CertificateEncodingException) { + // Certificate type X509 encoding failed + null + } catch (e: CertificateException) { + throw AssertionError("Should not happen", e) + } catch (e: NoSuchAlgorithmException) { + throw AssertionError("Should not happen", e) + } + } + + @Suppress("PackageManagerGetSignatures") + // https://stackoverflow.com/questions/39192844/android-studio-warning-when-using-packagemanager-get-signatures + private fun PackageManager.getSignatures(packageName: String): Array<Signature> { + val packageInfo = getPackageSignatureInfo(packageName) ?: return emptyArray() + + return if (SDK_INT >= Build.VERSION_CODES.P) { + val signingInfo = packageInfo.signingInfo + if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners + } else { + val history = signingInfo.signingCertificateHistory + if (history.isEmpty()) { + emptyArray() + } else { + arrayOf(history.first()) + } + } + } else { + @Suppress("Deprecation") + packageInfo.signatures + } + } + + @SuppressLint("PackageManagerGetSignatures") + private fun PackageManager.getPackageSignatureInfo(packageName: String): PackageInfo? { + return try { + if (SDK_INT >= Build.VERSION_CODES.P) { + getPackageInfoCompat(packageName, PackageManager.GET_SIGNING_CERTIFICATES) + } else { + @Suppress("Deprecation") + getPackageInfo(packageName, PackageManager.GET_SIGNATURES) + } + } catch (e: PackageManager.NameNotFoundException) { + // Will return null if there is no package found. + return null + } + } + + /** + * Converts a byte array to hex string with : inserted between each element. + * @param bytes The array to be converted. + * @return A string with two letters representing each byte and : in between. + */ + @Suppress("MagicNumber") + @VisibleForTesting + internal fun byteArrayToHexString(bytes: ByteArray): String { + val hexString = StringBuilder(bytes.size * HEX_STRING_SIZE - 1) + var v: Int + for (j in bytes.indices) { + v = bytes[j].toInt() and 0xFF + hexString.append(HEX_CHAR_LOOKUP[v.ushr(HALF_BYTE)]) + hexString.append(HEX_CHAR_LOOKUP[v and 0x0F]) + if (j < bytes.lastIndex) hexString.append(':') + } + return hexString.toString() + } + + companion object { + private const val HALF_BYTE = 4 + private const val HEX_STRING_SIZE = "0F:".length + private val HEX_CHAR_LOOKUP = "0123456789ABCDEF".toCharArray() + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AssetDescriptor.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AssetDescriptor.kt new file mode 100644 index 0000000000..a3f81799e2 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AssetDescriptor.kt @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks + +/** + * Uniquely identifies an asset. + * + * A digital asset is an identifiable and addressable online entity that typically provides some + * service or content. + */ +sealed class AssetDescriptor { + + /** + * A web site asset descriptor. + * @property site URI representing the domain of the website. + * @sample + * AssetDescriptor.Web( + * site = "https://{fully-qualified domain}{:optional port}" + * ) + */ + data class Web( + val site: String, + ) : AssetDescriptor() + + /** + * An Android app asset descriptor. + * @property packageName Package name for the Android app. + * @property sha256CertFingerprint A colon-separated hex string. + * @sample + * AssetDescriptor.Android( + * packageName = "com.costingtons.app", + * sha256CertFingerprint = "A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5" + * ) + */ + data class Android( + val packageName: String, + val sha256CertFingerprint: String, + ) : AssetDescriptor() +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Constants.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Constants.kt new file mode 100644 index 0000000000..e1860c0463 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Constants.kt @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks + +import java.util.concurrent.TimeUnit + +@Suppress("MagicNumber", "TopLevelPropertyNaming") +internal val TIMEOUT = 3L to TimeUnit.SECONDS diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Relation.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Relation.kt new file mode 100644 index 0000000000..2cc4c32429 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Relation.kt @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks + +/** + * Describes the nature of a statement, and consists of a kind and a detail. + * @property kindAndDetail Kind and detail, separated by a slash character. + */ +enum class Relation(val kindAndDetail: String) { + + /** + * Grants the target permission to retrieve sign-in credentials stored for the source. + * For App -> Web transitions, requests the app to use the declared origin to be used as origin + * for the client app in the web APIs context. + */ + USE_AS_ORIGIN("delegate_permission/common.use_as_origin"), + + /** + * Requests the ability to handle all URLs from a given origin. + */ + HANDLE_ALL_URLS("delegate_permission/common.handle_all_urls"), + + /** + * Grants the target permission to retrieve sign-in credentials stored for the source. + */ + GET_LOGIN_CREDS("delegate_permission/common.get_login_creds"), +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/RelationChecker.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/RelationChecker.kt new file mode 100644 index 0000000000..7134b6cd68 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/RelationChecker.kt @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks + +/** + * Verifies that a source is linked to a target. + */ +interface RelationChecker { + + /** + * Performs a check to ensure a directional relationships exists between the specified + * [source] and [target] assets. The relationship must match the [relation] type given. + */ + fun checkRelationship( + source: AssetDescriptor.Web, + relation: Relation, + target: AssetDescriptor, + ): Boolean +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Statement.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Statement.kt new file mode 100644 index 0000000000..5686e74375 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Statement.kt @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks + +/** + * Represents a statement that can be found in an assetlinks.json file. + */ +sealed class StatementResult + +/** + * Entry in a Digital Asset Links statement file. + */ +data class Statement( + val relation: Relation, + val target: AssetDescriptor, +) : StatementResult() + +/** + * Include statements point to another Digital Asset Links statement file. + */ +data class IncludeStatement( + val include: String, +) : StatementResult() diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/StatementListFetcher.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/StatementListFetcher.kt new file mode 100644 index 0000000000..435332635f --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/StatementListFetcher.kt @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks + +/** + * Lists all statements made by a given source. + */ +interface StatementListFetcher { + + /** + * Retrieves a list of all statements from a given [source]. + */ + fun listStatements(source: AssetDescriptor.Web): Sequence<Statement> +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/CheckAssetLinksResponse.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/CheckAssetLinksResponse.kt new file mode 100644 index 0000000000..1ab14d687e --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/CheckAssetLinksResponse.kt @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks.api + +import org.json.JSONObject + +/** + * @property linked True if the assets specified in the request are linked by the relation specified in the request. + * @property maxAge From serving time, how much longer the response should be considered valid barring further updates. + * Formatted as a duration in seconds with up to nine fractional digits, terminated by 's'. Example: "3.5s". + * @property debug Human-readable message containing information about the response. + */ +data class CheckAssetLinksResponse( + val linked: Boolean, + val maxAge: String, + val debug: String, +) + +internal fun parseCheckAssetLinksJson(json: JSONObject) = CheckAssetLinksResponse( + linked = json.getBoolean("linked"), + maxAge = json.getString("maxAge"), + debug = json.optString("debugString"), +) diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApi.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApi.kt new file mode 100644 index 0000000000..c1a09e06c2 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApi.kt @@ -0,0 +1,120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks.api + +import android.net.Uri +import androidx.annotation.VisibleForTesting +import androidx.core.net.toUri +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.Relation +import mozilla.components.service.digitalassetlinks.RelationChecker +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.service.digitalassetlinks.StatementListFetcher +import mozilla.components.service.digitalassetlinks.TIMEOUT +import mozilla.components.service.digitalassetlinks.ext.parseJsonBody +import mozilla.components.service.digitalassetlinks.ext.safeFetch +import mozilla.components.support.ktx.kotlin.sanitizeURL +import org.json.JSONObject + +/** + * Digital Asset Links allows any caller to check pre declared relationships between + * two assets which can be either web domains or native applications. + * This class checks for a specific relationship declared by two assets via the online API. + */ +class DigitalAssetLinksApi( + private val httpClient: Client, + private val apiKey: String?, +) : RelationChecker, StatementListFetcher { + + override fun checkRelationship( + source: AssetDescriptor.Web, + relation: Relation, + target: AssetDescriptor, + ): Boolean { + val request = buildCheckApiRequest(source, relation, target) + val response = httpClient.safeFetch(request) + val parsed = response?.parseJsonBody { body -> + parseCheckAssetLinksJson(JSONObject(body)) + } + return parsed?.linked == true + } + + override fun listStatements(source: AssetDescriptor.Web): Sequence<Statement> { + val request = buildListApiRequest(source) + val response = httpClient.safeFetch(request) + val parsed = response?.parseJsonBody { body -> + parseListStatementsJson(JSONObject(body)) + } + return parsed?.statements.orEmpty().asSequence() + } + + private fun apiUrlBuilder(path: String) = BASE_URL.toUri().buildUpon() + .encodedPath(path) + .appendQueryParameter("prettyPrint", false.toString()) + .appendQueryParameter("key", apiKey) + + /** + * Returns a [Request] used to check whether the specified (directional) relationship exists + * between the specified source and target assets. + * + * https://developers.google.com/digital-asset-links/reference/rest/v1/assetlinks/check + */ + @VisibleForTesting + internal fun buildCheckApiRequest( + source: AssetDescriptor, + relation: Relation, + target: AssetDescriptor, + ): Request { + val uriBuilder = apiUrlBuilder(CHECK_PATH) + .appendQueryParameter("relation", relation.kindAndDetail) + + // source and target follow the same format, so re-use the query logic for both. + uriBuilder.appendAssetAsQuery(source, "source") + uriBuilder.appendAssetAsQuery(target, "target") + + return Request( + url = uriBuilder.build().toString().sanitizeURL(), + method = Request.Method.GET, + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ) + } + + @VisibleForTesting + internal fun buildListApiRequest(source: AssetDescriptor): Request { + val uriBuilder = apiUrlBuilder(LIST_PATH) + uriBuilder.appendAssetAsQuery(source, "source") + + return Request( + url = uriBuilder.build().toString().sanitizeURL(), + method = Request.Method.GET, + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ) + } + + private fun Uri.Builder.appendAssetAsQuery(asset: AssetDescriptor, prefix: String) { + when (asset) { + is AssetDescriptor.Web -> { + appendQueryParameter("$prefix.web.site", asset.site) + } + is AssetDescriptor.Android -> { + appendQueryParameter("$prefix.androidApp.packageName", asset.packageName) + appendQueryParameter( + "$prefix.androidApp.certificate.sha256Fingerprint", + asset.sha256CertFingerprint, + ) + } + } + } + + companion object { + private const val BASE_URL = "https://digitalassetlinks.googleapis.com" + private const val CHECK_PATH = "/v1/assetlinks:check" + private const val LIST_PATH = "/v1/statements:list" + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/ListStatementsResponse.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/ListStatementsResponse.kt new file mode 100644 index 0000000000..4a9106be9f --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/ListStatementsResponse.kt @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks.api + +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.Relation +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.support.ktx.android.org.json.asSequence +import org.json.JSONObject + +/** + * @property statements A list of all the matching statements that have been found. + * @property maxAge From serving time, how much longer the response should be considered valid barring further updates. + * Formatted as a duration in seconds with up to nine fractional digits, terminated by 's'. Example: "3.5s". + * @property debug Human-readable message containing information about the response. + */ +data class ListStatementsResponse( + val statements: List<Statement>, + val maxAge: String, + val debug: String, +) + +internal fun parseListStatementsJson(json: JSONObject): ListStatementsResponse { + val statements = json.getJSONArray("statements") + .asSequence { i -> getJSONObject(i) } + .mapNotNull { statementJson -> + val relationString = statementJson.getString("relation") + val relation = Relation.values().find { relationString == it.kindAndDetail } + + val targetJson = statementJson.getJSONObject("target") + val webJson = targetJson.optJSONObject("web") + val androidJson = targetJson.optJSONObject("androidApp") + val target = when { + webJson != null -> AssetDescriptor.Web(site = webJson.getString("site")) + androidJson != null -> AssetDescriptor.Android( + packageName = androidJson.getString("packageName"), + sha256CertFingerprint = androidJson.getJSONObject("certificate") + .getString("sha256Fingerprint"), + ) + else -> null + } + + if (relation != null && target != null) { + Statement(relation, target) + } else { + null + } + } + return ListStatementsResponse( + statements = statements.toList(), + maxAge = json.getString("maxAge"), + debug = json.optString("debugString"), + ) +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Client.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Client.kt new file mode 100644 index 0000000000..cbdcf5e14a --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Client.kt @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks.ext + +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.concept.fetch.Response.Companion.SUCCESS +import java.io.IOException + +internal fun Client.safeFetch(request: Request): Response? { + return try { + val response = fetch(request) + if (response.status == SUCCESS) response else null + } catch (e: IOException) { + null + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Response.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Response.kt new file mode 100644 index 0000000000..36a1d82afc --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Response.kt @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks.ext + +import mozilla.components.concept.fetch.Response +import org.json.JSONException + +/** + * Safely parse a JSON [Response] returned by an API. + */ +inline fun <T> Response.parseJsonBody(crossinline parser: (String) -> T): T? { + val responseBody = use { body.string() } + return try { + parser(responseBody) + } catch (e: JSONException) { + null + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementApi.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementApi.kt new file mode 100644 index 0000000000..798650af34 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementApi.kt @@ -0,0 +1,143 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks.local + +import androidx.core.net.toUri +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Headers.Names.CONTENT_TYPE +import mozilla.components.concept.fetch.Headers.Values.CONTENT_TYPE_APPLICATION_JSON +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.IncludeStatement +import mozilla.components.service.digitalassetlinks.Relation +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.service.digitalassetlinks.StatementListFetcher +import mozilla.components.service.digitalassetlinks.StatementResult +import mozilla.components.service.digitalassetlinks.TIMEOUT +import mozilla.components.service.digitalassetlinks.ext.safeFetch +import mozilla.components.support.ktx.android.org.json.asSequence +import mozilla.components.support.ktx.kotlin.sanitizeURL +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * Builds a list of statements by sending HTTP requests to the given website. + */ +class StatementApi(private val httpClient: Client) : StatementListFetcher { + + /** + * Lazily list all the statements in the given [source] website. + * If include statements are present, they will be resolved lazily. + */ + override fun listStatements(source: AssetDescriptor.Web): Sequence<Statement> { + val url = source.site.toUri().buildUpon() + .path("/.well-known/assetlinks.json") + .build() + .toString() + return getWebsiteStatementList(url, seenSoFar = mutableSetOf()) + } + + /** + * Recursively download all the website statements. + * @param assetLinksUrl URL to download. + * @param seenSoFar URLs that have been downloaded already. + * Used to prevent infinite loops. + */ + private fun getWebsiteStatementList( + assetLinksUrl: String, + seenSoFar: MutableSet<String>, + ): Sequence<Statement> { + if (assetLinksUrl in seenSoFar) { + return emptySequence() + } else { + seenSoFar.add(assetLinksUrl) + } + + val request = Request( + url = assetLinksUrl.sanitizeURL(), + method = Request.Method.GET, + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ) + val response = httpClient.safeFetch(request)?.let { res -> + val contentTypes = res.headers.getAll(CONTENT_TYPE) + if (contentTypes.any { it.contains(CONTENT_TYPE_APPLICATION_JSON, ignoreCase = true) }) { + res + } else { + res.close() + null + } + } + + val statements = response?.let { parseStatementResponse(response) }.orEmpty() + return sequence<Statement> { + val includeStatements = mutableListOf<IncludeStatement>() + // Yield all statements that have already been downloaded + statements.forEach { statement -> + when (statement) { + is Statement -> yield(statement) + is IncludeStatement -> includeStatements.add(statement) + } + } + // Recursively download include statements + yieldAll( + includeStatements.asSequence().flatMap { + getWebsiteStatementList(it.include, seenSoFar) + }, + ) + } + } + + /** + * Parse the JSON [Response] returned by the website. + */ + private fun parseStatementResponse(response: Response): List<StatementResult> { + val responseBody = response.use { response.body.string() } + return try { + val responseJson = JSONArray(responseBody) + parseStatementListJson(responseJson) + } catch (e: JSONException) { + emptyList() + } + } + + private fun parseStatementListJson(json: JSONArray): List<StatementResult> { + return json.asSequence { i -> getJSONObject(i) } + .flatMap { parseStatementJson(it) } + .toList() + } + + private fun parseStatementJson(json: JSONObject): Sequence<StatementResult> { + val include = json.optString("include") + if (include.isNotEmpty()) { + return sequenceOf(IncludeStatement(include)) + } + + val relationTypes = Relation.values() + val relations = json.getJSONArray("relation") + .asSequence { i -> getString(i) } + .mapNotNull { relation -> relationTypes.find { relation == it.kindAndDetail } } + + return relations.flatMap { relation -> + val target = json.getJSONObject("target") + val assets = when (target.getString("namespace")) { + "web" -> sequenceOf( + AssetDescriptor.Web(site = target.getString("site")), + ) + "android_app" -> { + val packageName = target.getString("package_name") + target.getJSONArray("sha256_cert_fingerprints") + .asSequence { i -> getString(i) } + .map { fingerprint -> AssetDescriptor.Android(packageName, fingerprint) } + } + else -> emptySequence() + } + + assets.map { asset -> Statement(relation, asset) } + } + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementRelationChecker.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementRelationChecker.kt new file mode 100644 index 0000000000..2dc3548f7b --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementRelationChecker.kt @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks.local + +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.Relation +import mozilla.components.service.digitalassetlinks.RelationChecker +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.service.digitalassetlinks.StatementListFetcher + +/** + * Checks if a matching relationship is present in a remote statement list. + */ +class StatementRelationChecker( + private val listFetcher: StatementListFetcher, +) : RelationChecker { + + override fun checkRelationship(source: AssetDescriptor.Web, relation: Relation, target: AssetDescriptor): Boolean { + val statements = listFetcher.listStatements(source) + return checkLink(statements, relation, target) + } + + companion object { + + /** + * Check if any of the given [Statement]s are linked to the given [target]. + */ + fun checkLink(statements: Sequence<Statement>, relation: Relation, target: AssetDescriptor) = + statements.any { statement -> + statement.relation == relation && statement.target == target + } + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinderTest.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinderTest.kt new file mode 100644 index 0000000000..32c244286d --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinderTest.kt @@ -0,0 +1,170 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.Signature +import android.content.pm.SigningInfo +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mock +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class AndroidAssetFinderTest { + + private lateinit var assetFinder: AndroidAssetFinder + private lateinit var packageInfo: PackageInfo + + @Mock lateinit var packageManager: PackageManager + + @Mock lateinit var signingInfo: SigningInfo + + @Before + fun setup() { + assetFinder = spy(AndroidAssetFinder()) + + MockitoAnnotations.openMocks(this) + packageInfo = PackageInfo() + @Suppress("DEPRECATION") + `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + } + + @Test + fun `test getAndroidAppAsset returns empty list if name not found`() { + @Suppress("DEPRECATION") + `when`(packageManager.getPackageInfo(anyString(), anyInt())) + .thenThrow(PackageManager.NameNotFoundException::class.java) + + assertEquals( + emptyList<AssetDescriptor.Android>(), + assetFinder.getAndroidAppAsset("com.test.app", packageManager).toList(), + ) + } + + @Config(sdk = [Build.VERSION_CODES.P]) + @Test + fun `test getAndroidAppAsset on P SDK`() { + val signature = mock<Signature>() + packageInfo.signingInfo = signingInfo + `when`(signingInfo.hasMultipleSigners()).thenReturn(false) + `when`(signingInfo.signingCertificateHistory).thenReturn(arrayOf(signature, mock())) + doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature) + + assertEquals( + listOf(AssetDescriptor.Android("com.test.app", "01:BB:AA:10:30")), + assetFinder.getAndroidAppAsset("com.test.app", packageManager).toList(), + ) + } + + @Config(sdk = [Build.VERSION_CODES.P]) + @Test + fun `test getAndroidAppAsset with multiple signatures on P SDK`() { + val signature1 = mock<Signature>() + val signature2 = mock<Signature>() + packageInfo.signingInfo = signingInfo + `when`(signingInfo.hasMultipleSigners()).thenReturn(true) + `when`(signingInfo.apkContentsSigners).thenReturn(arrayOf(signature1, signature2)) + doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature1) + doReturn("FF:CC:AA:99:77").`when`(assetFinder).getCertificateSHA256Fingerprint(signature2) + + assertEquals( + listOf( + AssetDescriptor.Android("org.test.app", "01:BB:AA:10:30"), + AssetDescriptor.Android("org.test.app", "FF:CC:AA:99:77"), + ), + assetFinder.getAndroidAppAsset("org.test.app", packageManager).toList(), + ) + } + + @Config(sdk = [Build.VERSION_CODES.P]) + @Test + fun `test getAndroidAppAsset with empty history`() { + packageInfo.signingInfo = signingInfo + `when`(signingInfo.hasMultipleSigners()).thenReturn(false) + `when`(signingInfo.signingCertificateHistory).thenReturn(emptyArray()) + + assertEquals( + emptyList<AssetDescriptor.Android>(), + assetFinder.getAndroidAppAsset("com.test.app", packageManager).toList(), + ) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Suppress("Deprecation") + @Test + fun `test getAndroidAppAsset on deprecated SDK`() { + val signature = mock<Signature>() + packageInfo.signatures = arrayOf(signature) + doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature) + + assertEquals( + listOf(AssetDescriptor.Android("com.test.app", "01:BB:AA:10:30")), + assetFinder.getAndroidAppAsset("com.test.app", packageManager).toList(), + ) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Suppress("Deprecation") + @Test + fun `test getAndroidAppAsset with multiple signatures on deprecated SDK`() { + val signature1 = mock<Signature>() + val signature2 = mock<Signature>() + packageInfo.signatures = arrayOf(signature1, signature2) + doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature1) + doReturn("FF:CC:AA:99:77").`when`(assetFinder).getCertificateSHA256Fingerprint(signature2) + + assertEquals( + listOf( + AssetDescriptor.Android("org.test.app", "01:BB:AA:10:30"), + AssetDescriptor.Android("org.test.app", "FF:CC:AA:99:77"), + ), + assetFinder.getAndroidAppAsset("org.test.app", packageManager).toList(), + ) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Suppress("Deprecation") + @Test + fun `test getAndroidAppAsset is lazily computed`() { + val signature1 = mock<Signature>() + val signature2 = mock<Signature>() + packageInfo.signatures = arrayOf(signature1, signature2) + doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature1) + doReturn("FF:CC:AA:99:77").`when`(assetFinder).getCertificateSHA256Fingerprint(signature2) + + val result = assetFinder.getAndroidAppAsset("android.package", packageManager).first() + assertEquals( + AssetDescriptor.Android("android.package", "01:BB:AA:10:30"), + result, + ) + + verify(assetFinder, times(1)).getCertificateSHA256Fingerprint(any()) + } + + @Test + fun `test byteArrayToHexString`() { + val array = byteArrayOf(0xaa.toByte(), 0xbb.toByte(), 0xcc.toByte(), 0x10, 0x20, 0x30, 0x01, 0x02) + assertEquals( + "AA:BB:CC:10:20:30:01:02", + assetFinder.byteArrayToHexString(array), + ) + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApiTest.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApiTest.kt new file mode 100644 index 0000000000..53418751b7 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApiTest.kt @@ -0,0 +1,229 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks.api + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.concept.fetch.Response.Companion.SUCCESS +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.Relation.HANDLE_ALL_URLS +import mozilla.components.service.digitalassetlinks.Relation.USE_AS_ORIGIN +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.service.digitalassetlinks.TIMEOUT +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify + +@RunWith(AndroidJUnit4::class) +class DigitalAssetLinksApiTest { + + private val webAsset = AssetDescriptor.Web(site = "https://mozilla.org") + private val androidAsset = AssetDescriptor.Android( + packageName = "com.mozilla.fenix", + sha256CertFingerprint = "01:23:45:67:89", + ) + private val baseRequest = Request( + url = "https://mozilla.org", + method = Request.Method.GET, + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ) + private val apiKey = "X" + private lateinit var client: Client + private lateinit var api: DigitalAssetLinksApi + + @Before + fun setup() { + client = mock() + api = DigitalAssetLinksApi(client, apiKey) + + doReturn(mockResponse("")).`when`(client).fetch(any()) + } + + @Test + fun `reject for invalid status`() { + val response = mockResponse("").copy(status = 400) + doReturn(response).`when`(client).fetch(any()) + + assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)) + assertEquals(emptyList<Statement>(), api.listStatements(webAsset).toList()) + } + + @Test + fun `reject check for invalid json`() { + doReturn(mockResponse("")).`when`(client).fetch(any()) + assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, webAsset)) + + doReturn(mockResponse("{}")).`when`(client).fetch(any()) + assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)) + + doReturn(mockResponse("[]")).`when`(client).fetch(any()) + assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)) + + doReturn(mockResponse("{\"lnkd\":true}")).`when`(client).fetch(any()) + assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)) + } + + @Test + fun `reject list for invalid json`() { + val empty = emptyList<Statement>() + + doReturn(mockResponse("")).`when`(client).fetch(any()) + assertEquals(empty, api.listStatements(webAsset).toList()) + + doReturn(mockResponse("{}")).`when`(client).fetch(any()) + assertEquals(empty, api.listStatements(webAsset).toList()) + + doReturn(mockResponse("[]")).`when`(client).fetch(any()) + assertEquals(empty, api.listStatements(webAsset).toList()) + + doReturn(mockResponse("{\"stmt\":[]}")).`when`(client).fetch(any()) + assertEquals(empty, api.listStatements(webAsset).toList()) + } + + @Test + fun `return linked from json`() { + doReturn(mockResponse("{\"linked\":true,\"maxAge\":\"3s\"}")).`when`(client).fetch(any()) + assertTrue(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)) + + doReturn(mockResponse("{\"linked\":false}\"maxAge\":\"3s\"}")).`when`(client).fetch(any()) + assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)) + } + + @Test + fun `return empty list if json doesn't match expected format`() { + val jsonPrefix = "{\"statements\":[" + val jsonSuffix = "],\"maxAge\":\"3s\"}" + doReturn(mockResponse(jsonPrefix + jsonSuffix)).`when`(client).fetch(any()) + assertEquals(emptyList<Statement>(), api.listStatements(webAsset).toList()) + + val invalidRelation = """ + { + "source": {"web":{"site": "https://mozilla.org"}}, + "target": {"web":{"site": "https://mozilla.org"}}, + "relation": "not-a-relation" + } + """ + doReturn(mockResponse(jsonPrefix + invalidRelation + jsonSuffix)).`when`(client).fetch(any()) + assertEquals(emptyList<Statement>(), api.listStatements(webAsset).toList()) + + val invalidTarget = """ + { + "source": {"web":{"site": "https://mozilla.org"}}, + "target": {}, + "relation": "delegate_permission/common.use_as_origin" + } + """ + doReturn(mockResponse(jsonPrefix + invalidTarget + jsonSuffix)).`when`(client).fetch(any()) + assertEquals(emptyList<Statement>(), api.listStatements(webAsset).toList()) + } + + @Test + fun `parses json statement list with web target`() { + val webStatement = """ + {"statements": [{ + "source": {"web":{"site": "https://mozilla.org"}}, + "target": {"web":{"site": "https://mozilla.org"}}, + "relation": "delegate_permission/common.use_as_origin" + }], "maxAge": "59s"} + """ + doReturn(mockResponse(webStatement)).`when`(client).fetch(any()) + assertEquals( + listOf( + Statement( + relation = USE_AS_ORIGIN, + target = webAsset, + ), + ), + api.listStatements(webAsset).toList(), + ) + } + + @Test + fun `parses json statement list with android target`() { + val androidStatement = """ + {"statements": [{ + "source": {"web":{"site": "https://mozilla.org"}}, + "target": {"androidApp":{ + "packageName": "com.mozilla.fenix", + "certificate": {"sha256Fingerprint": "01:23:45:67:89"} + }}, + "relation": "delegate_permission/common.handle_all_urls" + }], "maxAge": "2m"} + """ + doReturn(mockResponse(androidStatement)).`when`(client).fetch(any()) + assertEquals( + listOf( + Statement( + relation = HANDLE_ALL_URLS, + target = androidAsset, + ), + ), + api.listStatements(webAsset).toList(), + ) + } + + @Test + fun `passes data in get check request URL for android target`() { + api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset) + verify(client).fetch( + baseRequest.copy( + url = "https://digitalassetlinks.googleapis.com/v1/assetlinks:check?" + + "prettyPrint=false&key=X&relation=delegate_permission%2Fcommon.use_as_origin&" + + "source.web.site=${Uri.encode("https://mozilla.org")}&" + + "target.androidApp.packageName=com.mozilla.fenix&" + + "target.androidApp.certificate.sha256Fingerprint=${Uri.encode("01:23:45:67:89")}", + ), + ) + } + + @Test + fun `passes data in get check request URL for web target`() { + api.checkRelationship(webAsset, HANDLE_ALL_URLS, webAsset) + verify(client).fetch( + baseRequest.copy( + url = "https://digitalassetlinks.googleapis.com/v1/assetlinks:check?" + + "prettyPrint=false&key=X&relation=delegate_permission%2Fcommon.handle_all_urls&" + + "source.web.site=${Uri.encode("https://mozilla.org")}&" + + "target.web.site=${Uri.encode("https://mozilla.org")}", + ), + ) + } + + @Test + fun `passes data in get list request URL`() { + api.listStatements(webAsset) + verify(client).fetch( + baseRequest.copy( + url = "https://digitalassetlinks.googleapis.com/v1/statements:list?" + + "prettyPrint=false&key=X&source.web.site=${Uri.encode("https://mozilla.org")}", + ), + ) + } + + private fun mockResponse(data: String) = Response( + url = "", + status = SUCCESS, + headers = MutableHeaders(), + body = mockBody(data), + ) + + private fun mockBody(data: String): Response.Body { + val mockBody: Response.Body = mock() + doReturn(data).`when`(mockBody).string() + return mockBody + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementApiTest.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementApiTest.kt new file mode 100644 index 0000000000..7ebd8ac67e --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementApiTest.kt @@ -0,0 +1,356 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks.local + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Headers.Names.CONTENT_TYPE +import mozilla.components.concept.fetch.Headers.Values.CONTENT_TYPE_APPLICATION_JSON +import mozilla.components.concept.fetch.Headers.Values.CONTENT_TYPE_FORM_URLENCODED +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.Relation +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.service.digitalassetlinks.StatementListFetcher +import mozilla.components.service.digitalassetlinks.TIMEOUT +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import java.io.ByteArrayInputStream +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class StatementApiTest { + + @Mock private lateinit var httpClient: Client + private lateinit var listFetcher: StatementListFetcher + private val jsonHeaders = MutableHeaders( + CONTENT_TYPE to CONTENT_TYPE_APPLICATION_JSON, + ) + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + listFetcher = StatementApi(httpClient) + } + + @Test + fun `return empty list if request fails`() { + `when`( + httpClient.fetch( + Request( + url = "https://mozilla.org/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenThrow(IOException::class.java) + + val source = AssetDescriptor.Web("https://mozilla.org") + assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList()) + } + + @Test + fun `return empty list if response does not have status 200`() { + val response = Response( + url = "https://firefox.com/.well-known/assetlinks.json", + status = 201, + headers = jsonHeaders, + body = mock(), + ) + `when`( + httpClient.fetch( + Request( + url = "https://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn(response) + + val source = AssetDescriptor.Web("https://firefox.com") + assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList()) + } + + @Test + fun `return empty list if response does not have JSON content type`() { + val response = Response( + url = "https://firefox.com/.well-known/assetlinks.json", + status = 200, + headers = MutableHeaders( + CONTENT_TYPE to CONTENT_TYPE_FORM_URLENCODED, + ), + body = mock(), + ) + + `when`( + httpClient.fetch( + Request( + url = "https://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn(response) + + val source = AssetDescriptor.Web("https://firefox.com") + assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList()) + } + + @Test + fun `return empty list if response is not valid JSON`() { + val response = Response( + url = "http://firefox.com/.well-known/assetlinks.json", + status = 200, + headers = jsonHeaders, + body = stringBody("not-json"), + ) + + `when`( + httpClient.fetch( + Request( + url = "http://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn(response) + + val source = AssetDescriptor.Web("http://firefox.com") + assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList()) + } + + @Test + fun `return empty list if response is an empty JSON array`() { + val response = Response( + url = "http://firefox.com/.well-known/assetlinks.json", + status = 200, + headers = jsonHeaders, + body = stringBody("[]"), + ) + + `when`( + httpClient.fetch( + Request( + url = "http://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn(response) + + val source = AssetDescriptor.Web("http://firefox.com") + assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList()) + } + + @Test + fun `parses example asset links file`() { + val response = Response( + url = "http://firefox.com/.well-known/assetlinks.json", + status = 200, + headers = jsonHeaders, + body = stringBody( + """ + [{ + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.use_as_origin" + ], + "target": { + "namespace": "web", + "site": "https://www.google.com" + } + },{ + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "org.digitalassetlinks.sampleapp", + "sha256_cert_fingerprints": [ + "10:39:38:EE:45:37:E5:9E:8E:E7:92:F6:54:50:4F:B8:34:6F:C6:B3:46:D0:BB:C4:41:5F:C3:39:FC:FC:8E:C1" + ] + } + },{ + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "org.digitalassetlinks.sampleapp2", + "sha256_cert_fingerprints": ["AA", "BB"] + } + }] + """, + ), + ) + `when`( + httpClient.fetch( + Request( + url = "http://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn(response) + + val source = AssetDescriptor.Web("http://firefox.com") + assertEquals( + listOf( + Statement( + relation = Relation.HANDLE_ALL_URLS, + target = AssetDescriptor.Web("https://www.google.com"), + ), + Statement( + relation = Relation.USE_AS_ORIGIN, + target = AssetDescriptor.Web("https://www.google.com"), + ), + Statement( + relation = Relation.HANDLE_ALL_URLS, + target = AssetDescriptor.Android( + packageName = "org.digitalassetlinks.sampleapp", + sha256CertFingerprint = "10:39:38:EE:45:37:E5:9E:8E:E7:92:F6:54:50:4F:B8:34:6F:C6:B3:46:D0:BB:C4:41:5F:C3:39:FC:FC:8E:C1", + ), + ), + Statement( + relation = Relation.HANDLE_ALL_URLS, + target = AssetDescriptor.Android( + packageName = "org.digitalassetlinks.sampleapp2", + sha256CertFingerprint = "AA", + ), + ), + Statement( + relation = Relation.HANDLE_ALL_URLS, + target = AssetDescriptor.Android( + packageName = "org.digitalassetlinks.sampleapp2", + sha256CertFingerprint = "BB", + ), + ), + ), + listFetcher.listStatements(source).toList(), + ) + } + + @Test + fun `resolves include statements`() { + `when`( + httpClient.fetch( + Request( + url = "http://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn( + Response( + url = "http://firefox.com/.well-known/assetlinks.json", + status = 200, + headers = jsonHeaders, + body = stringBody( + """ + [{ + "relation": ["delegate_permission/common.use_as_origin"], + "target": { + "namespace": "web", + "site": "https://www.google.com" + } + },{ + "include": "https://example.com/includedstatements.json" + }] + """, + ), + ), + ) + `when`( + httpClient.fetch( + Request( + url = "https://example.com/includedstatements.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn( + Response( + url = "https://example.com/includedstatements.json", + status = 200, + headers = jsonHeaders, + body = stringBody( + """ + [{ + "relation": ["delegate_permission/common.use_as_origin"], + "target": { + "namespace": "web", + "site": "https://www.example.com" + } + }] + """, + ), + ), + ) + + val source = AssetDescriptor.Web("http://firefox.com") + assertEquals( + listOf( + Statement( + relation = Relation.USE_AS_ORIGIN, + target = AssetDescriptor.Web("https://www.google.com"), + ), + Statement( + relation = Relation.USE_AS_ORIGIN, + target = AssetDescriptor.Web("https://www.example.com"), + ), + ), + listFetcher.listStatements(source).toList(), + ) + } + + @Test + fun `no infinite loops`() { + `when`( + httpClient.fetch( + Request( + url = "http://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn( + Response( + url = "http://firefox.com/.well-known/assetlinks.json", + status = 200, + headers = jsonHeaders, + body = stringBody( + """ + [{ + "relation": ["delegate_permission/common.use_as_origin"], + "target": { + "namespace": "web", + "site": "https://example.com" + } + },{ + "include": "http://firefox.com/.well-known/assetlinks.json" + }] + """, + ), + ), + ) + + val source = AssetDescriptor.Web("http://firefox.com") + assertEquals( + listOf( + Statement( + relation = Relation.USE_AS_ORIGIN, + target = AssetDescriptor.Web("https://example.com"), + ), + ), + listFetcher.listStatements(source).toList(), + ) + } + + private fun stringBody(data: String) = Response.Body(ByteArrayInputStream(data.toByteArray())) +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementRelationCheckerTest.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementRelationCheckerTest.kt new file mode 100644 index 0000000000..4498ab56a1 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementRelationCheckerTest.kt @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.digitalassetlinks.local + +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.Relation +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.service.digitalassetlinks.StatementListFetcher +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StatementRelationCheckerTest { + + @Test + fun `checks list lazily`() { + var numYields = 0 + val target = AssetDescriptor.Web("https://mozilla.org") + val listFetcher = object : StatementListFetcher { + override fun listStatements(source: AssetDescriptor.Web) = sequence { + numYields = 1 + yield( + Statement( + relation = Relation.USE_AS_ORIGIN, + target = target, + ), + ) + numYields = 2 + yield( + Statement( + relation = Relation.USE_AS_ORIGIN, + target = target, + ), + ) + } + } + + val checker = StatementRelationChecker(listFetcher) + assertEquals(0, numYields) + + assertTrue(checker.checkRelationship(mock(), Relation.USE_AS_ORIGIN, target)) + assertEquals(1, numYields) + + // Sanity check that the mock can yield twice + numYields = 0 + listFetcher.listStatements(mock()).toList() + assertEquals(2, numYields) + } + + @Test + fun `fails if relation does not match`() { + val target = AssetDescriptor.Android("com.test", "AA:BB") + val listFetcher = object : StatementListFetcher { + override fun listStatements(source: AssetDescriptor.Web) = sequenceOf( + Statement( + relation = Relation.USE_AS_ORIGIN, + target = target, + ), + ) + } + + val checker = StatementRelationChecker(listFetcher) + assertFalse(checker.checkRelationship(mock(), Relation.HANDLE_ALL_URLS, target)) + } + + @Test + fun `fails if target does not match`() { + val target = AssetDescriptor.Web("https://mozilla.org") + val listFetcher = object : StatementListFetcher { + override fun listStatements(source: AssetDescriptor.Web) = sequenceOf( + Statement( + relation = Relation.HANDLE_ALL_URLS, + target = AssetDescriptor.Web("https://mozilla.com"), + ), + Statement( + relation = Relation.HANDLE_ALL_URLS, + target = AssetDescriptor.Web("http://mozilla.org"), + ), + ) + } + + val checker = StatementRelationChecker(listFetcher) + assertFalse(checker.checkRelationship(mock(), Relation.HANDLE_ALL_URLS, target)) + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..1f0955d450 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 |