diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..2aaab3e --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,96 @@ +name: Android CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + release: + types: [ published ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # Setup + - uses: actions/checkout@v3 + with: + submodules: true + - name: set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + cache: gradle + + # Build + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build with Gradle and run tests + run: ./gradlew build + + # Upload libraries to workflow artifacts + - uses: actions/upload-artifact@v3 + if: ${{ github.event_name != 'pull_request' }} + with: + name: libraries + path: digitalassetlinks/build/outputs/aar/*.aar + if-no-files-found: error + retention-days: 5 + + # Upload javadoc to workflow artifacts + - uses: actions/upload-artifact@v3 + if: ${{ github.event_name != 'pull_request' }} + with: + name: digitalassetlinks-javadoc + path: | + digitalassetlinks/build/docs/* + !digitalassetlinks/build/docs/**/*.zip + if-no-files-found: error + retention-days: 1 + + # Note: only runs on 'push' events + publish-to-gh-pages: + runs-on: ubuntu-latest + needs: build + concurrency: publish-to-gh-pages + if: ${{ github.event_name == 'push' }} + + steps: + - name: Update digitalassetlinks javadoc + uses: solana-mobile/gha-commit-artifact-to-branch@v1 + with: + token: ${{ secrets.UPDATE_GITHUB_PAGES_TOKEN }} + branch: gh-pages + artifact-name: digitalassetlinks-javadoc + dest: digitalassetlinks + commit-message: 'Update digitalassetlinks javadoc' + + # Note: only runs on 'release' events + upload-to-release: + runs-on: ubuntu-latest + needs: build + permissions: + contents: write # needed for uploading files to releases + if: ${{ github.event_name == 'release' }} + + steps: + - uses: actions/download-artifact@v3 + with: + name: libraries + path: libraries + - uses: actions/download-artifact@v3 + with: + name: digitalassetlinks-javadoc + path: digitalassetlinks-javadoc + - name: Compress digitalassetlinks javadoc + run: tar -cvzf digitalassetlinks-javadoc/digitalassetlinks-javadoc.tgz -C digitalassetlinks-javadoc javadoc + - name: Upload files to release + run: gh release -R ${GITHUB_REPOSITORY} upload ${TAG} ${FILES} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name }} + FILES: | + libraries/*.aar + digitalassetlinks-javadoc/digitalassetlinks-javadoc.tgz \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..757125b --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# OSX +.DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..786cf69 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "digitalassetlinks/external/digitalassetlinks"] + path = digitalassetlinks/external/digitalassetlinks + url = https://github.com/google/digitalassetlinks.git + branch = master diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..9cadac3 --- /dev/null +++ b/build.gradle @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id 'com.android.application' version '7.2.1' apply false + id 'com.android.library' version '7.2.1' apply false + id 'org.jetbrains.kotlin.android' version '1.7.10' apply false +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/digitalassetlinks/.gitignore b/digitalassetlinks/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/digitalassetlinks/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/digitalassetlinks/build.gradle b/digitalassetlinks/build.gradle new file mode 100644 index 0000000..9cad924 --- /dev/null +++ b/digitalassetlinks/build.gradle @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +plugins { + id 'com.android.library' + id 'com.google.protobuf' version "0.8.19" +} + +android { + compileSdk 31 + + defaultConfig { + minSdk 23 + targetSdk 31 + + consumerProguardFiles "consumer-rules.pro" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + testOptions { + unitTests { + includeAndroidResources = true + } + } + + sourceSets { + test { + proto { + srcDir 'external/digitalassetlinks/compatibility-tests/v1' + } + } + } +} + +protobuf { + protoc { + def archSuffix = '' + if (osdetector.os == "osx") { + // Workaround: there are no published protoc binaries for Apple Silicon-based macs. + // Apply a manual architecture to the artifact to select the osx-x86_64 variant, which + // runs fine with Rosetta. + archSuffix = ':osx-x86_64' + } + artifact = "com.google.protobuf:protoc:3.21.2$archSuffix" // Download from repositories + } + generateProtoTasks { + all().each { task -> + task.builtins { + java { } + } + } + } +} + +// Define JavaDoc build task for all variants +android.libraryVariants.all { variant -> + task("generate${variant.name.capitalize()}Javadoc", type: Javadoc) { + description "Generates Javadoc for $variant.name." + source = variant.javaCompileProvider.get().source + classpath = project.files(android.getBootClasspath().join(File.pathSeparator)) + classpath += files(variant.javaCompileProvider.get().classpath) + options.links("https://docs.oracle.com/javase/8/docs/api/"); + options.links("https://d.android.com/reference/"); + exclude '**/BuildConfig.java' + exclude '**/R.java' + } +} + +// Build JavaDoc when making a release build +tasks.whenTaskAdded { task -> + if (task.name == 'assembleRelease') { + task.dependsOn("generateReleaseJavadoc") + } +} + +dependencies { + implementation 'androidx.annotation:annotation:1.4.0' + implementation 'androidx.collection:collection:1.2.0' + testImplementation 'com.google.protobuf:protobuf-java:3.21.2' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-inline:4.6.1' + testImplementation 'org.robolectric:robolectric:4.8.1' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test:rules:1.4.0' + androidTestImplementation 'androidx.test:runner:1.4.0' + androidTestImplementation 'junit:junit:4.13.2' +} \ No newline at end of file diff --git a/digitalassetlinks/consumer-rules.pro b/digitalassetlinks/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/digitalassetlinks/external/digitalassetlinks b/digitalassetlinks/external/digitalassetlinks new file mode 160000 index 0000000..d893749 --- /dev/null +++ b/digitalassetlinks/external/digitalassetlinks @@ -0,0 +1 @@ +Subproject commit d893749ee3c84b31f72b552a7ed333789c3235d3 diff --git a/digitalassetlinks/proguard-rules.pro b/digitalassetlinks/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/digitalassetlinks/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/digitalassetlinks/src/androidTest/AndroidManifest.xml b/digitalassetlinks/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..31bb646 --- /dev/null +++ b/digitalassetlinks/src/androidTest/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/digitalassetlinks/src/androidTest/java/com/solana/digitalassetlinks/DigitalAssetLinksIntegrationTests.java b/digitalassetlinks/src/androidTest/java/com/solana/digitalassetlinks/DigitalAssetLinksIntegrationTests.java new file mode 100644 index 0000000..882550e --- /dev/null +++ b/digitalassetlinks/src/androidTest/java/com/solana/digitalassetlinks/DigitalAssetLinksIntegrationTests.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solana.digitalassetlinks; + +import static org.junit.Assert.*; + +import android.content.pm.PackageManager; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.net.URI; + +/** NOTE: these tests are not hermetic - they use the state of the emulator, plus the network */ +@RunWith(AndroidJUnit4.class) +public class DigitalAssetLinksIntegrationTests { + @Test + public void testGmsPackageIsVerifiedByWwwAndroidCom() + throws AndroidAppPackageVerifier.CouldNotVerifyPackageException { + final PackageManager pm = ApplicationProvider.getApplicationContext().getPackageManager(); + assertNotNull(pm); + + final AndroidAppPackageVerifier verifier = new AndroidAppPackageVerifier(pm); + final boolean verified = verifier.verify( + "com.google.android.gms", URI.create("https://www.android.com")); + assertTrue(verified); + } + + @Test + public void testGmsPackageIsNotVerifiedByDeveloperAndroidCom() + throws AndroidAppPackageVerifier.CouldNotVerifyPackageException { + final PackageManager pm = ApplicationProvider.getApplicationContext().getPackageManager(); + assertNotNull(pm); + + final AndroidAppPackageVerifier verifier = new AndroidAppPackageVerifier(pm); + final boolean verified = verifier.verify( + "com.google.android.gms", URI.create("https://developer.android.com")); + assertFalse(verified); + } + + @Test + public void testGmsPackageIsNotVerifiedByWwwSolanaCom() { + final PackageManager pm = ApplicationProvider.getApplicationContext().getPackageManager(); + assertNotNull(pm); + + final AndroidAppPackageVerifier verifier = new AndroidAppPackageVerifier(pm); + assertThrows(AndroidAppPackageVerifier.CouldNotVerifyPackageException.class, + () -> verifier.verify( + "com.google.android.gms", URI.create("https://www.solana.com"))); + // NOTE: verifier throws, rather than returns false, because www.solana.com does not host + // any Digital Asset Links content. + } + + @Test + public void testGmsPackageIsNotVerifiedByHttpWwwAndroidCom() { + final PackageManager pm = ApplicationProvider.getApplicationContext().getPackageManager(); + assertNotNull(pm); + + final AndroidAppPackageVerifier verifier = new AndroidAppPackageVerifier(pm); + assertThrows(AndroidAppPackageVerifier.CouldNotVerifyPackageException.class, + () -> verifier.verify( + "com.google.android.gms", URI.create("http://www.android.com"))); + } + + @Test + public void testCamera2PackageIsNotVerifiedByWwwAndroidCom() throws AndroidAppPackageVerifier.CouldNotVerifyPackageException { + final PackageManager pm = ApplicationProvider.getApplicationContext().getPackageManager(); + assertNotNull(pm); + + final AndroidAppPackageVerifier verifier = new AndroidAppPackageVerifier(pm); + final boolean verified = verifier.verify( + "com.android.camera2", URI.create("https://www.android.com")); + assertFalse(verified); + } +} diff --git a/digitalassetlinks/src/main/AndroidManifest.xml b/digitalassetlinks/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c49b6b4 --- /dev/null +++ b/digitalassetlinks/src/main/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AndroidAppPackageVerifier.java b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AndroidAppPackageVerifier.java new file mode 100644 index 0000000..268ff74 --- /dev/null +++ b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AndroidAppPackageVerifier.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solana.digitalassetlinks; + +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.Signature; +import android.os.Build; + +import androidx.annotation.NonNull; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.net.URI; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +/** + * Verifier for Android app packages using + * Digital Asset Links + */ +public class AndroidAppPackageVerifier extends URISourceVerifier { + private final PackageManager mPackageManager; + + /** + * Construct a new {@link AndroidAppPackageVerifier} + * @param pm the {@link PackageManager} from which to look up app package details + */ + public AndroidAppPackageVerifier(@NonNull PackageManager pm) { + mPackageManager = pm; + } + + /** + * Verify that the specified app package is covered by a reliable statement in the Asset Links + * JSON for the provided {@link URI}. + *

NOTE: this method performs blocking network activity; it should not be invoked on the + * UI thread of an app. A running instance of this function can be cancelled (from another + * thread using the {@link #cancel()} method.

+ * @param packageName the android app package name (e.g. com.solana.example) + * @param uri the URI with which this app package should be verified. This should be a URI that + * establishes a trust link of interest to the app. For example, if the use case is a + * Solana Pay transaction request, the URI might refer to the site which is generating the + * transaction. + * @return true if the package was verified using the specified URI, or false if it was not + * verified + * @throws CouldNotVerifyPackageException if the package verification process was not + * successful. Note that this doesn't necessarily imply that the package was not verified, + * but rather that a relationship could not be established. + */ + public boolean verify(@NonNull String packageName, @NonNull URI uri) + throws CouldNotVerifyPackageException { + if (!"https".equalsIgnoreCase(uri.getScheme())) { + throw new CouldNotVerifyPackageException( + "Android app packages can only be verified with secure source URIs"); + } + + // Check that mPackageName is present and visible to this app + final int flags; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + flags = PackageManager.GET_SIGNING_CERTIFICATES; + } else { + flags = PackageManager.GET_SIGNATURES; + } + final PackageInfo packageInfo; + try { + packageInfo = mPackageManager.getPackageInfo(packageName, flags); + } catch (PackageManager.NameNotFoundException e) { + throw new CouldNotVerifyPackageException("Failed reading signatures for package " + packageName, e); + } + + // Query the set of signatures we're looking for + final byte[][] packageCertSha256Fingerprints; + final boolean[] signatureMask; + final boolean requireAllSignatures; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + if (packageInfo.signingInfo.hasMultipleSigners()) { + packageCertSha256Fingerprints = convertDEREncodedCertificatesToSHA256Fingerprints( + packageInfo.signingInfo.getApkContentsSigners()); + signatureMask = new boolean[packageCertSha256Fingerprints.length]; + requireAllSignatures = true; + } else { + packageCertSha256Fingerprints = convertDEREncodedCertificatesToSHA256Fingerprints( + packageInfo.signingInfo.getSigningCertificateHistory()); + signatureMask = new boolean[1]; + requireAllSignatures = false; + } + } else { + packageCertSha256Fingerprints = convertDEREncodedCertificatesToSHA256Fingerprints( + packageInfo.signatures); + signatureMask = new boolean[packageCertSha256Fingerprints.length]; + requireAllSignatures = true; + } + + // Create and configure an AssetLinksJSONParser object + final StatementMatcher androidAppMatcher = StatementMatcher + .createAndroidAppStatementMatcher( + AssetLinksGrammar.GRAMMAR_RELATION_HANDLE_ALL_URLS, + packageName, + null); + final AssetLinksJSONParser.StatementMatcherCallback androidAppMatcherCallback = + (matcher, o) -> { + final JSONObject target = o.getJSONObject(AssetLinksGrammar.GRAMMAR_TARGET); // mandatory field + final JSONArray certFingerprints = target.getJSONArray(AssetLinksGrammar.GRAMMAR_ANDROID_APP_SHA256_CERT_FINGERPRINTS); // mandatory field + final int numCertFingerprints = certFingerprints.length(); + for (int i = 0; i < numCertFingerprints; i++) { + final String certFingerprint = certFingerprints.getString(i); + final byte[] fp = convertSHA256CertFingerprintStringToByteArray(certFingerprint); + for (int j = 0; j < packageCertSha256Fingerprints.length; j++) { + final byte[] packageFp = packageCertSha256Fingerprints[j]; + if (Arrays.equals(fp, packageFp)) { + signatureMask[requireAllSignatures ? j : 0] = true; + } + } + } + }; + + // Do URI source verification with the Android app package target matcher + try { + // NOTE: ignore the return value of verify; it conveys whether there were acceptable + // parsing errors that do not affect the validity of the verification. + verify(uri, new StatementMatcherWithCallback(androidAppMatcher, androidAppMatcherCallback)); + } catch (CouldNotVerifyException e) { + throw new CouldNotVerifyPackageException("Could not verify package " + packageName, e); + } catch (IllegalArgumentException e) { + throw new CouldNotVerifyPackageException("Source URI not a valid HTTP or HTTPS URL: " + uri, e); + } + + // No need to check parser.isComplete() or parser.isError() here; the first is handled by + // waiting for parser.onDocumentLoaded(...) to return null, and the second by terminating on + // a caught exception. + + // Step 6: Check the verification state produced by the Android App statement matcher + boolean result = true; + for (boolean b : signatureMask) { + if (!b) { + result = false; + break; + } + } + + return result; + } + + @NonNull + private static byte[][] convertDEREncodedCertificatesToSHA256Fingerprints( + @NonNull Signature[] derEncodedCerts) { + final MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedOperationException("SHA-256 message digest algorithm not found", e); + } + final byte[][] sha256CertFingerprints = new byte[derEncodedCerts.length][]; + for (int i = 0; i < derEncodedCerts.length; i++) { + sha256CertFingerprints[i] = digest.digest(derEncodedCerts[i].toByteArray()); + } + return sha256CertFingerprints; + } + + @NonNull + private static byte[] convertSHA256CertFingerprintStringToByteArray( + @NonNull String sha256CertFingerprint) { + // NOTE: the format of the certificate fingerprint string is already checked against + // AssetLinksGrammer.SHA256_CERT_FINGERPRINT_PATTERN by AssetLinksJSONParser + final byte[] fp = new byte[32]; + for (int i = 0; i < 32; i++) { + final int j = i * 3; + String s = sha256CertFingerprint.substring(j, j + 2); + try { + fp[i] = (byte)Short.parseShort(s, 16); + } catch (NumberFormatException nfe) { + throw new IllegalArgumentException("Error while parsing hex substring", nfe); + } + } + return fp; + } + + /** + * This exception indicates that the verification process could not be completed (for e.g., due + * to a network error). It indicates that no trust relationship between the app package and the + * {@link URI} was established, but does not necessarily imply that the app package is not + * trusted. + */ + public static class CouldNotVerifyPackageException extends CouldNotVerifyException { + public CouldNotVerifyPackageException(String message) { super(message); } + public CouldNotVerifyPackageException(String message, Throwable t) { super(message, t); } + } +} \ No newline at end of file diff --git a/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AssetLinksGrammar.java b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AssetLinksGrammar.java new file mode 100644 index 0000000..2c12b3a --- /dev/null +++ b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AssetLinksGrammar.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solana.digitalassetlinks; + +import androidx.annotation.NonNull; + +import java.net.URI; + +/** + * The defined grammar for Digital Asset Links + */ +public final class AssetLinksGrammar { + public static final int MAX_ASSET_LINKS_URIS = 11; // 1 source URI + max 10 include statements + + public static final String GRAMMAR_INCLUDE = "include"; + + public static final String GRAMMAR_RELATION = "relation"; + public static final String GRAMMAR_TARGET = "target"; + public static final String GRAMMAR_NAMESPACE = "namespace"; + + public static final String GRAMMAR_DELEGATE_PERMISSION_PREFIX = "delegate_permission"; + public static final String GRAMMAR_RELATION_HANDLE_ALL_URLS = GRAMMAR_DELEGATE_PERMISSION_PREFIX + "/common.handle_all_urls"; + public static final String GRAMMER_RELATION_GET_LOGIN_CREDS = GRAMMAR_DELEGATE_PERMISSION_PREFIX + "/common.get_login_creds"; + + public static final String GRAMMAR_NAMESPACE_WEB = "web"; + public static final String GRAMMAR_WEB_SITE = "site"; + + public static final String GRAMMAR_NAMESPACE_ANDROID_APP = "android_app"; + public static final String GRAMMAR_ANDROID_APP_PACKAGE_NAME = "package_name"; + public static final String GRAMMAR_ANDROID_APP_SHA256_CERT_FINGERPRINTS = "sha256_cert_fingerprints"; + + public static final String RELATION_PATTERN = "^[a-z0-9_.]+/[a-z0-9_.]+$"; + public static final String PACKAGE_NAME_PATTERN = "^(?:[a-zA-Z0-9_]+\\.)+[a-zA-Z0-9_]+$"; + public static final String SHA256_CERT_FINGERPRINT_PATTERN = "^(?:[0-9A-F]{2}:){31}[0-9A-F]{2}$"; + + /** Tests if the specified URI is valid for {@link #GRAMMAR_WEB_SITE} */ + public static boolean isValidSiteURI(@NonNull URI uri) { + return (uri.isAbsolute() && + ("http".equalsIgnoreCase(uri.getScheme()) || + "https".equalsIgnoreCase(uri.getScheme())) && + (uri.getRawUserInfo() == null || uri.getRawUserInfo().isEmpty()) && + (uri.getRawPath() == null || uri.getRawPath().isEmpty()) && + (uri.getRawQuery() == null || uri.getRawQuery().isEmpty()) && + (uri.getRawFragment() == null || uri.getRawFragment().isEmpty()) && + (uri.getPort() == -1 || (uri.getPort() >= 1 && uri.getPort() <= 65535))); + } + + private AssetLinksGrammar() {} +} diff --git a/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AssetLinksJSONParser.java b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AssetLinksJSONParser.java new file mode 100644 index 0000000..04ec91b --- /dev/null +++ b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AssetLinksJSONParser.java @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solana.digitalassetlinks; + +import android.util.Log; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A Digital Asset Links JSON parser + */ +public class AssetLinksJSONParser { + private static final String TAG = AssetLinksJSONParser.class.getSimpleName(); + + private final ArrayList mURIs = new ArrayList<>(1); + private int mLoadingIndex = -1; + + private final ArrayList> mStatementMatchers = new ArrayList<>(); + + private boolean mIsSourceURISecure; + + private boolean mIsError; + private boolean mIsWarning; + + /** + * Construct a new {@link AssetLinksJSONParser} + */ + public AssetLinksJSONParser() {} + + /** + * Retrieve all URIs referenecd by this Asset Links tree. If {@link #isComplete()}, this set + * represents the full Asset Links tree derived from the first entry in the returned list. + * @return a {@link List} of {@link URI}s in the order discovered. The first entry is the source + * {@link URI} for this Asset Links tree. + */ + @NonNull + public List getURIs() { + return Collections.unmodifiableList(mURIs); + } + + /** + * Whether parsing has completed successfully (including the full tree of include directives). + * If {@link #isError()} is true, this will always return false. + * @return true if parsing completed successfully, else false + */ + public boolean isComplete() { + return !mIsError && (mLoadingIndex == mURIs.size()); + } + + /** + * Whether an error was encountered during parsing. If this is true, no more parsing can occur, + * and any {@link StatementMatcherCallback} results should be ignored. + * @return true if an error was encountered during parsing, else false + */ + public boolean isError() { + return mIsError; + } + + /** + * Whether any non-fatal parsing errors were encountered during parsing. If this is true, the + * parsing can still complete successfully, and the verification results can still be trusted. + * NOTE: this exists primarily to validate the behavior of this parser with the Asset Links + * compatibility test suite. + * @return true if any non-fatal parsing errors were encountered, else false + */ + public boolean isWarning() { return mIsWarning; } + + /** + * Add a {@link StatementMatcher} and corresponding {@link StatementMatcherCallback} to this + * {@link AssetLinksJSONParser}. All statement matchers should be added before + * {@link #start(URI)} is called. + * @param statementMatcher the {@link StatementMatcher} to add + * @param callback the {@link StatementMatcherCallback} to invoke when a match occurs + */ + public void addStatementMatcher(@NonNull StatementMatcher statementMatcher, + @NonNull StatementMatcherCallback callback) { + mStatementMatchers.add(Pair.create(statementMatcher, callback)); + } + + /** + * Start this {@link AssetLinksJSONParser} for the specified Asset Links {@link URI} + * @param sourceURI the initial Asset Links {@link URI}. It must be an absolute {@link URI}. + * @return the first {@link URI} to fetch for {@link #onDocumentLoaded(URI, String)} + */ + @NonNull + public URI start(@NonNull URI sourceURI) { + if (!sourceURI.isAbsolute()) { + throw new IllegalArgumentException("Source URI must be absolute"); + } + + if (mLoadingIndex != -1) { + throw new IllegalStateException("Already started"); + } + + mLoadingIndex = 0; + mURIs.add(sourceURI); + mIsSourceURISecure = !"http".equalsIgnoreCase(sourceURI.getScheme()); + return sourceURI; + } + + /** + * This method should be invoked with the document contents for the {@link URI}s returned from + * {@link #start(URI)} and {@link #onDocumentLoaded(URI, String)}. It will continue to return + * {@link URI}s until the entire Asset Links include tree has been parsed, or until a parsing + * error occurs. + * @param documentURI the {@link URI} for document + * @param document a {@link String} containing the contents of document, interpreted as a UTF-8 + * string + * @return the next {@link URI} to fetch, or null if parsing is complete + * @throws IllFormattedStatementException if parsing fails due to a semantically invalid + * document + * @throws TooManyIncludesException if parsing fails due to exceeding the limit on the size of + * the Asset Links include tree + * @throws MatcherException if any registered {@link StatementMatcher} throws an exception + */ + @Nullable + public URI onDocumentLoaded(@NonNull URI documentURI, @NonNull String document) + throws IllFormattedStatementException, TooManyIncludesException, MatcherException { + if (mIsError) { + throw new IllegalStateException("AssetLinksJSONParser already in the error state"); + } + + try { + final URI expectedURI = mURIs.get(mLoadingIndex); + if (!documentURI.equals(expectedURI)) { + throw new IllegalArgumentException("Document URI does not match expected value: expected=" + expectedURI + ", actual=" + documentURI); + } + + final ArrayList statementList = new ArrayList<>(); + final ArrayList includeList = new ArrayList<>(); + + boolean doPostParsing = true; + try { + parseAssetLinksDocument(documentURI, document, statementList, includeList); + } catch (IllFormattedStatementException e) { + if (mLoadingIndex == 0) { + throw e; + } + Log.w(TAG, "Parsing error on an included statement list; discarding and continuing processing"); + doPostParsing = false; + mIsWarning = true; + } + + if (doPostParsing) { + // Process include directives + if (includeList.removeAll(mURIs)) { + Log.w(TAG, "Discarded include URIs to avoid recursion"); + mIsWarning = true; + } + mURIs.addAll(includeList); + if (mURIs.size() > AssetLinksGrammar.MAX_ASSET_LINKS_URIS) { + throw new TooManyIncludesException(); + } + + // Dispatch statements to matchers + for (JSONObject statement : statementList) { + for (Pair pair : mStatementMatchers) { + if (pair.first.compare(statement)) { + try { + pair.second.onMatch(pair.first, statement); + } catch (Exception e) { + throw new MatcherException("Matcher failed, terminating parsing", e); + } + } + } + } + } + + mLoadingIndex++; + return (isComplete() ? null : mURIs.get(mLoadingIndex)); + } catch (Exception e) { + mIsError = true; + throw e; + } + } + + private void parseAssetLinksDocument(@NonNull URI documentURI, + @NonNull String document, + @NonNull ArrayList statementList, + @NonNull ArrayList includeList) + throws IllFormattedStatementException { + try { + final JSONArray statements = new JSONArray(document); // mandatory top-level array + final int numStatements = statements.length(); + for (int i = 0; i < numStatements; i++) { + final JSONObject statement = statements.getJSONObject(i); // mandatory object + + if (statement.has(AssetLinksGrammar.GRAMMAR_INCLUDE)) { + parseIncludeStatement(documentURI, statement, includeList); + } else if (statement.has(AssetLinksGrammar.GRAMMAR_RELATION)) { + parseAssetLinkStatement(statement, statementList); + } else if (statement.has(AssetLinksGrammar.GRAMMAR_TARGET)) { + // Has target but no relation - error! + throw new IllFormattedStatementException("statement has target without relation"); + } else { + // Some other JSON object; it has none of include, relation, or target. Skip it. + Log.w(TAG, "Skipping an unknown JSON object in the statement list"); + mIsWarning = true; + } + } + } catch (JSONException e) { + throw new IllFormattedStatementException("Error while parsing Asset Links JSON", e); + } + } + + private void parseIncludeStatement(@NonNull URI documentURI, + @NonNull JSONObject statement, + @NonNull ArrayList includeList) + throws JSONException, IllFormattedStatementException { + if (statement.has(AssetLinksGrammar.GRAMMAR_RELATION) || + statement.has(AssetLinksGrammar.GRAMMAR_TARGET)) { + throw new IllFormattedStatementException("Include statement should not contain relation or target keys"); + } + + final URI includeURI; + try { + includeURI = URI.create(statement.getString(AssetLinksGrammar.GRAMMAR_INCLUDE)); + } catch (IllegalArgumentException e) { + throw new IllFormattedStatementException("Include statement URI is not well-formed", e); + } + final URI resolvedURI = documentURI.resolve(includeURI); + + if (mIsSourceURISecure && !"https".equalsIgnoreCase(resolvedURI.getScheme())) { + throw new IllFormattedStatementException("Include statement must reference a secure location when Asset Links source URI is secure; URI=" + resolvedURI); + } + + includeList.add(resolvedURI); + } + + private void parseAssetLinkStatement(@NonNull JSONObject statement, + @NonNull ArrayList statementList) + throws JSONException, IllFormattedStatementException { + final JSONArray relations = statement.getJSONArray(AssetLinksGrammar.GRAMMAR_RELATION); + if (relations.length() == 0) { + throw new IllFormattedStatementException("relation array must contain at least one relation"); + } + for (int i = 0; i < relations.length(); i++) { + final String relation = relations.getString(i); + if (!relation.matches(AssetLinksGrammar.RELATION_PATTERN)) { + throw new IllFormattedStatementException("relation does not match expected format"); + } + } + final JSONObject target = statement.getJSONObject(AssetLinksGrammar.GRAMMAR_TARGET); + final String namespace = target.getString(AssetLinksGrammar.GRAMMAR_NAMESPACE); + if (AssetLinksGrammar.GRAMMAR_NAMESPACE_WEB.equals(namespace)) { + validateWebTarget(target); + } else if (AssetLinksGrammar.GRAMMAR_NAMESPACE_ANDROID_APP.equals(namespace)) { + validateAndroidAppTarget(target); + } + + statementList.add(statement); + } + + private void validateWebTarget(@NonNull JSONObject webTarget) + throws JSONException, IllFormattedStatementException { + final String site = webTarget.getString(AssetLinksGrammar.GRAMMAR_WEB_SITE); + final URI uri; + try { + uri = URI.create(site); + } catch (IllegalArgumentException e) { + throw new IllFormattedStatementException("site URL format is invalid: " + site, e); + } + + if (!AssetLinksGrammar.isValidSiteURI(uri)) { + throw new IllFormattedStatementException("site URL format is invalid: " + site); + } + } + + private void validateAndroidAppTarget(@NonNull JSONObject androidAppTarget) + throws JSONException, IllFormattedStatementException { + final String packageName = androidAppTarget.getString("package_name"); + if (!packageName.matches(AssetLinksGrammar.PACKAGE_NAME_PATTERN)) { + throw new IllFormattedStatementException("package_name is invalid: " + packageName); + } + + final JSONArray certs = androidAppTarget.getJSONArray( + AssetLinksGrammar.GRAMMAR_ANDROID_APP_SHA256_CERT_FINGERPRINTS); + if (certs.length() == 0) { + throw new IllFormattedStatementException("At least one certificate must be present"); + } + for (int i = 0; i < certs.length(); i++) { + final String cert = certs.getString(i); + if (!cert.matches(AssetLinksGrammar.SHA256_CERT_FINGERPRINT_PATTERN)) { + throw new IllFormattedStatementException("Ill-formatted certificate fingerprint: " + cert); + } + } + } + + /** A callback to be invoked when a {@link StatementMatcher} match occurs */ + public interface StatementMatcherCallback { + void onMatch(@NonNull StatementMatcher matcher, @NonNull JSONObject o) throws JSONException; + } + + /** The base type for all {@link AssetLinksJSONParser} exceptions */ + public static abstract class AssetLinksJSONParserException extends Exception { + protected AssetLinksJSONParserException() {} + protected AssetLinksJSONParserException(String message) { super(message); } + protected AssetLinksJSONParserException(String message, Throwable t) { super(message, t); } + } + + /** Thrown when the Asset Links JSON is not well-formed */ + public static class IllFormattedStatementException extends AssetLinksJSONParserException { + public IllFormattedStatementException(String message) { super(message); } + public IllFormattedStatementException(String message, Throwable t) { super(message, t); } + } + + /** Thrown when the number of includes in the Asset Links tree exceeds the allowed limit */ + public static class TooManyIncludesException extends AssetLinksJSONParserException {} + + /** Thrown when a matcher generates an Exception during processing */ + public static class MatcherException extends AssetLinksJSONParserException { + public MatcherException(String message, Throwable t) { super(message, t); } + } +} diff --git a/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/StatementMatcher.java b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/StatementMatcher.java new file mode 100644 index 0000000..d55a0fc --- /dev/null +++ b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/StatementMatcher.java @@ -0,0 +1,374 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solana.digitalassetlinks; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.ArrayMap; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.Objects; + +/** + * A {@link StatementMatcher} is used to test a set of conditions against a + * Digital Asset Links statement + */ +public class StatementMatcher { + @Nullable private final String mRelation; + @Nullable private final String mTargetNamespace; + @NonNull private final ArrayMap mTargetKeyValues; + + /** + * Construct a new {@link StatementMatcher} + * @param relation if specified, the relation string condition + * @param targetNamespace if specified, the target namespace condition + * @param targetKeyValues if specified, a map of target object names and corresponding value + * conditions + */ + protected StatementMatcher(@Nullable String relation, + @Nullable String targetNamespace, + @NonNull ArrayMap targetKeyValues) { + if (relation != null && !relation.matches(AssetLinksGrammar.RELATION_PATTERN)) { + throw new IllegalArgumentException("relation does not match the expected format"); + } + + this.mRelation = relation; + this.mTargetNamespace = targetNamespace; + this.mTargetKeyValues = new ArrayMap<>(targetKeyValues); + } + + /** + * Apply this {@link StatementMatcher} against the specified + * @param o the Asset Links statement object against which to perform matching + * @return true if all conditions of this {@link StatementMatcher} are satisfied, else false + * @throws IllegalArgumentException if o is not a well-formed Asset Links statement object + */ + public boolean compare(@NonNull JSONObject o) { + try { + if (mRelation != null) { + final JSONArray relations = o.getJSONArray(AssetLinksGrammar.GRAMMAR_RELATION); // mandatory field + final int numRelations = relations.length(); + if (numRelations == 0) { + throw new IllegalArgumentException("At least one relation must be present"); + } + boolean relationMatched = false; + for (int i = 0; i < numRelations; i++) { + final String relation = relations.getString(i); + if (mRelation.equals(relation)) { + relationMatched = true; + break; + } + } + if (!relationMatched) { + return false; + } + } + + if (mTargetNamespace != null || !mTargetKeyValues.isEmpty()) { + final JSONObject target = o.getJSONObject(AssetLinksGrammar.GRAMMAR_TARGET); // mandatory field + + if (mTargetNamespace != null) { + final String namespace = target.getString(AssetLinksGrammar.GRAMMAR_NAMESPACE); // mandatory field + if (!mTargetNamespace.equals(namespace)) { + return false; + } + } + + for (Map.Entry entry : mTargetKeyValues.entrySet()) { + if (!target.has(entry.getKey())) { + return false; + } + + final Object valueObj = entry.getValue(); + if (valueObj instanceof String) { + final String value = (String)valueObj; + final String targetValue = target.getString(entry.getKey()); + if (!value.equals(targetValue)) { + return false; + } + } else if (valueObj instanceof AllowMatchInArray) { + final String value = ((AllowMatchInArray)valueObj).value; + final Object targetValueObj = target.get(entry.getKey()); + if (targetValueObj instanceof JSONArray) { + final JSONArray targetValues = (JSONArray) targetValueObj; + boolean any = false; + for (int i = 0; i < targetValues.length(); i++) { + final String targetValue = targetValues.getString(i); + if (value.equals(targetValue)) { + any = true; + break; + } + } + if (!any) { + return false; + } + } else { + if (!value.equals(targetValueObj.toString())) { + return false; + } + } + } else if (valueObj instanceof URI) { + final URI value = canonicalizeURI((URI)valueObj); + final String targetValue = target.getString(entry.getKey()); + try { + final URI targetURI = canonicalizeURI(URI.create(targetValue)); + if (!value.equals(targetURI)) { + return false; + } + } catch (IllegalArgumentException e) { + // target value not a URI; skip it. Matchers aren't responsible for + // syntax validation of the model. + } + } + } + } + } catch (JSONException e) { + throw new IllegalArgumentException("Asset Links JSON object '" + o + "' is not well-formed", e); + } + + return true; + } + + // Removes default port numbers and the trailing FQDN '.' for comparison purposes + @NonNull + private URI canonicalizeURI(@NonNull URI uri) { + final boolean hasExplicitDefaultPort; + final String scheme = uri.getScheme(); + final int port = uri.getPort(); + if ("http".equals(scheme)) { + hasExplicitDefaultPort = (port == 80); + } else if ("https".equals(scheme)) { + hasExplicitDefaultPort = (port == 443); + } else { + hasExplicitDefaultPort = false; + } + + final String host = uri.getHost(); + final boolean isFullyQualifiedDomainName = (host != null && host.length() > 1 + && host.charAt(host.length() - 1) == '.'); + + if (!hasExplicitDefaultPort && !isFullyQualifiedDomainName) { + return uri; + } + + try { + return new URI(scheme, + uri.getUserInfo(), + isFullyQualifiedDomainName ? host.substring(0, host.length() - 1) : host, + hasExplicitDefaultPort ? -1 : port, + uri.getPath(), + uri.getQuery(), + uri.getFragment()); + } catch (URISyntaxException e) { + throw new RuntimeException("Impossible when only modifying the port"); + } + } + + /** + * Create a {@link StatementMatcher} for the {@link AssetLinksGrammar#GRAMMAR_NAMESPACE_WEB} + * namespace + * @param relation the {@link AssetLinksGrammar#GRAMMAR_RELATION} to match + * @param site if non-null, the {@link AssetLinksGrammar#GRAMMAR_WEB_SITE} to match. This will + * be validated against the rules for the web domain. + * @return a {@link StatementMatcher} + * @throws IllegalArgumentException if site is non-null and fails validation + */ + public static StatementMatcher createWebStatementMatcher(@NonNull String relation, + @Nullable URI site) { + final Builder builder = StatementMatcher.newBuilder() + .setRelation(relation) + .setTargetNamespace(AssetLinksGrammar.GRAMMAR_NAMESPACE_WEB); + if (site != null) { + if (!AssetLinksGrammar.isValidSiteURI(site)) { + throw new IllegalArgumentException("site is not a valid site URI"); + } + builder.setTargetKeyValue(AssetLinksGrammar.GRAMMAR_WEB_SITE, site); + } + return builder.build(); + } + + /** + * Create a {@link StatementMatcher} for the + * {@link AssetLinksGrammar#GRAMMAR_NAMESPACE_ANDROID_APP} namespace + * @param relation the {@link AssetLinksGrammar#GRAMMAR_RELATION} to match + * @param packageName if non-null, the + * {@link AssetLinksGrammar#GRAMMAR_ANDROID_APP_PACKAGE_NAME} to match. This will be + * validated against the rules for the android_app domain. + * @param sha256CertFingerprint if non-null, the + * {@link AssetLinksGrammar#GRAMMAR_ANDROID_APP_SHA256_CERT_FINGERPRINTS} to match. This + * will be validated against the rules for the android_app domain. + * @return a {@link StatementMatcher} + * @throws IllegalArgumentException if packageName is non-null and fails validation, or if + * sha256CertFingerprint is non-null and fails validation. + */ + public static StatementMatcher createAndroidAppStatementMatcher(@NonNull String relation, + @Nullable String packageName, + @Nullable String sha256CertFingerprint) { + final Builder builder = StatementMatcher.newBuilder() + .setRelation(relation) + .setTargetNamespace(AssetLinksGrammar.GRAMMAR_NAMESPACE_ANDROID_APP); + if (packageName != null) { + if (!packageName.matches(AssetLinksGrammar.PACKAGE_NAME_PATTERN)) { + throw new IllegalArgumentException("Invalid Android app package name"); + } + builder.setTargetKeyValue( + AssetLinksGrammar.GRAMMAR_ANDROID_APP_PACKAGE_NAME, packageName); + } + if (sha256CertFingerprint != null) { + if (!sha256CertFingerprint.matches(AssetLinksGrammar.SHA256_CERT_FINGERPRINT_PATTERN)) { + throw new IllegalArgumentException("Invalid Android app certificate fingerprint"); + } + builder.setTargetKeyValue( + AssetLinksGrammar.GRAMMAR_ANDROID_APP_SHA256_CERT_FINGERPRINTS, + sha256CertFingerprint, true); + } + return builder.build(); + } + + /** + * Create a new {@link Builder} + * @return a new {@link Builder} + */ + @NonNull + public static Builder newBuilder() { + return new Builder(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + StatementMatcher that = (StatementMatcher) o; + return Objects.equals(mRelation, that.mRelation) && + Objects.equals(mTargetNamespace, that.mTargetNamespace) && + mTargetKeyValues.equals(that.mTargetKeyValues); + } + + @Override + public int hashCode() { + return Objects.hash(mRelation, mTargetNamespace, mTargetKeyValues); + } + + @NonNull + @Override + public String toString() { + return "StatementMatcher{" + + "mRelation='" + mRelation + '\'' + + ", mTargetNamespace='" + mTargetNamespace + '\'' + + ", mKeyValues=" + mTargetKeyValues + + '}'; + } + + /** + * Implements the Builder pattern for {@link StatementMatcher} + */ + public static class Builder { + private String mRelation; + private String mTargetNamespace; + private final ArrayMap mKeyValues = new ArrayMap<>(); + + /** + * Construct a new {@link Builder} + */ + public Builder() {} + + /** + * Sets the relation condition for this {@link Builder} + * @param relation the relation condition for the {@link StatementMatcher} + * @return this {@link Builder} + */ + @NonNull + public Builder setRelation(@Nullable String relation) { + mRelation = relation; + return this; + } + + /** + * Sets the target namespace condition for this {@link Builder} + * @param targetNamespace the target namespace condition for the {@link StatementMatcher} + * @return this {@link Builder} + */ + @NonNull + public Builder setTargetNamespace(@Nullable String targetNamespace) { + mTargetNamespace = targetNamespace; + return this; + } + + /** + * Sets a target String key-value condition for this {@link Builder} + * @param key the target name for which to apply this condition + * @param value the target String value of this condition + * @return this {@link Builder} + */ + @NonNull + public Builder setTargetKeyValue(@NonNull String key, @Nullable String value) { + return setTargetKeyValue(key, value, false); + } + + /** + * Sets a target String key-value condition for this {@link Builder} + * @param key the target name for which to apply this condition + * @param value the target String value of this condition + * @param allowMatchInArray whether this match is allowed to occur within an array of values + * for key + * @return this {@link Builder} + */ + @NonNull + public Builder setTargetKeyValue(@NonNull String key, + @Nullable String value, + boolean allowMatchInArray) { + if (value != null) { + if (allowMatchInArray) { + mKeyValues.put(key, new AllowMatchInArray(value)); + } else { + mKeyValues.put(key, value); + } + } else { + mKeyValues.remove(key); + } + return this; + } + + /** + * Sets a target {@link URI} key-value condition for this {@link Builder} + * @param key the target name for which to apply this condition + * @param value the target {@link URI} value of this condition + * @return this {@link Builder} + */ + @NonNull + public Builder setTargetKeyValue(@NonNull String key, @Nullable URI value) { + if (value != null) { + mKeyValues.put(key, value); + } else { + mKeyValues.remove(key); + } + return this; + } + + /** + * Construct a new {@link StatementMatcher} from the current state of this {@link Builder} + * @return a new {@link StatementMatcher} + */ + @NonNull + public StatementMatcher build() { + return new StatementMatcher(mRelation, mTargetNamespace, mKeyValues); + } + } + + private static final class AllowMatchInArray { + @NonNull + public final String value; + + public AllowMatchInArray(@NonNull String value) { + this.value = value; + } + } +} diff --git a/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/URISourceVerifier.java b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/URISourceVerifier.java new file mode 100644 index 0000000..38a4a3d --- /dev/null +++ b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/URISourceVerifier.java @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solana.digitalassetlinks; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +public abstract class URISourceVerifier { + private static final int HTTP_TIMEOUT_MS = 1000; // 1.0s + private static final int MAX_DOCUMENT_LENGTH = 50 * 1024; // 100KB (2 bytes per char) + + private final AtomicBoolean mVerificationInProgress = new AtomicBoolean(false); + private final AtomicReference mHttpURLConnection = new AtomicReference<>(null); + + /** + * Given an HTTP or HTTPS absolute {@link URI}, constructs the corresponding well-known Asset + * Links {@link URI}. For e.g., https://www.solana.com/example will return + * https://www.solana.com/.well-known/assetlinks.json. + * @param baseURI the base HTTP or HTTPS {@link URI} + * @return the corresponding well-known Asset Links {@link URI} + * @throws IllegalArgumentException if baseURI is not an hierarchical HTTP or HTTPS URI + */ + @NonNull + public static URI getWellKnownAssetLinksURI(@NonNull URI baseURI) { + if (!baseURI.isAbsolute() || baseURI.isOpaque()) { + throw new IllegalArgumentException("Source URI must be absolute and hierarchical"); + } + + final String scheme = baseURI.getScheme(); + if (!("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) { + throw new IllegalArgumentException("Source URI must be HTTP or HTTPS"); + } + + try { + return new URI(baseURI.getScheme(), baseURI.getAuthority(), "/.well-known/assetlinks.json", null, null); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Unable to construct well-known URI", e); + } + } + + /** + * Load the Asset Links tree from sourceURI, verify its contents, and run the provided + * {@link StatementMatcher}s with accompanying + * {@link AssetLinksJSONParser.StatementMatcherCallback}s against it. This function does not + * establish any relations between the source URI and any target; sub-classes should employ + * suitable {@link StatementMatcher}s to establish those relationships. + * @param sourceURI the {@link URI} from which to begin Asset Links parsing + * @param matchers the set of {@link StatementMatcherWithCallback}s to match against the Asset + * Links tree + * @return true if verification completed without any warnings, or false if non-fatal warnings + * occurred during parsing. This value does not imply that verification failed; just + * whether there were syntax or parsing errors that do not impact the validity of the + * verification result. It is primarily of interest for automated testing. + * @throws IllegalArgumentException if sourceURI is not a HTTP or HTTPS hierarchical URI + * @throws CouldNotVerifyException if verification could not be performed for any reason (such + * as malformed Asset Links files, network unavailability, too many includes, etc). + */ + protected boolean verify(@NonNull URI sourceURI, StatementMatcherWithCallback... matchers) + throws CouldNotVerifyException { + if (!mVerificationInProgress.compareAndSet(false, true)) { + throw new IllegalStateException("verification already in progress"); + } + + // Create and configure an AssetLinksJSONParser object + final AssetLinksJSONParser parser = new AssetLinksJSONParser(); + for (StatementMatcherWithCallback matcher : matchers) { + parser.addStatementMatcher(matcher.statementMatcher, matcher.callback); + } + + // Derive the initial Asset Links JSON URI + final URI assetLinksURI = getWellKnownAssetLinksURI(sourceURI); + + // Start the AssetLinksJSONParser, and respond to document requests + URI loadDocumentURI = parser.start(assetLinksURI); + try { + while (loadDocumentURI != null) { + throwIfCancelled(); + + final String document; + try { + document = loadDocument(loadDocumentURI.toURL()); + } catch (IOException | UnsupportedOperationException e) { + throw new CouldNotVerifyException("Failed loading an Asset Links document " + loadDocumentURI, e); + } + + throwIfCancelled(); + + loadDocumentURI = parser.onDocumentLoaded(loadDocumentURI, document); + } + } catch (AssetLinksJSONParser.IllFormattedStatementException | + AssetLinksJSONParser.TooManyIncludesException | + AssetLinksJSONParser.MatcherException e) { + throw new CouldNotVerifyException("Asset Links statement list processing failed", e); + } + + mVerificationInProgress.set(false); + return !parser.isWarning(); + } + + /** + * Attempt to cancel a running {@link #verify(URI, StatementMatcherWithCallback...)} for this + * {@link URISourceVerifier}. This will return immediately, however the verification may + * continue for a short amount of time (until the next cancellation point). + */ + public void cancel() { + mVerificationInProgress.set(false); + final HttpURLConnection URIConnection = mHttpURLConnection.get(); + if (URIConnection != null) { + URIConnection.disconnect(); + } + } + + private void throwIfCancelled() throws CouldNotVerifyException { + if (!mVerificationInProgress.get()) { + throw new CouldNotVerifyException("Asset Links verification cancelled"); + } + } + + // N.B. protected only so compatibility test suite harnesses can override this to provide mock + // URL objects + @NonNull + protected String loadDocument(@NonNull URL documentURL) throws IOException { + final HttpURLConnection URIConnection = (HttpURLConnection)documentURL.openConnection(); + mHttpURLConnection.set(URIConnection); + + try { + URIConnection.setConnectTimeout(HTTP_TIMEOUT_MS); + URIConnection.setReadTimeout(HTTP_TIMEOUT_MS); + URIConnection.setInstanceFollowRedirects(false); + URIConnection.connect(); + final int responseCode = URIConnection.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + throw new UnsupportedOperationException("Asset Links document fetch failed; actual=" + responseCode + ", expected=" + HttpURLConnection.HTTP_OK); + } + final String mimeType = URIConnection.getContentType(); + if (!"application/json".equals(mimeType)) { + throw new UnsupportedOperationException("Asset Links document type incorrect; actual=" + mimeType + ", expected=application/json"); + } + StringBuilder documentBuilder = new StringBuilder(); + final InputStream rawInputStream = URIConnection.getInputStream(); + final InputStreamReader charInputStream = new InputStreamReader(rawInputStream, StandardCharsets.UTF_8); + final char[] buf = new char[2048]; + while (true) { + final int count = charInputStream.read(buf); + if (count == -1) break; + documentBuilder.append(buf, 0, count); + final int documentLength = documentBuilder.length(); + if (documentLength > MAX_DOCUMENT_LENGTH) { + throw new UnsupportedOperationException("Asset Links document content too long; actual=" + documentLength + ", max=" + MAX_DOCUMENT_LENGTH); + } + } + return documentBuilder.toString(); + } finally { + URIConnection.disconnect(); + mHttpURLConnection.set(null); + } + } + + /** + * A data class wrapper to bind a {@link StatementMatcher} and a + * {@link AssetLinksJSONParser.StatementMatcherCallback} together, for matching against an + * Asset Links tree. + */ + protected static final class StatementMatcherWithCallback { + @NonNull + public final StatementMatcher statementMatcher; + + @NonNull + public final AssetLinksJSONParser.StatementMatcherCallback callback; + + public StatementMatcherWithCallback(@NonNull StatementMatcher statementMatcher, + @NonNull AssetLinksJSONParser.StatementMatcherCallback callback) { + this.statementMatcher = statementMatcher; + this.callback = callback; + } + } + + /** Indicates that the Asset Links tree could not be verified for any reason */ + public static class CouldNotVerifyException extends Exception { + public CouldNotVerifyException(String message) { super(message); } + public CouldNotVerifyException(String message, Throwable t) { super(message, t); } + } +} diff --git a/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/AndroidAppPackageVerifierUnitTests.java b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/AndroidAppPackageVerifierUnitTests.java new file mode 100644 index 0000000..339a5c4 --- /dev/null +++ b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/AndroidAppPackageVerifierUnitTests.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solana.digitalassetlinks; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +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.annotation.NonNull; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk={ RobolectricConfig.MIN_SDK, Build.VERSION_CODES.P, RobolectricConfig.CUR_SDK }) // SigningInfo introduced in P +public class AndroidAppPackageVerifierUnitTests { + // SHA256(0x01) == "4B:F5:12:2F:34:45:54:C5:3B:DE:2E:BB:8C:D2:B7:E3:D1:60:0A:D6:31:C3:85:A5:D7:CC:E2:3C:77:85:45:9A" + private static final byte[] CERT_1 = new byte[] { 0x01 }; + + // SHA256(0x02) == "DB:C1:B4:C9:00:FF:E4:8D:57:5B:5D:A5:C6:38:04:01:25:F6:5D:B0:FE:3E:24:49:4B:76:EA:98:64:57:D9:86" + private static final byte[] CERT_2 = new byte[] { 0x02 }; + + // SHA256(0x03) == "08:4F:ED:08:B9:78:AF:4D:7D:19:6A:74:46:A8:6B:58:00:9E:63:6B:61:1D:B1:62:11:B6:5A:9A:AD:FF:29:C5" + private static final byte[] CERT_3 = new byte[] { 0x03 }; + + private static final String ANDROID_APP_STATEMENT_LIST_CERTS_2_3 = + "[{\"relation\": [\"delegate_permission/common.handle_all_urls\"], " + + "\"target\": {" + + "\"namespace\": \"android_app\", " + + "\"package_name\": \"com.test.sample\", " + + "\"sha256_cert_fingerprints\": [" + + "\"DB:C1:B4:C9:00:FF:E4:8D:57:5B:5D:A5:C6:38:04:01:25:F6:5D:B0:FE:3E:24:49:4B:76:EA:98:64:57:D9:86\", " + // SHA256(CERT_2) + "\"08:4F:ED:08:B9:78:AF:4D:7D:19:6A:74:46:A8:6B:58:00:9E:63:6B:61:1D:B1:62:11:B6:5A:9A:AD:FF:29:C5\"" + // SHA256(CERT_3) + "]}}]"; + + @Test + public void testAppPackageVerificationSuccess() + throws AndroidAppPackageVerifier.CouldNotVerifyPackageException { + ArrayList mockWebContent = new ArrayList<>(); + mockWebContent.add(new MockWebContentServer.Content( + URI.create("https://www.test.com/.well-known/assetlinks.json"), + HttpURLConnection.HTTP_OK, + "application/json", + ANDROID_APP_STATEMENT_LIST_CERTS_2_3)); + + final PackageManager pm = mockPackageManagerFactory( + "com.test.sample", new byte[][] { CERT_2, CERT_1 }, false); + + final AndroidAppPackageVerifierHarness verifier = + new AndroidAppPackageVerifierHarness(pm, mockWebContent); + boolean verified = verifier.verify("com.test.sample", URI.create("https://www.test.com")); + assertTrue(verified); + } + + @Test + public void testAppPackageVerificationNoAssetLinks() { + ArrayList mockWebContent = new ArrayList<>(); + mockWebContent.add(new MockWebContentServer.Content( + URI.create("https://www.test.com/.well-known/assetlinks.json"), + HttpURLConnection.HTTP_OK, + "application/json", + ANDROID_APP_STATEMENT_LIST_CERTS_2_3)); + + final PackageManager pm = mockPackageManagerFactory( + "com.test.sample", new byte[][] { CERT_2, CERT_1 }, false); + + final AndroidAppPackageVerifierHarness verifier = + new AndroidAppPackageVerifierHarness(pm, mockWebContent); + assertThrows(AndroidAppPackageVerifier.CouldNotVerifyPackageException.class, + () ->verifier.verify("com.test.sample", URI.create("https://www.other.com"))); + } + + @Test + public void testAppPackageVerificationNoMatchingPackageInAssetLinks() + throws AndroidAppPackageVerifier.CouldNotVerifyPackageException { + ArrayList mockWebContent = new ArrayList<>(); + mockWebContent.add(new MockWebContentServer.Content( + URI.create("https://www.test.com/.well-known/assetlinks.json"), + HttpURLConnection.HTTP_OK, + "application/json", + ANDROID_APP_STATEMENT_LIST_CERTS_2_3)); + + final PackageManager pm = mockPackageManagerFactory( + "com.test.other", new byte[][] { CERT_2, CERT_1 }, false); + + final AndroidAppPackageVerifierHarness verifier = + new AndroidAppPackageVerifierHarness(pm, mockWebContent); + boolean verified = verifier.verify("com.test.other", URI.create("https://www.test.com")); + assertFalse(verified); + } + + @Test + public void testAppPackageVerificationInsecureAssetLinks() { + ArrayList mockWebContent = new ArrayList<>(); + mockWebContent.add(new MockWebContentServer.Content( + URI.create("http://www.test.com/.well-known/assetlinks.json"), + HttpURLConnection.HTTP_OK, + "application/json", + ANDROID_APP_STATEMENT_LIST_CERTS_2_3)); + + final PackageManager pm = mockPackageManagerFactory( + "com.test.sample", new byte[][] { CERT_2, CERT_1 }, false); + + final AndroidAppPackageVerifierHarness verifier = + new AndroidAppPackageVerifierHarness(pm, mockWebContent); + assertThrows(AndroidAppPackageVerifier.CouldNotVerifyPackageException.class, + () ->verifier.verify("com.test.sample", URI.create("http://www.test.com"))); + } + + @Test + public void testAppPackageVerificationNoMatchingCertificate() + throws AndroidAppPackageVerifier.CouldNotVerifyPackageException { + ArrayList mockWebContent = new ArrayList<>(); + mockWebContent.add(new MockWebContentServer.Content( + URI.create("https://www.test.com/.well-known/assetlinks.json"), + HttpURLConnection.HTTP_OK, + "application/json", + ANDROID_APP_STATEMENT_LIST_CERTS_2_3)); + + final PackageManager pm = mockPackageManagerFactory( + "com.test.sample", new byte[][] { CERT_1 }, false); + + final AndroidAppPackageVerifierHarness verifier = + new AndroidAppPackageVerifierHarness(pm, mockWebContent); + boolean verified = verifier.verify("com.test.sample", URI.create("https://www.test.com")); + assertFalse(verified); + } + + @Test + public void testAppPackageVerificationMultipleSignersSuccess() + throws AndroidAppPackageVerifier.CouldNotVerifyPackageException { + ArrayList mockWebContent = new ArrayList<>(); + mockWebContent.add(new MockWebContentServer.Content( + URI.create("https://www.test.com/.well-known/assetlinks.json"), + HttpURLConnection.HTTP_OK, + "application/json", + ANDROID_APP_STATEMENT_LIST_CERTS_2_3)); + + final PackageManager pm = mockPackageManagerFactory( + "com.test.sample", new byte[][] { CERT_2, CERT_3 }, true); + + final AndroidAppPackageVerifierHarness verifier = + new AndroidAppPackageVerifierHarness(pm, mockWebContent); + boolean verified = verifier.verify("com.test.sample", URI.create("https://www.test.com")); + assertTrue(verified); + } + + @Test + public void testAppPackageVerificationMultipleSignersMissingSigner() + throws AndroidAppPackageVerifier.CouldNotVerifyPackageException { + ArrayList mockWebContent = new ArrayList<>(); + mockWebContent.add(new MockWebContentServer.Content( + URI.create("https://www.test.com/.well-known/assetlinks.json"), + HttpURLConnection.HTTP_OK, + "application/json", + ANDROID_APP_STATEMENT_LIST_CERTS_2_3)); + + final PackageManager pm = mockPackageManagerFactory( + "com.test.sample", new byte[][] { CERT_1, CERT_2 }, true); + + final AndroidAppPackageVerifierHarness verifier = + new AndroidAppPackageVerifierHarness(pm, mockWebContent); + boolean verified = verifier.verify("com.test.sample", URI.create("https://www.test.com")); + assertFalse(verified); + } + + @Test + public void testAppPackageVerificationNoMatchingPackageInPackageManager() { + ArrayList mockWebContent = new ArrayList<>(); + mockWebContent.add(new MockWebContentServer.Content( + URI.create("https://www.test.com/.well-known/assetlinks.json"), + HttpURLConnection.HTTP_OK, + "application/json", + ANDROID_APP_STATEMENT_LIST_CERTS_2_3)); + + final PackageManager pm = mock(PackageManager.class); + try { + when(pm.getPackageInfo(eq("com.test.sample"), anyInt())) + .thenThrow(new PackageManager.NameNotFoundException()); + } catch (PackageManager.NameNotFoundException ignored) {} + + final AndroidAppPackageVerifierHarness verifier = + new AndroidAppPackageVerifierHarness(pm, mockWebContent); + assertThrows(AndroidAppPackageVerifier.CouldNotVerifyPackageException.class, + () ->verifier.verify("com.test.sample", URI.create("https://www.test.com"))); + } + + private static PackageManager mockPackageManagerFactory(@NonNull String packageName, + @NonNull byte[][] certificates, + boolean multipleSigners) { + if (certificates.length == 0) { + throw new IllegalArgumentException("at least 1 certificate required"); + } else if (multipleSigners && certificates.length == 1) { + throw new IllegalArgumentException("multipleSigners requires at least 2 certificates"); + } + + final PackageInfo pi = new PackageInfo(); + final int piFlags; + final Signature[] certs = new Signature[certificates.length]; + for (int i = 0; i < certificates.length; i++) { + certs[i] = new Signature(certificates[i]); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + final SigningInfo si = mock(SigningInfo.class); + when(si.hasMultipleSigners()).thenReturn(multipleSigners); + when(si.hasPastSigningCertificates()).thenReturn(!multipleSigners && certs.length >= 2); + when(si.getSigningCertificateHistory()).thenReturn(multipleSigners ? null : certs); + when(si.getApkContentsSigners()) + .thenReturn(multipleSigners ? certs : new Signature[]{certs[0]}); + + piFlags = PackageManager.GET_SIGNING_CERTIFICATES; + pi.signingInfo = si; + //noinspection deprecation + pi.signatures = null; + } else { + piFlags = PackageManager.GET_SIGNATURES; + pi.signatures = (multipleSigners ? certs : new Signature[] { certs[0] }); + } + + final PackageManager pm = mock(PackageManager.class); + try { + when(pm.getPackageInfo(eq(packageName), eq(piFlags))).thenReturn(pi); + } catch (PackageManager.NameNotFoundException ignored) {} + + return pm; + } + + private static class AndroidAppPackageVerifierHarness extends AndroidAppPackageVerifier { + @NonNull + private final MockWebContentServer server; + + public AndroidAppPackageVerifierHarness( + @NonNull PackageManager pm, + @NonNull List mockWebContent) { + super(pm); + server = new MockWebContentServer(mockWebContent); + } + + @NonNull + @Override + protected String loadDocument(@NonNull URL documentURL) throws IOException { + try { + return super.loadDocument(server.serve(documentURL.toURI())); + } catch (URISyntaxException e) { + throw new RuntimeException("Test harness error converting URL to URI", e); + } + } + } +} diff --git a/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/DigitalAssetLinksCompatibilityTestSuite.java b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/DigitalAssetLinksCompatibilityTestSuite.java new file mode 100644 index 0000000..a21461e --- /dev/null +++ b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/DigitalAssetLinksCompatibilityTestSuite.java @@ -0,0 +1,512 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solana.digitalassetlinks; + +import static org.junit.Assert.*; + +import androidx.annotation.NonNull; + +import com.google.digitalassetlinks.v1.MessagesProto; +import com.google.digitalassetlinks.v1.testproto.TestProto; +import com.google.protobuf.AbstractMessage; +import com.google.protobuf.Message; +import com.google.protobuf.TextFormat; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.io.Reader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +@RunWith(ParameterizedRobolectricTestRunner.class) +@Config(sdk={ RobolectricConfig.MIN_SDK, RobolectricConfig.CUR_SDK }) +public class DigitalAssetLinksCompatibilityTestSuite { + // Change this value to false to run against the real hosted Digital Asset Links content + private static final boolean RUN_LOCALLY = true; + + private static final File BASE_PATH = new File("external/digitalassetlinks/compatibility-tests/v1"); + + private static final File[] SOURCES = new File[] { + new File(BASE_PATH, "1000-query-parsing/1000-list-source.pb"), + new File(BASE_PATH, "1000-query-parsing/1100-list-relation.pb"), + new File(BASE_PATH, "1000-query-parsing/1200-check-source.pb"), + new File(BASE_PATH, "1000-query-parsing/1300-check-relation.pb"), + new File(BASE_PATH, "1000-query-parsing/1400-check-target.pb"), + new File(BASE_PATH, "2000-web-statement-list-parsing/2000-general.pb"), + new File(BASE_PATH, "2000-web-statement-list-parsing/2100-relations.pb"), + new File(BASE_PATH, "2000-web-statement-list-parsing/2200-web-targets.pb"), + new File(BASE_PATH, "2000-web-statement-list-parsing/2300-android-targets.pb"), + new File(BASE_PATH, "3000-android-statement-list-parsing/3000-general.pb"), + new File(BASE_PATH, "3000-android-statement-list-parsing/3100-relations.pb"), + new File(BASE_PATH, "3000-android-statement-list-parsing/3200-web-targets.pb"), + new File(BASE_PATH, "3000-android-statement-list-parsing/3300-android-targets.pb"), + new File(BASE_PATH, "4000-query-matching/4000-list-source.pb"), + new File(BASE_PATH, "4000-query-matching/4100-list-relation.pb"), + new File(BASE_PATH, "4000-query-matching/4200-check-source.pb"), + new File(BASE_PATH, "4000-query-matching/4300-check-relation.pb"), + new File(BASE_PATH, "4000-query-matching/4400-check-target.pb"), + new File(BASE_PATH, "5000-include-file-processing/5000-include-file-processing.pb"), + }; + + @NonNull + @ParameterizedRobolectricTestRunner.Parameters(name="{0} -> {1}") + public static Iterable data() { + final ArrayList tests = new ArrayList<>(); + + for (File f : SOURCES) { + final TestProto.CompatibilityTestSuite.Builder builder = + TestProto.CompatibilityTestSuite.newBuilder(); + loadTextProtobuf(f, builder); + final TestProto.CompatibilityTestSuite cts = builder.build(); + + for (TestProto.TestGroup testGroup : cts.getTestGroupList()) { + final String testGroupName = testGroup.getName(); + + for (TestProto.ListTestCase list : testGroup.getListStatementsTestsList()) { + final String testName = list.getName(); + tests.add(new Object[] { testGroupName, testName, testGroup, list }); + } + + for (TestProto.CheckTestCase check : testGroup.getCheckStatementsTestsList()) { + final String testName = check.getName(); + tests.add(new Object[] { testGroupName, testName, testGroup, check }); + } + } + } + return tests; + } + + private static void loadTextProtobuf(@NonNull File file, @NonNull Message.Builder messageBuilder) { + try { + loadTextProtobuf(new FileReader(file), messageBuilder); + } catch (FileNotFoundException e) { + throw new IllegalArgumentException("Failed to load protobuf message from " + file, e); + } + } + + private static void loadTextProtobuf(@NonNull Reader reader, @NonNull Message.Builder messageBuilder) { + final TextFormat.Parser textParser = TextFormat.getParser(); + try { + textParser.merge(reader, messageBuilder); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to load protobuf message", e); + } + } + + @NonNull + private final String testGroupName; + + @NonNull + private final String testName; + + @NonNull + private final TestProto.TestGroup testGroup; + + @NonNull + private final AbstractMessage testCase; + + public DigitalAssetLinksCompatibilityTestSuite(@NonNull String testGroupName, + @NonNull String testName, + @NonNull TestProto.TestGroup testGroup, + @NonNull AbstractMessage testCase) { + this.testGroupName = testGroupName; + this.testName = testName; + this.testGroup = testGroup; + this.testCase = testCase; + } + + @Test + public void compatibilitySuiteTestCase() { + skippedTests(); + + if (testCase instanceof TestProto.ListTestCase) { + listRequestTestCase((TestProto.ListTestCase) testCase); + } else if (testCase instanceof TestProto.CheckTestCase) { + checkRequestTestCase((TestProto.CheckTestCase) testCase); + } else { + throw new IllegalArgumentException("Unknown test case type"); + } + } + + private void skippedTests() { + Assume.assumeTrue( + "Ignore stub_environment_only tests when running against real hosted content", + RUN_LOCALLY || !testGroup.getStubEnvironmentOnly()); + + // N.B. list statement test in comptest2002/'Parses assetlinks.json correctly.' appears to + // be incorrect. It looks like the same test as comptest1101/'Missing relation query`. which + // is marked as success. Force-skip this test. + Assume.assumeFalse("Skipping broken test", + testGroupName.startsWith("comptest2002") + && testName.equals("Parses assetlinks.json correctly.")); + } + + // ============================================================================================= + // List request test case + // ============================================================================================= + + private void listRequestTestCase(@NonNull TestProto.ListTestCase listTestCase) { + final MessagesProto.ListRequest request = listTestCase.getRequest(); + final MessagesProto.Asset source = request.getSource(); + final MessagesProto.Asset.AssetCase assetCase = source.getAssetCase(); + + Assume.assumeFalse("We don't currently handle Android app sources", + assetCase == MessagesProto.Asset.AssetCase.ANDROID_APP); + + final String site = source.getWeb().getSite(); + final TestProto.QueryOutcome outcome = listTestCase.getOutcome(); + final String relation = request.getRelation(); + final List expectedResponse = listTestCase.getResponseList(); + + URI siteURI = URI.create(site); + Assume.assumeTrue("Ignore tests with a non-empty URI path; we replace the path element from the URI with the well-known path", + siteURI.getPath() == null || siteURI.getPath().isEmpty()); + Assume.assumeTrue("Ignore tests with a non-empty URI query; we remove the query element from the URI", + siteURI.getQuery() == null || siteURI.getQuery().isEmpty()); + Assume.assumeTrue("Ignore tests with a non-empty URI fragment; we remove the fragment element from the URI", + siteURI.getFragment() == null || siteURI.getFragment().isEmpty()); + + final ListRequestVerifierHarness lrv = new ListRequestVerifierHarness( + testGroup.getWebContentList()); + + try { + List response = lrv.list(siteURI, relation); + + assertSame(outcome, lrv.hasParserWarnings() ? + TestProto.QueryOutcome.FETCH_ERROR : TestProto.QueryOutcome.SUCCESS); + assertStatementsListsMatch(expectedResponse, response); + } catch (URISourceVerifier.CouldNotVerifyException e) { + assertEquals("Actual: " + e, outcome, TestProto.QueryOutcome.FETCH_ERROR); + if (!expectedResponse.isEmpty()) { + throw new IllegalArgumentException("On failure, expected responses should be empty"); + } + } catch (QueryParsingException e) { + assertEquals("Actual: " + e, outcome, TestProto.QueryOutcome.QUERY_PARSING_ERROR); + if (!expectedResponse.isEmpty()) { + throw new IllegalArgumentException("On failure, expected responses should be empty"); + } + } + } + + private void assertStatementsListsMatch(@NonNull List ref, + @NonNull List check) { + assertEquals(ref.size(), check.size()); + + final ArrayList mutCheck = new ArrayList<>(check); + for (MessagesProto.Statement s1 : ref) { + final String s1r = s1.getRelation(); + final MessagesProto.Asset s1t = s1.getTarget(); + final MessagesProto.Asset.AssetCase s1ac = s1t.getAssetCase(); + for (int i = 0; i < mutCheck.size(); i++) { + final MessagesProto.Statement s2 = mutCheck.get(i); + final String s2r = s2.getRelation(); + final MessagesProto.Asset s2t = s2.getTarget(); + final MessagesProto.Asset.AssetCase s2ac = s2t.getAssetCase(); + if (!s1r.equals(s2r) || s1ac != s2ac) continue; + switch (s1ac) { + case WEB: + final String s1s = s1t.getWeb().getSite(); + final String s2s = s2t.getWeb().getSite(); + if (!s1s.equals(s2s)) continue; + break; + case ANDROID_APP: + final MessagesProto.AndroidAppAsset s1a = s1t.getAndroidApp(); + final MessagesProto.AndroidAppAsset s2a = s2t.getAndroidApp(); + final String s1p = s1a.getPackageName(); + final String s2p = s2a.getPackageName(); + final String s1c = s1a.getCertificate().getSha256Fingerprint(); + final String s2c = s2a.getCertificate().getSha256Fingerprint(); + if (!s1p.equals(s2p) || !s1c.equals(s2c)) continue; + break; + case ASSET_NOT_SET: + default: + throw new IllegalArgumentException("Asset type must be valid"); + } + + mutCheck.remove(i); + break; + } + } + + assertTrue("Expected mutCheck to be empty. mutCheck=" + mutCheck + ", ref=" + ref + + ", check=" + check, mutCheck.isEmpty()); + } + + // NOTE: this test harness contains a small amount of logic, adapting from the expectations of + // the Digital Asset Links Compatibility Test Suite to our verifier implementation. + private static class ListRequestVerifierHarness extends MockInjectingURISourceVerifier { + public ListRequestVerifierHarness( + @NonNull List hostedWebContents) { + super(hostedWebContents); + } + + public List list(@NonNull URI uri, @NonNull String relation) + throws CouldNotVerifyException, QueryParsingException { + if (!AssetLinksGrammar.isValidSiteURI(uri)) { + throw new QueryParsingException("Invalid source URI"); + } + + final ArrayList statements = new ArrayList<>(); + final StatementMatcher matcher; + try { + matcher = StatementMatcher.newBuilder() + .setRelation(!relation.isEmpty() ? relation : null) + .build(); + } catch (IllegalArgumentException e) { + throw new QueryParsingException("Failing verification due to invalid matcher"); + } + final AssetLinksJSONParser.StatementMatcherCallback callback = (statement, o) -> { + final JSONArray relations = o.getJSONArray(AssetLinksGrammar.GRAMMAR_RELATION); + for (int i = 0; i < relations.length(); i++) { + final String r = relations.getString(i); + if (!relation.isEmpty() && !relation.equals(r)) continue; + final JSONObject t = o.getJSONObject(AssetLinksGrammar.GRAMMAR_TARGET); + final String s = t.getString(AssetLinksGrammar.GRAMMAR_WEB_SITE); + final String namespace = t.getString(AssetLinksGrammar.GRAMMAR_NAMESPACE); + if (AssetLinksGrammar.GRAMMAR_NAMESPACE_WEB.equals(namespace)) { + final MessagesProto.WebAsset wa = MessagesProto.WebAsset.newBuilder() + .setSite(s + ".").build(); + final MessagesProto.Asset a = MessagesProto.Asset.newBuilder() + .setWeb(wa).build(); + final MessagesProto.Statement st = MessagesProto.Statement.newBuilder() + .setRelation(r).setTarget(a).build(); + statements.add(st); + } else if (AssetLinksGrammar.GRAMMAR_NAMESPACE_ANDROID_APP.equals(namespace)) { + final String p = t.getString(AssetLinksGrammar.GRAMMAR_ANDROID_APP_PACKAGE_NAME); + final JSONArray c = t.getJSONArray(AssetLinksGrammar.GRAMMAR_ANDROID_APP_SHA256_CERT_FINGERPRINTS); + for (int j = 0; j < c.length(); j++) { + final String fp = c.getString(j); + final MessagesProto.AndroidAppAsset.CertificateInfo ci = + MessagesProto.AndroidAppAsset.CertificateInfo.newBuilder() + .setSha256Fingerprint(fp).build(); + final MessagesProto.AndroidAppAsset aaa = MessagesProto.AndroidAppAsset.newBuilder() + .setPackageName(p).setCertificate(ci).build(); + final MessagesProto.Asset a = MessagesProto.Asset.newBuilder(). + setAndroidApp(aaa).build(); + final MessagesProto.Statement st = MessagesProto.Statement.newBuilder() + .setRelation(r).setTarget(a).build(); + statements.add(st); + } + } else { + throw new IllegalArgumentException("namespace " + namespace + " not recognized"); + } + } + }; + try { + verify(uri, new StatementMatcherWithCallback(matcher, callback)); + } catch (IllegalArgumentException e) { + throw new QueryParsingException("Failed verification due to bad URI " + uri, e); + } + + return statements; + } + } + + // ============================================================================================= + // Check request test case + // ============================================================================================= + + private void checkRequestTestCase(@NonNull TestProto.CheckTestCase checkTestCase) { + final MessagesProto.CheckRequest request = checkTestCase.getRequest(); + final MessagesProto.Asset source = request.getSource(); + final MessagesProto.Asset.AssetCase assetCase = source.getAssetCase(); + final MessagesProto.Asset target = request.getTarget(); + + Assume.assumeFalse("We don't currently handle Android app sources", + assetCase == MessagesProto.Asset.AssetCase.ANDROID_APP); + + final String site = source.getWeb().getSite(); + final TestProto.QueryOutcome outcome = checkTestCase.getOutcome(); + final String relation = request.getRelation(); + final boolean response = checkTestCase.getResponse(); + + URI siteURI = URI.create(site); + + final CheckRequestVerifierHarness crv = new CheckRequestVerifierHarness( + testGroup.getWebContentList()); + try { + final boolean linked = crv.check(siteURI, relation, target); + assertSame(outcome, crv.hasParserWarnings() ? + TestProto.QueryOutcome.FETCH_ERROR : TestProto.QueryOutcome.SUCCESS); + assertEquals(response, linked); + } catch (URISourceVerifier.CouldNotVerifyException e) { + assertEquals("Actual: " + e, outcome, TestProto.QueryOutcome.FETCH_ERROR); + assertFalse(response); + } catch (QueryParsingException e) { + assertEquals("Actual: " + e, outcome, TestProto.QueryOutcome.QUERY_PARSING_ERROR); + assertFalse(response); + } + } + + // NOTE: this test harness contains a small amount of logic, adapting from the expectations of + // the Digital Asset Links Compatibility Test Suite to our verifier implementation. + private static class CheckRequestVerifierHarness extends MockInjectingURISourceVerifier { + public CheckRequestVerifierHarness( + @NonNull List hostedWebContents) { + super(hostedWebContents); + } + + public boolean check(@NonNull URI uri, + @NonNull String relation, + @NonNull MessagesProto.Asset target) + throws CouldNotVerifyException, QueryParsingException { + if (!AssetLinksGrammar.isValidSiteURI(uri)) { + throw new QueryParsingException("Invalid source URI"); + } + + final boolean[] linked = { false }; + + final StatementMatcher matcher; + + final MessagesProto.Asset.AssetCase assetCase = target.getAssetCase(); + switch (assetCase) { + case WEB: + final URI targetSite; + try { + targetSite = URI.create(target.getWeb().getSite()); + matcher = StatementMatcher.createWebStatementMatcher(relation, targetSite); + } catch (IllegalArgumentException e) { + throw new QueryParsingException("target site is not a valid URI", e); + } + break; + case ANDROID_APP: + final String packageName = target.getAndroidApp().getPackageName(); + final String certFingerprint = target.getAndroidApp().getCertificate() + .getSha256Fingerprint(); + try { + matcher = StatementMatcher.createAndroidAppStatementMatcher(relation, + packageName, certFingerprint); + } catch (IllegalArgumentException e) { + throw new QueryParsingException("package name or cert fingerprint is not valid", e); + } + break; + case ASSET_NOT_SET: + default: + throw new QueryParsingException("Unknown target asset case"); + } + + final AssetLinksJSONParser.StatementMatcherCallback callback = + ((m, o) -> linked[0] = true); + + try { + verify(uri, new StatementMatcherWithCallback(matcher, callback)); + } catch (IllegalArgumentException e) { + throw new QueryParsingException("Failed verification due to bad URI " + uri, e); + } + + return linked[0]; + } + } + + // ============================================================================================= + // Common + // ============================================================================================= + + private static final class QueryParsingException extends Exception { + public QueryParsingException(String message) { super(message); } + public QueryParsingException(String message, Throwable t) { super(message, t); } + } + + private static class MockInjectingURISourceVerifier extends URISourceVerifier { + private final MockWebContentServer server; + + private boolean hasParserWarnings; + + public MockInjectingURISourceVerifier( + @NonNull List hostedWebContents) { + if (RUN_LOCALLY) { + ArrayList mockWebContents = + new ArrayList<>(hostedWebContents.size()); + for (TestProto.HostedWebContent hwc : hostedWebContents) { + mockWebContents.add(new MockWebContentServer.Content( + canonicalizeURI(URI.create(hwc.getUrl())), + HttpURLConnection.HTTP_OK, + "application/json", + hwc.getBody())); + } + server = new MockWebContentServer(mockWebContents); + } else { + server = null; + } + } + + // Removes default port numbers and the trailing FQDN '.' for comparison purposes + @NonNull + private URI canonicalizeURI(@NonNull URI uri) { + final boolean hasExplicitDefaultPort; + final String scheme = uri.getScheme(); + final int port = uri.getPort(); + if ("http".equals(scheme)) { + hasExplicitDefaultPort = (port == 80); + } else if ("https".equals(scheme)) { + hasExplicitDefaultPort = (port == 443); + } else { + hasExplicitDefaultPort = false; + } + + final String host = uri.getHost(); + final boolean isFullyQualifiedDomainName = (host != null && host.length() > 1 + && host.charAt(host.length() - 1) == '.'); + + if (!hasExplicitDefaultPort && !isFullyQualifiedDomainName) { + return uri; + } + + try { + return new URI(scheme, + uri.getUserInfo(), + isFullyQualifiedDomainName ? host.substring(0, host.length() - 1) : host, + hasExplicitDefaultPort ? -1 : port, + uri.getPath(), + uri.getQuery(), + uri.getFragment()); + } catch (URISyntaxException e) { + throw new RuntimeException("Impossible when only modifying the port"); + } + } + + @Override + protected boolean verify(@NonNull URI sourceURI, StatementMatcherWithCallback... matchers) + throws CouldNotVerifyException { + final boolean result = super.verify(sourceURI, matchers); + hasParserWarnings = !result; + return result; + } + + public boolean hasParserWarnings() { + return this.hasParserWarnings; + } + + @NonNull + @Override + protected String loadDocument(@NonNull URL documentURL) throws IOException { + URL url; + if (RUN_LOCALLY) { + try { + url = server.serve(canonicalizeURI(documentURL.toURI())); + } catch (URISyntaxException e) { + throw new RuntimeException("Test harness error converting URL to URI", e); + } + } else { + url = documentURL; + } + return super.loadDocument(url); + } + } +} diff --git a/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/MockWebContentServer.java b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/MockWebContentServer.java new file mode 100644 index 0000000..5c92cd6 --- /dev/null +++ b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/MockWebContentServer.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solana.digitalassetlinks; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; + +public class MockWebContentServer { + public static final class Content { + public final URI url; + public final int responseCode; + public final String contentType; + public final String responseBody; + + public Content(@NonNull URI url, + int responseCode, + @Nullable String contentType, + @Nullable String responseBody) { + this.url = url; + this.responseCode = responseCode; + this.contentType = contentType; + this.responseBody = responseBody; + } + } + + private static final URL MOCK_404 = createMockURLForContent( + new Content(URI.create(""), HttpURLConnection.HTTP_NOT_FOUND, null, null)); + + private final HashMap mockURLs; + + public MockWebContentServer(List contents) { + mockURLs = new HashMap<>(contents.size()); + for (Content c : contents) { + final URL mockURL = createMockURLForContent(c); + mockURLs.put(c.url, mockURL); + } + } + + @NonNull + private static URL createMockURLForContent(@NonNull Content content) { + try { + final HttpURLConnection mockConn = mock(HttpURLConnection.class); + when(mockConn.getResponseCode()).thenReturn(content.responseCode); + when(mockConn.getContentType()).thenReturn(content.contentType); + if (content.responseBody != null) { + when(mockConn.getInputStream()).thenReturn(new ByteArrayInputStream( + content.responseBody.getBytes(StandardCharsets.UTF_8))); + } else { + when(mockConn.getInputStream()).thenThrow(new IOException()); + } + final URL mockURL = mock(URL.class); + when(mockURL.openConnection()).thenReturn(mockConn); + return mockURL; + } catch (IOException e) { + throw new RuntimeException("Test harness error when mocking web content", e); + } + } + + @NonNull + public URL serve(URI source) { + URL url = mockURLs.get(source); + if (url == null) { + url = MOCK_404; + } + return url; + } +} diff --git a/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/RobolectricConfig.java b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/RobolectricConfig.java new file mode 100644 index 0000000..2749741 --- /dev/null +++ b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/RobolectricConfig.java @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solana.digitalassetlinks; + +import android.os.Build; + +public final class RobolectricConfig { + public static final int MIN_SDK = Build.VERSION_CODES.M; + public static final int CUR_SDK = Build.VERSION_CODES.S; + + private RobolectricConfig() {} +} diff --git a/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/URISourceVerifierUnitTests.java b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/URISourceVerifierUnitTests.java new file mode 100644 index 0000000..cbb4242 --- /dev/null +++ b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/URISourceVerifierUnitTests.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solana.digitalassetlinks; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import androidx.annotation.NonNull; + +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Semaphore; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk={ RobolectricConfig.MIN_SDK, RobolectricConfig.CUR_SDK }) +public class URISourceVerifierUnitTests { + + // NOTE: this class will not test the parsing correctness of this class - there's a whole + // compatibility suite that covers that in detail. This will focus on runtime behaviors. + + @Test + public void testGetWellKnownAssetLinksURISuccess() { + final URI baseURI = URI.create("https://www.test.com:1234/foo/bar"); + final URI assetLinksURI = URISourceVerifier.getWellKnownAssetLinksURI(baseURI); + assertEquals("https", assetLinksURI.getScheme()); + assertEquals("www.test.com:1234", assetLinksURI.getAuthority()); + assertEquals("/.well-known/assetlinks.json", assetLinksURI.getPath()); + assertNull(assetLinksURI.getQuery()); + assertNull(assetLinksURI.getFragment()); + } + + @Test + public void testGetWellKnownAssetLinksURIBlankURIError() { + final URI blankURI = URI.create(""); + assertThrows(IllegalArgumentException.class, + () -> URISourceVerifier.getWellKnownAssetLinksURI(blankURI)); + } + + @Test + public void testGetWellKnownAssetLinksURIUnknownProtocolURIError() { + final URI badProtocolURI = URI.create("foo://www.test.com"); + assertThrows(IllegalArgumentException.class, + () -> URISourceVerifier.getWellKnownAssetLinksURI(badProtocolURI)); + } + + @Test + public void testGetWellKnownAssetLinksURIOpaqueHTTPSURIError() { + final URI opaqueHTTPSURI = URI.create("https:www.test.com"); + assertThrows(IllegalArgumentException.class, + () -> URISourceVerifier.getWellKnownAssetLinksURI(opaqueHTTPSURI)); + } + + @Test + public void testGetWellKnownAssetLinksURIRelativeURIError() { + final URI relativeURI = URI.create("www.test.com"); + assertThrows(IllegalArgumentException.class, + () -> URISourceVerifier.getWellKnownAssetLinksURI(relativeURI)); + } + + @Test + public void testHTTP200() throws URISourceVerifier.CouldNotVerifyException { + final ArrayList mockWebContents = new ArrayList<>(); + mockWebContents.add(new MockWebContentServer.Content( + URI.create("https://www.test.com/.well-known/assetlinks.json"), + HttpURLConnection.HTTP_OK, "application/json", "[]")); + final URISourceVerifierMockContentHarness uriSourceVerifier = + new URISourceVerifierMockContentHarness(mockWebContents); + + uriSourceVerifier.verify(URI.create("https://www.test.com")); + } + + @Test + public void testHTTP204() { + final ArrayList mockWebContents = new ArrayList<>(); + mockWebContents.add(new MockWebContentServer.Content(URI.create( + "https://www.test.com/.well-known/assetlinks.json"), + HttpURLConnection.HTTP_NO_CONTENT, "application/json", "")); + final URISourceVerifierMockContentHarness uriSourceVerifier = + new URISourceVerifierMockContentHarness(mockWebContents); + + assertThrows(URISourceVerifier.CouldNotVerifyException.class, + () -> uriSourceVerifier.verify(URI.create("https://www.test.com"))); + } + + @Test + @Ignore("Need a better mechansim that mock URLs to test HTTP redirect behavior") + public void testHTTP302() { + fail(); + } + + @Test + public void testHTTP404() { + final ArrayList mockWebContents = new ArrayList<>(); + final URISourceVerifierMockContentHarness uriSourceVerifier = + new URISourceVerifierMockContentHarness(mockWebContents); + + assertThrows(URISourceVerifier.CouldNotVerifyException.class, + () -> uriSourceVerifier.verify(URI.create("https://www.test.com"))); + } + + @Test + public void testNonJSONContentType() { + final ArrayList mockWebContents = new ArrayList<>(); + mockWebContents.add(new MockWebContentServer.Content( + URI.create("https://www.test.com/.well-known/assetlinks.json"), + HttpURLConnection.HTTP_OK, "application/text", "[]")); + final URISourceVerifierMockContentHarness uriSourceVerifier = + new URISourceVerifierMockContentHarness(mockWebContents); + + assertThrows(URISourceVerifier.CouldNotVerifyException.class, + () -> uriSourceVerifier.verify(URI.create("https://www.test.com"))); + } + + @Test + public void testNonJSONContent() { + final ArrayList mockWebContents = new ArrayList<>(); + mockWebContents.add(new MockWebContentServer.Content( + URI.create("https://www.test.com/.well-known/assetlinks.json"), + HttpURLConnection.HTTP_OK, "application/json", "BAD")); + final URISourceVerifierMockContentHarness uriSourceVerifier = + new URISourceVerifierMockContentHarness(mockWebContents); + + assertThrows(URISourceVerifier.CouldNotVerifyException.class, + () -> uriSourceVerifier.verify(URI.create("https://www.test.com"))); + } + + @Test(timeout=5000) + public void testCancellation() { + final URISourceVerifierCancellationHarness uriSourceVerifier = + new URISourceVerifierCancellationHarness(); + + uriSourceVerifier.launchAsyncAfterConnect(() -> { + try { + Thread.sleep(200); + uriSourceVerifier.cancel(); + } catch (InterruptedException ignored) {} + }); + + assertThrows(URISourceVerifier.CouldNotVerifyException.class, + () -> uriSourceVerifier.verify(URI.create("https://www.test.com"))); + } + + private static class URISourceVerifierMockContentHarness extends URISourceVerifier { + @NonNull + private final MockWebContentServer server; + + public URISourceVerifierMockContentHarness( + @NonNull List mockWebContent) { + server = new MockWebContentServer(mockWebContent); + } + + public void verify(URI sourceURI) throws CouldNotVerifyException { + super.verify(sourceURI); + } + + @NonNull + @Override + protected String loadDocument(@NonNull URL documentURL) throws IOException { + try { + return super.loadDocument(server.serve(documentURL.toURI())); + } catch (URISyntaxException e) { + throw new RuntimeException("Test harness error converting URL to URI", e); + } + } + } + + private static class URISourceVerifierCancellationHarness extends URISourceVerifier { + private final Semaphore connectCalledSem = new Semaphore(0); + private final Semaphore connectCancelledSem = new Semaphore(0); + + @NonNull + @Override + protected String loadDocument(@NonNull URL documentURL) throws IOException { + // Busy-wait until disconnect is called (on another thread), then throw an IOException + final HttpURLConnection mockConn = mock(HttpURLConnection.class); + doAnswer(invocation -> { + connectCalledSem.release(); + connectCancelledSem.acquire(); + throw new IOException("Connect cancelled due to disconnect"); + }).when(mockConn).connect(); + doAnswer(invocation -> { + connectCancelledSem.release(); + return null; + }).when(mockConn).disconnect(); + final URL mockURL = mock(URL.class); + when(mockURL.openConnection()).thenReturn(mockConn); + return super.loadDocument(mockURL); + } + + public void launchAsyncAfterConnect(@NonNull Runnable r) { + new Thread(() -> { + try { + connectCalledSem.acquire(); + r.run(); + } catch (InterruptedException ignored) { + } + }).start(); + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4527aa6 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,31 @@ +# +# Copyright (c) 2022 Solana Mobile Inc. +# + +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +# Group name and project version, used when publishing. For official releases, the version should be +# provided to Gradle via '-P version="1.0"'. +group=com.solanamobile +version=main-SNAPSHOT \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5040c59 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +# +# Copyright (c) 2022 Solana Mobile Inc. +# + +#Fri Apr 22 16:33:17 PDT 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..d9666b7 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "Digital Asset Links for Android" +include ':digitalassetlinks'